Avant-propos

"API on Rails 6" est basé sur "APIs on Rails: Building REST APIs with Rails". Celui-ci fut initialement publié en 2014 par Abraham Kuri sous les licences MIT et Beerware.

Cette première version, non maintenue, était prévue pour la version 4 de Ruby on Rails qui ne reçoit plus que les mises à jour de sécurité. J’ai voulu mettre à jour cet excellent ouvrage et contribuer à la communauté francophone en le traduisant moi-même et en l’adaptant aux nouvelles versions de Ruby on Rails. Ce livre est donc disponible pour la version 5.2 de Ruby on Rail et la version 6.0 (celle que vous lisez actuellement).

Note
Ce livre est aussi disponible dans la langue de Shakespeare.

Dans cette version je n’ai pas seulement cherché à rendre les exemples compatibles mais j’ai aussi voulu simplifier la mise en place d’une API avec Rails en utilisant des librairies plus récentes et surtout: maintenues.

A propos de l’auteur original

Je m’appelle Alexandre Rousseau et je suis un développeur Rails passionné avec plus de 4 ans d’expérience (à l’heure où j’écris ces lignes). Je suis actuellement associé chez (iSignif SAS) où je construis et maintiens un produit de type SAAS en utilisant Rails. Je contribue aussi à la communauté Ruby en produisant et maintenant quelques gemmes que vous pouvez consulter sur mon profil Rubygems.org. La plupart de mes projets sont sur GitHub donc n’hésitez pas à me suivre.

Tout le code source de ce livre est disponible au format Asciidoctor sur GitHub. Ainsi n’hésitez pas à forker le projet si vous voulez l’améliorer ou corriger une faute qui m’aurait échappée.

Droits d’auteur et licence

Cette traduction est disponible sous licence MIT. Tout le code source de ce livre est disponible au format Asciidoctor sur GitHub

licence MIT

La licence MIT Copyright (c) 2019 Alexandre Rousseau

La permission est accordée, à titre gratuit, à toute personne obtenant une copie de ce logiciel et la documentation associée, pour faire des modifications dans le logiciel sans restriction et sans limitation des droits d’utiliser, copier, modifier, fusionner, publier, distribuer, concéder sous licence, et / ou de vendre les copies du Logiciel, et à autoriser les personnes auxquelles le Logiciel est meublé de le faire, sous réserve des conditions suivantes:

L’avis de copyright ci-dessus et cette autorisation doivent être inclus dans toutes les copies ou parties substantielles du Logiciel.

LE LOGICIEL EST FOURNI «TEL QUEL», SANS GARANTIE D’AUCUNE SORTE, EXPLICITE OU IMPLICITE, Y COMPRIS, MAIS SANS S’Y LIMITER, LES GARANTIES DE QUALITÉ MARCHANDE, ADAPTATION À UN USAGE PARTICULIER ET D’ABSENCE DE CONTREFAÇON. EN AUCUN CAS LES AUTEURS OU TITULAIRES DOIVENT ÊTRE TENUS RESPONSABLE DE TOUT DOMMAGE, RÉCLAMATION OU AUTRES RESPONSABILITÉS, SOIT DANS UNE ACTION DE CONTRAT, UN TORT OU AUTRE, PROVENANT DE, DE OU EN RELATION AVEC LE LOGICIEL OU L’UTILISATION OU DE TRANSACTIONS AUTRES LE LOGICIEL.

"API on Rails 6" de Alexandre Rousseau est mis à disposition selon les termes de la licence Creative Commons Attribution - Partage dans les Mêmes Conditions 4.0 International. Fondé sur une œuvre à http://apionrails.icalialabs.com/book/.

Merci

Un grand merci à tous les contributeurs de Github qui rendent ce livre vivant. Par ordre alphabétique :

Introduction

Bienvenue sur API on Rails 6, un tutoriel sous stéroïdes pour apprendre la meilleure façon de construire votre prochaine API avec Rails. Le but de ce livre est de vous fournir une méthodologie complète pour développer une API RESTful en suivant les meilleures pratiques.

Lorsque vous en aurez fini avec ce livre, vous serez en mesure de créer votre propre API et de l’intégrer à n’importe quel client comme un navigateur Web ou une application mobile. Le code généré est construit avec Ruby on Rails 6.0 qui est la version actuelle.

L’intention de ce livre n’est pas seulement de vous apprendre à construire une API avec Rails mais plutôt de vous apprendre comment construire une API évolutive et maintenable avec Rails. C’est-à-dire améliorer vos connaissances actuelles avec Rails. Dans ce voyage, vous allez apprendre à:

  • Utiliser Git pour le contrôle de version

  • Construire des réponses JSON

  • Tester vos points d’entrées avec des tests unitaires et fonctionnels

  • Mettre en place une authentification avec des JSON Web Tokens (JWT)

  • Utiliser les spécifications JSON:API

  • Optimiser et mettre en cache l’API

Je vous recommande fortement de suivre toutes les étapes de ce livre. Essayez de ne pas sauter des chapitres car je vais vous proposer des conseils et des astuces pour vous améliorer tout au long du livre. Vous pouvez vous considérer comme le personnage principal d’un jeu vidéo qui obtient un niveau supérieur à chaque chapitre.

Dans ce premier chapitre je vous expliquerai comment configurer votre environnement (au cas où vous ne l’auriez pas déjà fait). Nous allons ensuite créer une application appelée market_place_api. Je veillerai à vous enseigner les meilleures pratiques que j’ai pu apprendre au cours de mon expérience. Cela signifie qu’après avoir initialisé le projet, nous commencerons à utiliser Git.

Dans les prochains chapitres, nous allons construire l’application en suivant une méthode de travail simple que j’utilise quotidiennement. Nous développerons toute l’application en utilisant le développement piloté par les tests (TDD). Je vous expliquerai aussi l’intérêt d’utiliser une API pour votre prochain projet et de choisir un format de réponse adapté comme le JSON ou le XML. Plus loin, nous mettrons les mains dans le code et nous compléterons les bases de l’application en construisant toutes les routes nécessaires. Nous sécuriserons aussi l’accès à l’API en construisant une authentification par échange d’en-têtes HTTP. Enfin, dans le dernier chapitre, nous ajouterons quelques techniques d’optimisation pour améliorer la structure et les temps de réponse du serveur.

L’application finale sera une application de place de marché qui permettra à des vendeurs de mettre en place leur propre boutique en ligne. Les utilisateurs seront en mesure de passer des commandes, télécharger des produits et plus encore. Il existe de nombreuses options pour créer une boutique en ligne comme Shopify, Spree ou Magento.

Tout au long de ce voyage (cela dépend de votre expertise), vous allez vous améliorer et être en mesure de mieux comprendre certaines des meilleures ressources Rails. J’ai aussi pris certaines des pratiques que j’ai trouvées sur Railscasts, CodeSchool et JSON API.

Conventions sur ce livre

Les conventions de ce livre sont basées sur celles du Tutoriel Ruby on Rails. Dans cette section, je vais en mentionner quelques-unes que vous ne connaissez peut-être pas.

Je vais utiliser de nombreux exemples en utilisant des ligne de commande. Je ne vais pas traiter avec Windows cmd (désolé les gars). Je vais baser tous les exemples en utilisant l’invite de ligne de commande de style Unix. Voici un exemple:

$ echo "A command-line command"
A command-line command

J’utiliserai quelques principes spécifiques à Ruby. C’est-à-dire:

  • "Éviter" signifie que vous n’êtes pas censé le faire.

  • "Préférer" indique que parmi les deux options, la première est la plus appropriée.

  • "Utiliser" signifie que vous êtes en mesure d’utiliser la ressource.

Si vous rencontrez une erreur quelconque lors de l’exécution d’une commande, je vous recommande d’utiliser votre moteur de recherche pour trouver votre solution. Malheureusement, je ne peux pas couvrir toutes les erreurs possibles. Si vous rencontrez des problèmes avec ce tutoriel, vous pouvez toujours m’envoyer un email.

Environnements de développement

Pour presque tous les développeurs, l’une des parties les plus douloureuses est de mettre en place un environnement de développement confortable. Si vous le faites correctement, les prochaines étapes devraient être un jeu d’enfant. Je vais vous guider dans cette étape afin de vous faciliter la tâche et de vous motiver.

Éditeurs de texte et Terminal

Les environnements de développement diffèrent d’un ordinateur à l’autre. Ce n’est pas le cas avec les éditeurs de texte. Je pense que pour le développement avec Rails, un IDE est beaucoup trop lourd. Cependant certains pensent que c’est la meilleure façon de travailler. Si c’est votre cas, je vous recommande d’essayer RadRails ou RubyMine. Tous deux sont bien maintenus et possèdent de nombreuses intégrations par défaut. Maintenant, pour ceux qui, comme moi, préfèrent des outils simples, je peux vous dire qu’il existe beaucoup d’outils disponibles que vous pourrez personnaliser via des plugins et plus.

  • Éditeur de texte: J’utilise personnellement Vim comme éditeur. Au cas où vous n’êtes pas un fan de Vim, il y a beaucoup d’autres solutions comme Sublime Text qui est facile à prendre en main et surtout multi-plateforme . Il est fortement inspiré par TextMate. Une troisième option est d’utiliser un éditeur de texte plus récent comme Atom de GitHub. C’est un éditeur de texte prometteur fait en JavaScript. Il est facile à personnaliser pour répondre à vos besoins. N’importe lequel des éditeurs que je viens de vous présenter fera le travail. Choisissez donc celui où vous êtes le plus à l’aise.

  • Terminal: Je ne suis pas un fan de l’application Terminal par défaut sous Mac OS. Je recommande iTerm2, qui est un remplacement de terminal pour Mac OS. Si vous êtes sous Linux, vous avez probablement déjà un bon terminal.

Navigateur web

Quand au navigateur, je conseillerai directement Firefox. Mais d’autres développeurs utilisent Chrome ou même Safari. N’importe lequel d’entre eux vous aidera à construire l’application que vous voulez. Ils proposent tous un bon inspecteur pour le DOM, un analyseur de réseau et de nombreuses autres fonctionnalités que vous connaissez peut-être déjà.

Gestionnaire de paquets

  • Mac OS: Il existe de nombreuses options pour gérer la façon dont vous installez les paquets sur votre Mac, comme Mac Ports ou Homebrew. Les deux sont de bonnes options, mais je choisirais la dernière. J’ai rencontré moins de problèmes lors de l’installation de logiciels avec Homebrew. Pour installer brew il suffit d’exécuter la commande ci-dessous:

$ /usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"
  • Linux: Vous êtes déjà prêts! Peu importe si vous utilisez apt, pacman, yum tant que vous vous sentez à l’aise et que vous savez comment installer des paquets.

Git

Nous utiliserons beaucoup Git et vous devriez aussi l’utiliser (non seulement pour ce tutoriel mais aussi pour tous vos projets). Pour l’installer, c’est très facile:

  • sous Mac OS: $ brew install git

  • sous Linux: $ sudo apt-get install git

Ruby

Il existe de nombreuses façons d’installer et de gérer Ruby. Vous devriez probablement déjà avoir une version installée sur votre système. Pour connaître votre version, tapez simplement:

$ ruby -v

Rails 6.0 nécessite l’installation de la version 2.5 ou supérieure.

Pour l’installer, je vous recommande d’utiliser Ruby Version Manager (RVM) ou rbenv.

Le principe de ces outils est de permettre d’installer plusieurs versions de Ruby sur une même machine, dans un environnement hermétique à une éventuelle version installée sur votre système d’exploitation et de pouvoir basculer de l’une à l’autre facilement.

Dans ce tutoriel, nous allons utiliser RVM mais peu importe laquelle de ces deux options vous utiliserez.

Pour installer RVM, rendez vous sur https://rvm.io/ et installez la clé GPG [1]. Une fois cela fait:

$ gpg --keyserver hkp://keys.gnupg.net --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB
$ \curl -sSL https://get.rvm.io | bash

Ensuite, vous pouvez installer la dernière version de Ruby:

$ rvm install 2.6

Si tout s’est bien passé, il est temps d’installer le reste des dépendances que nous allons utiliser.

Gemmes, Rails et bibliothèques manquantes

Tout d’abord, nous mettons à jour les Gemmes sur l’ensemble du système:

$ gem update --system

Dans la plupart des cas, si vous êtes sous Mac OS, vous devriez installer des bibliothèques supplémentaires:

$ brew install libtool libxslt libksba openssl

Nous installons ensuite les gemmes nécessaires et ignorons la documentation pour chaque gemme:

$ printf 'gem: --no-document' >> ~/.gemrc
$ gem install bundler
$ gem install foreman
$ gem install rails -v 6.0.0.rc1
Note
Vous pouvez vous demander ce que signifie RC1. RC1 signifie Release Candidate. Au moment où j’écris ces lignes, la version finale pour Rails 6.0 n’est pas terminée. J’utilise donc la version la plus récente qui est 6.0.0.0.rc1

Vérifiez que tout fonctionne bien:

$ rails -v
Rails 6.0.0.rc1

Bases de données

Je vous recommande fortement d’installer Postgresql pour gérer vos bases de données. Mais ici, pour plus de simplicité, nous allons utiliser SQlite. Si vous utilisez Mac OS vous n’avez pas de bibliothèques supplémentaires à installer. Si vous êtes sous Linux, ne vous inquiétez pas, je vous guide:

$ sudo apt-get install libxslt-dev libxml2-dev libsqlite3-dev

ou

$ sudo yum install libxslt-devel libxml2-devel libsqlite3-devel

Initialisation du projet

Vous devez sans doute déjà savoir comment initialiser une application Rails. Si ce n’est pas le cas, jetez un coup d’œil à cette section.

La commande est donc la suivante:

$ mkdir ~/workspace
$ cd ~/workspace
$ rails new market_place_api --api
Note
L’option --api est apparue lors de la version 5 de Rails. Elle permet de limiter les librairies et Middleware inclus dans l’application. Cela permet aussi d’éviter de générer les vues HTML lors de l’utilisation des générateurs de Rails.

Comme vous pouvez le deviner, les commandes ci-dessus généreront les éléments indispensables à votre application Rails. La prochaine étape est d’ajouter quelques gemmes que nous utiliserons pour construire l’API.

Contrôle de version

Rappelez-vous que Git vous aide à suivre et à maintenir l’historique de votre code. Gardez à l’esprit que le code source de l’application est publié sur GitHub. Vous pouvez suivre le projet sur GitHub

Lorsque vous avez utilisé la commande rails new, Ruby on Rails a initialisé le répertoire Git pour vous. Cela signifie que vous n’avez pas besoin d’exécuter la commande git init.

Il faut néanmoins configurer les informations de l’auteur des commits. Si ce n’est pas déjà fait, placez vous dans le répertoire et lancez les commandes suivantes:

$ git config user.name "Type in your name"
$ git config user.email "Type in your email"

Rails fournit également un fichier .gitignore pour ignorer certains fichiers que nous ne voulons pas suivre. Le fichier .gitignore par défaut devrait ressembler à celui illustré ci-dessous :

.gitignore
# Ignore bundler config.
/.bundle

# Ignore the default SQLite database.
/db/*.sqlite3
/db/*.sqlite3-journal

# Ignore all logfiles and tempfiles.
/log/*
/tmp/*
!/log/.keep
!/tmp/.keep

# Ignore uploaded files in development.
/storage/*
!/storage/.keep
.byebug_history

# Ignore master key for decrypting credentials and more.
/config/master.key

Une fois Git mis en place, il suffit d’ajouter les fichiers et de valider les modifications. Les commandes nécessaires sont les suivantes:

$ git add .
$ git commit -m "Initial commit"
Note
J’ai appris que commencer un message par un verbe au présent décrit ce que fait le commit et non ce qu’il a fait. De cette façon il est plus facile de lire et de comprendre l’historique du projet (ou du moins pour moi). Je vais suivre cette pratique jusqu’à la fin du tutoriel.

Enfin, et c’est une étape optionnelle, nous déployons le projet sur GitHub (je ne vais pas l’expliquer ici) et poussons notre code vers le serveur distant. On commence donc par ajouter un serveur distant:

$ git remote add origin git@github.com:madeindjs/market_place_api_6.git

Ensuite nous poussons le code:

$ git push -u origin master

Au fur et à mesure que nous avançons dans le tutoriel, j’utiliserai les pratiques que j’utilise quotidiennement. Cela inclut le travail avec les branches, le rebasage, le squash et bien d’autres. Vous n’avez pas à vous inquiéter si vous ne connaissez pas tous ces termes, je les expliquerai le temps venu.

Conclusion

Cela a été un chapitre assez long. Si vous êtes arrivés ici, permettez-moi de vous féliciter. Les choses vont s’améliorer à partir de ce point. Commençons à mettre les mains dans le code!

L’API

Dans ce chapitre, je vais vous donner les grandes lignes de l’application. Vous devriez avoir lu le chapitre précédent. Si ce n’est pas le cas, je vous recommande de le faire.

Vous pouvez cloner le projet jusqu’ici avec:

$ git checkout tags/checkpoint_chapter02

Pour résumer, nous avons simplement généré notre application Rails et réalisé notre premier commit.

Planification de l’application

Notre application sera assez simple. Elle se composera de cinq modèles. Ne vous inquiétez pas si vous ne comprenez pas bien ce qui se passe, nous reverrons et développerons chacune de ces ressources au fur et à mesure que nous avancerons avec le tutoriel.

Schéma des liaisons entre les différents modèles

En bref, nous avons l’utilisateur (User) qui sera en mesure de passer de nombreuses commandes (Order), télécharger de multiples produits (product) qui peuvent avoir de nombreuses images (Image) ou commentaires (Comment) d’autres utilisateurs sur l’application.

Nous n’allons pas construire d’interface pour l’interaction avec l’API afin de ne pas surcharger le tutoriel. Si vous voulez construire des vues, il existe de nombreuses options comme des frameworks JavaScript (Angular, Vue.JS, React) ou des librairies mobiles (AFNetworking).

À ce stade, vous devriez vous poser cette question:

D’accord, mais j’ai besoin d’explorer et de visualiser l’API que je vais construire, non?

C’est juste. Si vous googlez quelque chose lié à l’exploration d’une API, vous allez trouver pas mal de résultats. Vous pouvez par exemple utiliser Postman qui est devenu incontournable. Mais nous n’allons pas l’utiliser. Dans notre cas nous allons utiliser cURL qui est un outil en ligne de commande disponible presque partout.

Mettre en place l’API

Une API est définie par wikipedia comme une interface de programmation d’application (API) qui est un ensemble normalisé de composants qui sert de façade par laquelle un logiciel offre des services à d’autres logiciels. En d’autres termes, il s’agit d’une façon dont les systèmes interagissent les uns avec les autres via une interface (dans notre cas un service web construit avec JSON). Il existe d’autres types de protocoles de communication comme SOAP, mais nous n’en parlons pas ici.

JSON est devenu incontournable en tant que format de fichier pour Internet en raison de sa lisibilité, de son extensibilité et de sa facilité à mettre en œuvre. Beaucoup de frameworks JavaScript l’utilisent comme protocole par défaut comme Angular ou EmberJS. D’autres grandes bibliothèques en Objective-C l’utilisent comme AFNetworking ou RESTKit. Il existe probablement de bonnes solutions pour Android mais en raison de mon manque d’expérience sur cette plate-forme de développement je ne peux pas vous recommander quelque chose.

Nous allons donc utiliser le format JSON pour construire notre API. La première idée qui pourrait vous venir à l’esprit serait de commencer à créer des routes en vrac. Le problème est qu’elles ne seraient pas normalisées. Un utilisateur ne pourrait pas deviner quelle ressource est renvoyée par une route.

C’est pourquoi une norme existe: REST (Representational State Transfer). REST impose une norme pour les routes qui créent, lisent, mettent à jour ou suppriment des informations sur un serveur en utilisant de simples appels HTTP. C’est une alternative aux mécanismes plus complexes comme SOAP, CORBA et RPC. Un appel REST est simplement une requête GET HTTP vers le serveur.

aService.getUser("1")

Et avec REST, vous pouvez appeler une URL avec une requête HTTP spécifique. Dans ce cas avec une requête GET:

http://domain.com/resources_name/uri_pattern

Les API RESTful doivent suivre au minimum trois règles:

  • Une URI de base comme http://example.com/resources/

  • Un type de média Internet pour représenter les données, il est communément JSON et est communément défini par l’échange d’en-têtes.

  • Suivez les méthodes HTTP standard telles que GET, POST, PUT, PUT, DELETE.

    • GET: Lit la ou les ressources définies par le modèle URI

    • POST: Crée une nouvelle entrée dans la collection de ressources

    • PUT: Met à jour une collection ou un membre des ressources

    • DELETE: Détruit une collection ou un membre des ressources

Cela peut sembler compliqué mais au fur et à mesure que nous avancerons dans le tutoriel cela deviendra beaucoup plus facile à comprendre.

Routes, contraintes et Namespaces

Avant de commencer à taper du code, nous allons préparer le répertoire Git. Le workflow que nous allons suivre est le suivant:

  • Nous allons créer une branche par chapitre

  • Une fois terminé, nous pousserons la branche sur GitHub

  • Nous la fusionnerons avec master

Commençons donc par ouvrir le terminal dans le répertoire market_place_api et tapez la commande suivante pour créer la branche:

$ git checkout -b chapter02
Switched to a new branch 'chapter02'

Nous allons seulement travailler sur le fichier config/routes.rb car nous allons simplement définir les contraintes et le format de réponse par défaut pour chaque requête.

config/routes.rb
Rails.application.routes.draw do
  # ...
end

Effacez tout le code commenté qui se trouve dans le fichier. Nous n’en aurons pas besoin. Ensuite, faites un commit, juste pour vous échauffer:

$ git commit -am "Removes comments from the routes file"

Nous allons isoler les contrôleurs API dans des Namespace. Avec Rails, c’est assez simple. Il suffit de créer un dossier sous app/controllers nommé api. Le nom est important car c’est le Namespace que nous allons utiliser pour gérer les contrôleurs pour les points d’entrée de l’API

$ mkdir app/controllers/api

Nous ajoutons ensuite ce Namespace dans notre fichier routes.rb:

config/routes.rb
Rails.application.routes.draw do
  # Api definition
  namespace :api do
    # We are going to list our resources here
  end
end

En définissant un Namespace dans le fichier routes.rb, Rails mappera automatiquement ce Namespace à un répertoire correspondant au nom sous le dossier contrôleur (dans notre cas le répertoire api/).

Les types de medias supportés par Rails

Rails supporte jusqu’à 35 types de médias différents! Vous pouvez les lister en accédant à la classe SET sous le module de Mime:

$ rails c
2.6.3 :001 > Mime::SET.collect(&:to_s)
 => ["text/html", "text/plain", "text/javascript", "text/css", "text/calendar", "text/csv", "text/vcard", "text/vtt", "image/png", "image/jpeg", "image/gif", "image/bmp", "image/tiff", "image/svg+xml", "video/mpeg", "audio/mpeg", "audio/ogg", "audio/aac", "video/webm", "video/mp4", "font/otf", "font/ttf", "font/woff", "font/woff2", "application/xml", "application/rss+xml", "application/atom+xml", "application/x-yaml", "multipart/form-data", "application/x-www-form-urlencoded", "application/json", "application/pdf", "application/zip", "application/gzip"]

C’est important parce que nous allons travailler avec JSON, l’un des types MIME intégrés par Rails. Ainsi nous avons juste besoin de spécifier ce format comme format par défaut:

config/routes.rb
Rails.application.routes.draw do
  # Api definition
  namespace :api, defaults: { format: :json }  do
    # We are going to list our resources here
  end
end

Jusqu’à présent, nous n’avons rien fait de fou. Nous voulons maintenant générer un base_uri qui inclut la version de l’API comme ceci: http://localhost:3000/api/v1.

Note
Régler l’API sous un sous-domaine est une bonne pratique car cela permet d’adapter l’application à un niveau DNS. Mais dans notre cas, nous allons simplifier les choses pour l’instant.

Vous devriez vous soucier de versionner votre application dès le début car cela donnera une meilleure structure à votre API. Lorsque des changements interviendront sur votre API, vous pouvez ainsi proposer aux développeurs de s’adapter aux nouvelles fonctionnalités pendant que les anciennes sont dépréciées.

config/routes.rb
Rails.application.routes.draw do
  namespace :api, defaults: { format: :json } do
    namespace :v1 do
      # We are going to list our resources here
    end
  end
end
Les conventions des API

Vous pouvez trouver de nombreuses approches pour configurer la base_uri d’une API. En supposant que nous versionnons notre api:

  • api.example.com/: Je suis d’avis que c’est la voie à suivre, elle vous donne une meilleure interface et l’isolement, et à long terme peut vous aider à mettre rapidement à l’échelle

  • example.com/api/: Ce modèle est très commun. C’est un bon moyen de commencer quand vous ne voulez pas de Namespace de votre API avec sous un sous-domaine

  • example.com/api/v1: Cela semble être une bonne idée. Définir la version de l’API par l’URL semble être un modèle plus descriptif. Cependant, vous forcez à inclure la version à l’URL sur chaque demande. Cela devient un problème si vous décidez de changer ce modèle

Il est temps de faire un commit:

$ git add config/routes.rb
$ git commit -m "Set the routes constraints for the api"

Afin de définir la version de l’API, nous devons d’abord ajouter un autre répertoire sous le dossier api/ que nous avons créé:

$ mkdir app/controllers/api/v1

L’API est désormais scopée via l’URL. Par exemple, avec la configuration actuelle, la récupération d’un produit via l’API se ferait avec cette url: http://localhost:3000/v1/products/1.

Ne vous inquiétez pas, nous rentrerons plus en détails à propos du versionnement plus tard. Il est temps de commiter:

$ git commit -am "Set the routes namespaces for the api"
Note
Il existe certaines pratiques dans la construction d’API qui recommandent de ne pas versionner l’API via l’URL. C’est vrai. Le développeur ne devrait pas être au courant de la version qu’il utilise. Dans un souci de simplicité, j’ai choisi de mettre de côté cette convention que nous pourrons appliquer dans un second temps.

Nous arrivons à la fin de notre chapitre. Il est donc temps d’appliquer toutes nos modifications sur la branche master en faisant un merge. Pour cela, on se place sur la branche master et on merge chapter02:

$ git checkout master
$ git merge chapter02

Conclusion

Ça a été un peu long, je sais, mais vous avez réussi! N’abandonnez pas, c’est juste notre petite fondation pour quelque chose de grand, alors continuez comme ça. Sachez qu’il y a des gemmes qui gèrent ce genre de configuration pour nous:

Je n’en parle pas ici puisque nous essayons d’apprendre comment mettre en œuvre ce genre de fonctionnalité.

Présentation des utilisateurs

Dans le chapitre précédent, nous avons réussi à mettre en place les bases de la configuration de notre application.

Dans les prochains chapitres, nous traiterons l’authentification des utilisateurs à l’aide de jetons d’authentification ainsi que la définition de permissions pour limiter l’accès aux utilisateurs connectés. Nous relierons ensuite les produits aux utilisateurs et leur donnerons la possibilité de passer des commandes.

Vous pouvez cloner le projet jusqu’à ce point avec:

$ git checkout tags/checkpoint_chapter03

Comme vous pouvez déjà l’imaginer, il existe de nombreuses solutions d’authentification pour Rails comme AuthLogic, Clearance et Devise. Ces solutions sont des librairies clé en main, c’est à dire qu’elles permettent de gérer tout un tas de choses comme l’authentification, la fonctionnalité d’oubli de mot de passe, la validation, etc..

Nous ne les utiliserons pas afin de mieux appréhender le mécanisme d’authentification. Néanmoins nous allons utiliser la gemme bcrypt afin de hasher le mots de passe de l’utilisateur.

Ce chapitre sera complet. Il sera peut-être long mais je vais essayer d’aborder autant de sujets que possible. N’hésitez pas à vous prendre un café et allons-y. A la fin de ce chapitre, vous aurez construit toute la logique des utilisateurs ainsi que la validation et la gestion des erreurs.

Nous voulons suivre ce chapitre, c’est donc un bon moment pour créer une nouvelle branche:

$ git checkout -b chapter03
Note
Assurez-vous simplement d’être sur la branche master avant.

Modèle d’utilisateur

Génération du modèle User

Nous allons commencer par générer notre modèle User. Ce modèle sera vraiment basique et contiendra seulement deux champs:

  • email qui sera unique et lui permettra de se connecter à l’application

  • password_digest qui contiendra la version haché du mot de passe (nous en reparlerons plus tard dans ce chapitre)

Afin de générer notre modèle User, nous utiliserons la méthode generate model fournie par Ruby on Rails. Elle s’utilise très facilement:

$ rails generate model User email:string password_digest:string
invoke  active_record
      create    db/migrate/20190603195146_create_users.rb
      create    app/models/user.rb
      invoke    test_unit
      create      test/models/user_test.rb
      create      test/fixtures/users.yml
Note
Dans le design patern MVC, le modèle est l’élément qui contient les données ainsi que de la logique en rapport avec les données: validation, lecture et enregistrement. Nous allons donc dans un premier temps créer cette partie.

Cette commande génère beaucoup de fichiers! Ne vous inquiétez pas, nous allons les passer en revue les un après les autres.

Le fichier de migration contenu dans le dossier db/migrate contient la migration qui décrit les modifications qui seront effectuées sur la base de données. Ce fichier devrait ressembler à ceci

db/migrate/20190603195146_create_users.rb
class CreateUsers < ActiveRecord::Migration[6.0]
  def change
    create_table :users do |t|
      t.string :email
      t.string :password_digest

      t.timestamps
    end
  end
end
Note
la date insérée au début du nom du fichier de la migration devrait être différente chez vous puisque elle correspond à la date de création de la migration.

Nous allons modifier un petit peu cette migration afin d’ajouter quelques validations côté base de données. Avec Rails il est d’usage de faire ces verifications directement dans le modèle Ruby mais c’est une bonne pratique de le faire aussi dans le schéma de la base de données.

Nous allons donc rajouter deux contraintes:

  • l’email est présent: on utilise la propriété null: false

  • l’email est unique: on utilise la propriété unique: true

  • le mot de passe est obligatoire: on utilise la propriété null: false

La migration devient donc:

db/migrate/20190603195146_create_users.rb
# ...
create_table :users do |t|
  t.string :email, unique: true, null: false
  t.string :password_digest, null: false
  # ...
end

Une fois la migration terminée, nous pouvons effectuer les modifications avec la commande suivante:

db/migrate/20190603195146_create_users.rb
$ rake db:migrate
== 20190603195146 CreateUsers: migrating ======================================
-- create_table(:users)
   -> 0.0027s
== 20190603195146 CreateUsers: migrated (0.0028s) =============================
Note
La commande va convertir notre migration en requête SQL qui va mettre à jour la base de données SQlite3 contenue dans le dossier db.

Le modèle

Nous avons donc défini notre schéma de la base de données. La prochaine étape est de mettre à jour notre modèle afin de définir les règles de validation. Ces règles se définissent dans le modèle situé dans le dossier app/models.

Ruby on Rails propose un mécanisme complet de validation que vous pouvez consulter sur leur documentation officielle. Dans notre cas, nous voulons valider deux choses:

  • le courriel doit avoir un format valide

  • le courriel doit être unique

  • le mot de passe doit être rempli

Ces trois règles se définissent par le code suivant:

app/models/user.rb
class User < ApplicationRecord
  validates :email, uniqueness: true
  validates_format_of :email, with: /@/
  validates :password_digest, presence: true
end

Et voilà. Rails utilise une syntaxe très simple et le code est très lisible.

Validation du courriel

Vous remarquerez peut être que la validation du courriel utilise une validation simpliste en ne vérifiant que la présence d’un @.

C’est normal.

Une infinité d’exceptions existent en la matière d’adresse mail si bien que même "Look at all these spaces!"@example.com est une adresse valide. Il vaut mieux donc privilégier une approche simple et confirmer le mail par un mail de validation.

Les tests unitaires

Nous terminons par les tests unitaires. Nous utilisons ici le framework de test Minitest qui est fourni par défaut avec Rails.

Minitest s’appuie sur des Fixtures qui permettent de remplir votre base de données avec des données prédéfinies. Les Fixtures sont définies dans des fichiers YAML dans le dossier tests/fixtures. Il y a un fichier par modèle.

Nous devons donc commencer par mettre à jour nos tests/fixtures.

Note
Les fixtures ne sont pas conçues pour créer tous les données dont vos tests ont besoin. Elle permettent juste de définir les données basiques dont votre application a besoin.

Nous allons donc commencer par créer une fixture définissant un utilisateur:

test/fixtures/users.yml
one:
  email: one@one.org
  password_digest: hashed_password

Nous pouvons donc maintenant créer les tests. Il y en aura trois:

  • nous vérifions qu’un utilisateur avec des données valides est valide:

test/models/user_test.rb
# ...
test 'user with a valid email should be valid' do
  user = User.new(email: 'test@test.org', password_digest: 'test')
  assert user.valid?
end
  • nous vérifions qu’un utilisateur avec un courriel invalide n’est pas valide:

test/models/user_test.rb
# ...
test 'user with unvalid email should be unvalid' do
  user = User.new(email: 'test', password_digest: 'test')
  assert_not user.valid?
end
  • nous vérifions qu’un utilisateur avec un courriel déjà utilisé n’est pas valide. Nous utilisons donc le même courriel que la fixture que nous venons de créer.

test/models/user_test.rb
# ...
test 'user with taken email should be unvalid' do
  other_user = users(:one)
  user = User.new(email: other_user.email, password_digest: 'test')
  assert_not user.valid?
end

Et voilà. Nous pouvons vérifier que notre implémentation est correcte juste en lançant simplement les tests unitaires que nous venons de créer:

$ rake test
...
3 runs, 3 assertions, 0 failures, 0 errors, 0 skips

Je pense qu’il est temps de faire un petit commit afin de valider notre avancement:

$ git add . && git commit -m "Create user model"

Hachage du mot de passe

Nous avons précédemment mis en place le stockage des données de l’utilisateur. Il nous reste un problème à régler: le stockage des mot de passe est en clair.

Si vous stockez les mots de passe des utilisateurs en clair, alors un attaquant qui vole une copie de votre base de données a une liste géante d’emails et de mots de passe. Certains de vos utilisateurs n’auront qu’un seul mot de passe — pour leur compte de courriel, pour leur compte bancaire, pour votre application. Un simple piratage pourrait dégénérer en un vol d’identité massif. - source - Why you should use bcrypt

Nous allons donc utiliser la gemme bcrypt afin de hacher le mot de passe.

Note
Le hachage consiste à transformer une chaîne de caractère en Hash. Ce Hash ne permet pas de retrouver la chaîne de caractère d’origine. Cependant nous pouvons facilement l’utiliser afin de savoir si une chaîne de caractère donnée correspond au hash que nous avons stocké.

Nous devons d’abord ajouter la gemme Bcrypt au Gemfile. Nous pouvons utiliser la commande bundle add. Celle-ci va s’occuper:

  1. d’ajouter la gemme au Gemfile en récupérant la version actuelle

  2. lancer la commande bundle install qui va installer la gemme et mettre à jour le fichier Gemfile.lock qui "verrouille" la version actuelle de la gemme

Lancez donc la commande suivante:

$ bundle add bcrypt

Une fois la commande exécutée, la ligne suivante est ajoutée à la fin du Gemfile:

Gemfile
gem "bcrypt", "~> 3.1"
Note
La version 3.1 de bcrypt est celle actuelle à l’heure où j’écris ces lignes. Elle peut donc varier pour votre cas.

Active Record nous propose une méthode ActiveModel::SecurePassword::has_secure_password qui va s’interfacer avec Bcrypt et hacher le mot de passe pour nous très facilement.

app/models/user.rb
class User < ApplicationRecord
  # ...
  has_secure_password
end

has_secure_password ajoute les validations suivantes:

  • Le mot de passe doit être présent lors de la création.

  • La longueur du mot de passe doit être inférieure ou égale à 72 octets.

  • la confirmation du mot de passe à l’aide de l’attribut password_confirmation (s’il est envoyé)

De plus, cette méthode va ajouter un attribut User#password qui sera automatiquement haché et sauvegardé dans l’attribut User#password_digest.

Essayons cela tout de suite dans la console de Rails. Ouvrez une console avec rails console:

2.6.3 :001 > User.create! email: 'toto@toto.org', password: '123456'
 =>#<User id: 1, email: "toto@toto.org", password_digest: [FILTERED], created_at: "2019-06-04 10:51:44", updated_at: "2019-06-04 10:51:44">

Vous pouvez ainsi constater que lorsqu’on appelle la méthode User#create!, l’attribut password est haché et stocké dans password_digest. Nous pouvons aussi envoyer un attribut password_confirmation que ActiveRecord va comparer à password:

2.6.3 :002 > User.create! email: 'tata@tata.org', password: '123456', password_confirmation: 'azerty'
ActiveRecord::RecordInvalid (Validation failed: Password confirmation doesn t match Password)

Tout fonctionne comme prévus! Faisons maintenant un commit afin de garder un historique concis:

$ git commit -am "Setup Bcrypt"

Construire les utilisateurs

Il est temps de faire notre premier point d’entrée. Nous allons juste commencer à construire l’action show pour l’utilisateur qui va afficher un utilisateur en JSON. Nous allons d’abord

  1. générer le users_controller

  2. ajouter les tests correspondants

  3. construire le code réel.

Occupons nous tout d’abord de générer le contrôleur et les test fonctionnels.

Afin de respecter le visionnement de notre API, nous allons découper notre application en utilisant des modules. La syntaxe est donc la suivante:

$ rails generate controller api::v1::users

Cette commande va créer le fichier users_controller_test.rb. Avant d’entrer dans le vif du sujet, il y a deux choses que nous voulons tester pour une API:

  • La structure du JSON renvoyée par le serveur

  • Le code de réponse HTTP renvoyé par le serveur

Les codes HTTP courants

Le premier chiffre du code d’état spécifie l’une des cinq classes de réponse. Le strict minimum pour un client HTTP est qu’il utilise une ces cinq classes. Voici une liste des codes HTTP couramment utilisés:

  • 200: Réponse standard pour les requêtes HTTP réussies. C’est généralement sur les requêtes GET

  • 201: La demande a été satisfaite et a donné lieu à la création d’une nouvelle ressource. Après les demandes de POST

  • 204: Le serveur a traité la requête avec succès, mais ne renvoie aucun contenu. Il s’agit généralement d’une requête DELETE réussie.

  • 400: La requête ne peut pas être exécutée en raison d’une mauvaise syntaxe. Peut arriver pour tout type de requête.

  • 401: Similaire au 403, mais spécifiquement pour une utilisation lorsque l’authentification est requise et qu’elle a échoué ou n’a pas encore été fournie. Peut arriver pour tout type de requête.

  • 404: La ressource demandée n’a pas pu être trouvée mais peut être à nouveau disponible à l’avenir. Habituellement, concerne les requêtes GET

  • 500: Un message d’erreur générique, donné lorsqu’une condition inattendue a été rencontrée et qu’aucun autre message spécifique ne convient.

Pour une liste complète des codes de réponse HTTP, consultez l' article sur Wikipedia.

Nous allons donc implémenter le test fonctionnel qui vérifie l’accès à la méthode Users#show. pour cela,

test/controllers/api/v1/users_controller_test.rb
# ...
class UsersControllerTest < ActionDispatch::IntegrationTest
  setup do
    @user = users(:one)
  end

  test "should show user" do
    get api_v1_user_url(@user), as: :json
    assert_response :success
    # on teste que la réponse contient le courriel
    json_response = JSON.parse(self.response.body)
    assert_equal @user.email, json_response['email']
  end
end

Il suffit ensuite d’ajouter l’action à notre contrôleur. C’est extrêmement simple:

app/controllers/api/v1/users\_controller.rb
class  Api::V1::UsersController < ApplicationController
  def show
    render json: User.find(params[:id])
  end
end

Si vous exécutez les tests avec rails test vous obtenez l’erreur suivante:

$ rails test

...E

Error:
UsersControllerTest#test_should_show_user:
DRb::DRbRemoteError: undefined method `api_v1_user_url' for #<UsersControllerTest:0x000055ce32f00bd0> (NoMethodError)
    test/controllers/users_controller_test.rb:9:in `block in <class:UsersControllerTest>'

Ce type d’erreur est très courant lorsque vous générez vos ressources à la main! En effet, nous avons totalement oublié les routes. Alors ajoutons-les:

config/routes.rb
Rails.application.routes.draw do
  namespace :api, defaults: { format: :json } do
    namespace :v1 do
      resources :users, only: [:show]
    end
  end
end

Vos tests devraient désormais passer:

$ rails test
....
4 runs, 5 assertions, 0 failures, 0 errors, 0 skips

Comme d’habitude, après avoir ajouté une des fonctionnalités dont nous sommes satisfaits, nous faisons un commit:

$ git add . && git commit -m "Adds show action the users controller"

Tester notre ressource avec cURL

Nous avons donc enfin une ressource à tester. Nous avons plusieurs solutions pour la tester. La première qui me vient à l’esprit est l’utilisation de cURL qui est intégré dans presque toutes les distributions Linux. Alors, essayons:

$ curl http://localhost:3000/api/v1/users/1
{"id":1,"email":"toto@toto.org", ...

Nous retrouvons bien l’utilisateur que nous avons crée avec la console Rails dans la section précédente. Vous avez maintenant une entrée d’API d’enregistrement d’utilisateur.

Créer les utilisateurs

Maintenant que nous avons une meilleure compréhension de la façon de construire des points d’entrée, il est temps d’étendre notre API. Une des fonctionnalités les plus importante est de laisser les utilisateurs créer un profil sur notre application. Comme d’habitude, nous allons écrire des tests avant d’implémenter notre code pour étendre notre suite de tests.

Assurez-vous que votre répertoire Git est propre et que vous n’avez pas de fichier en staging. Si c’est le cas, committez-les pour que nous puissions recommencer à zéro.

Commençons donc par écrire notre test en ajoutant une entrée pour créer un utilisateur sur le fichier users_controller_test.rb :

test/controllers/users_controller_test.rb
# ...
class UsersControllerTest < ActionDispatch::IntegrationTest
  # ...
  test "should create user" do
    assert_difference('User.count') do
      post api_v1_users_url, params: { user: { email: 'test@test.org', password: '123456' } }, as: :json
    end
    assert_response :created
  end

  test "should not create user with taken email" do
    assert_no_difference('User.count') do
      post api_v1_users_url, params: { user: { email: @user.email, password: '123456' } }, as: :json
    end
    assert_response :unprocessable_entity
  end
end

Cela fait beaucoup de code. Ne vous inquiétez pas, je vous explique tout:

  • dans le premier test nous vérifions la création d’un utilisateur en envoyant une requête POST valide. Ensuite, nous vérifiions qu’un utilisateur supplémentaire existe en base et que le code HTTP de la réponse est created

  • dans le premier test nous vérifions que l’utilisateur n’est pas créé en utilisant un courriel déjà utilisé. Ensuite, nous vérifions que le code HTTP de la réponse est created

A ce moment là, les tests doivent échouer:

$ rails test
...E

Il est donc temps d’implémenter le code pour que nos tests réussissent:

app/controllers/api/v1/users_controller.rb
class Api::V1::UsersController < ApplicationController
  # ...

  # POST /users
  def create
    @user = User.new(user_params)

    if @user.save
      render json: @user, status: :created
    else
      render json: @user.errors, status: :unprocessable_entity
    end
  end

  private

  # Only allow a trusted parameter "white list" through.
  def user_params
    params.require(:user).permit(:email, :password)
  end
end

Rappelez-vous qu’à chaque fois que nous ajoutons une entrée dans notre API il faut aussi ajouter cette action dans notre fichier routes.rb.

config/routes.rb
Rails.application.routes.draw do
  namespace :api, defaults: { format: :json } do
    namespace :v1 do
      resources :users, only: %i[show create]
    end
  end
end

Comme vous pouvez le constater, l’implémentation est assez simple. Nous avons également ajouté la méthode privée user_params pour protéger les assignations d’attributs en masse. Maintenant, nos tests devraient passer:

$ rails test
......
6 runs, 9 assertions, 0 failures, 0 errors, 0 skips

Oura! Committons les changements et continuons à construire notre application:

$ git commit -am "Adds the user create endpoint"

Mettre à jour les utilisateurs

Le schéma de mise à jour des utilisateurs est très similaire à celui de la création. Si vous êtes un développeur de Rails expérimenté, vous connaissez peut-être déjà les différences entre ces deux actions:

  • L’action de mise à jour répond à une requête PUT/PATCH .

  • Seul un utilisateur connecté devrait être en mesure de mettre à jour ses informations. Ce qui signifie que nous devrons forcer un utilisateur à s’authentifier. Nous en parlerons au chapitre 5.

Comme d’habitude, nous commençons par écrire nos tests:

test/controllers/users_controller_test.rb
# ...
class UsersControllerTest < ActionDispatch::IntegrationTest
  # ...
  test "should update user" do
    patch api_v1_user_url(@user), params: { user: { email: @user.email, password: '123456' } }, as: :json
    assert_response :success
  end

  test "should not update other user" do
    patch api_v1_user_url(@user), params: { user: { email: 'bad_email', password: '123456' } }, as: :json
    assert_response :unprocessable_entity
  end
end

Pour que les tests réussissent, nous devons construire l’action de mise à jour sur le fichier users_controller.rb et ajouter la route au fichier routes.rb. Comme vous pouvez le voir, nous avons trop de code dupliqué, nous remanierons nos tests au chapitre 4. Tout d’abord nous ajoutons l’action le fichier routes.rb:

config/routes.rb
Rails.application.routes.draw do
  # ...
  resources :users, only: %i[show create update]
  # ...
end

Ensuite nous implémentons l’action de mise à jour sur le contrôleur utilisateur et faisons passer nos tests:

app/controllers/api/v1/users_controller.rb
class Api::V1::UsersController < ApplicationController
  before_action :set_user, only: %i[show update]

  def show
    render json: @user
  end

  # ...

  def create
    @user = User.new(user_params)

    if @user.save
      render json: @user, status: :created
    else
      render json: @user.errors, status: :unprocessable_entity
    end
  end

  private
  # ...

  def set_user
    @user = User.find(params[:id])
  end
end

Tous nos tests devraient maintenant passer:

$ rails test
........
8 runs, 11 assertions, 0 failures, 0 errors, 0 skips

Vue que tout fonctionne, on effectue un commit:

$ git commit -am "Adds update action the users controller"

Supprimer l’utilisateur

Jusqu’à présent, nous avons construit pas mal d’actions sur le contrôleur des utilisateurs avec leurs tests mais ce n’est terminé. Il nous en manque juste une dernière qui est l’action de destruction. Créons donc le test:

test/controllers/users_controller_test.rb
# ...
class UsersControllerTest < ActionDispatch::IntegrationTest
  # ...

  test "should destroy user" do
    assert_difference('User.count', -1) do
      delete api_v1_user_url(@user), as: :json
    end
    assert_response :no_content
  end
end

Comme vous pouvez le voir, le test est très simple. Nous ne répondons qu’avec un statut de 204 qui signifie No Content. Nous pourrions aussi retourner un code d’état de 200, mais je trouve plus naturel de répondre No Content dans ce cas car nous supprimons une ressource et une réponse réussie peut suffire.

La mise en œuvre de l’action de destruction est également assez simple:

app/controllers/api/v1/users_controller.rb
class Api::V1::UsersController < ApplicationController
  before_action :set_user, only: %i[show update destroy]
  # ...

  def destroy
    @user.destroy
    head 204
  end

  # ...
end

N’oubliez pas d’ajouter l’action destroy dans le fichier routes.rb:

config/routes.rb
Rails.application.routes.draw do
  # ...
  resources :users, only: %i[show create update destroy]
  # ...
end

Si tout est correct, vos tests devraient passer:

$ rails test
.........
9 runs, 13 assertions, 0 failures, 0 errors, 0 skips

Rappelez-vous qu’après avoir apporté quelques modifications à notre code, il est de bonne pratique de les commiter afin que nous gardions un historique bien découpé.

$ git commit -am "Adds destroy action to the users controller"

Et comme nous arrivons à la fin de notre chapitre, il est temps d’appliquer toutes nos modifications sur la branche master en faisant un merge:

$ git checkout master
$ git merge chapter03

Conclusion

Oh vous êtes là! Bien joué! Je sais que c’était probablement long mais n’abandonnez pas! Assurez-vous que vous comprenez chaque morceau de code, les choses vont s’améliorer, dans le prochain chapitre, nous remanierons nos tests pour rendre le code plus lisible et plus maintenable. Alors restez avec moi!

Authentification des utilisateurs

Cela fait longtemps que vous avez commencé. J’espère que vous appréciez ce voyage autant que moi.

Dans le chapitre précédent nous avons mis en place des entrée de ressources utilisateur. Si vous avez sauté ce chapitre ou si vous n’avez pas tout compris, je vous recommande vivement de le regarder. Il couvre les premières bases des tests et c’est une introduction aux réponses JSON.

Vous pouvez cloner le projet jusqu’ici:

$ git checkout tags/checkpoint_chapter04

Dans ce chapitre, les choses vont devenir plus intéressantes. Nous allons mettre en place notre mécanisme d’authentification. À mon avis, ce sera l’un des chapitres les plus intéressants car nous allons introduire beaucoup de nouveaux concepts. A la fin, vous aurez un système d’authentification simple mais puissante. Ne paniquez pas, nous y arriverons.

Commençons par le commencement. Comme d’habitude lorsque nous démarrons un nouveau chapitre, nous allons créer une nouvelle branche:

$ git checkout -b chapter04

Session sans état

Avant d’aller plus loin, quelque chose doit être clair: une API ne gère pas les sessions. Cela peut paraître un peu fou si vous n’avez pas d’expérience dans la création de ce genre d’applications. Une API doit être sans état. Ce qui signifie, par définition, qu’une API qui fournit une réponse après votre demande ne nécessite aucune autre attention. Cela a pour conséquence qu’aucun état antérieur ou futur n’est nécessaire pour que le système fonctionne.

Le processus d’authentification de l’utilisateur via une API est très simple:

  1. Le client demande une ressource de sessions avec les informations d’identification correspondantes (généralement un e-mail et un mot de passe).

  2. Le serveur renvoie la ressource utilisateur avec son jeton d’authentification correspondant.

  3. Pour chaque page qui nécessite une authentification, le client doit envoyer ce jeton d’authentification.

Dans cette section et la suivante, nous nous concentrerons sur la construction d’un contrôleur de sessions avec ses actions correspondantes. Nous compléterons ensuite le flux de demandes en ajoutant l’accès d’autorisation nécessaire.

Présentation de JSON Web Token

Lorsqu’on parle de jeton d’authentification, un standard existe: le JSON Web Token (JWT).

JWT est un standard ouvert défini dans la RFC 75191. Il permet l’échange sécurisé de jetons (tokens) entre plusieurs parties. - Wikipedia

Globalement, un jeton JWT est composé de trois parties:

  • un en-tête structuré en JSON qui contiendra par exemple la date de validité du jeton.

  • un payload structuré en JSON qui peut contenir n’importe quelle donnée. Dans notre cas, il contiendra l’identifiant de l’utilisateur "connecté".

  • une signature qui nous permettra de vérifier que le jeton a bien été chiffré par notre application et donc qu’il est valide.

Ces trois parties sont chacune encodées en base64 puis concaténées en utilisant des points (.). Ce qui nous donne quelque chose comme ça:

Un jeton JWT valide
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Une fois décodé, ce jeton nous donne les informations suivantes:

L’en-tête du jeton JWT
{ "alg": "HS256", "typ": "JWT" }
Le payload du jeton JWT
{ "sub": "1234567890", "name": "John Doe", "iat": 1516239022 }
Note
Pour plus d’information à propos des jetons JWT je vous invite à consulter jwt.io

Cela possède beaucoup d’avantages comme par exemple le fait d’envoyer des informations au consommateur de l’API directement dans le token. On pourra par exemple choisir d’intégrer les informations de l’utilisateur dans le payload.

Mise en place du jeton d’authentification

La norme JWT possède beaucoup d’implémentation dans des langages et des librairies diverses. Bien entendu, il existe une gemme Ruby à ce sujet: ruby-jwt.

Commençons donc par l’installer:

$ bundle add jwt

Une fois effectué, la ligne suivante est ajoutée dans votre Gemfile:

gem "jwt", "~> 2.2"

La libraire s’utilise très facilement avec les méthodes JWT.encode et JWT.decode. Ouvrons un terminal avec rails console.

2.6.3 :001 > token = JWT.encode({message: 'Hello World'}, 'my_secret_key')
2.6.3 :002 > JWT.decode(token, 'my_secret_key')
 => [{"message"=>"Hello World"}, {"alg"=>"HS256"}]

Lors de la première ligne nous avons encodé un payload avec la clé secrète my_secret_key. Nous obtenons donc un jeton que nous pouvons décoder, tout simplement. La deuxième ligne s’occupe de décoder le jeton et nous voyons que nous retrouvons bien notre payload.

Nous allons maintenant englober toute cette logique dans une classe JsonWebToken dans un nouveau fichier situé dans lib/. Cela nous permettra d’éviter de dupliquer le code. Cette classe s’occupera juste d’encoder et de décoder les jetons JWT. Voici donc l’implémentation.

lib/json_web_token.rb
class JsonWebToken
  SECRET_KEY = Rails.application.credentials.secret_key_base.to_s

  def self.encode(payload, exp = 24.hours.from_now)
    payload[:exp] = exp.to_i
    JWT.encode(payload, SECRET_KEY)
  end

  def self.decode(token)
    decoded = JWT.decode(token, SECRET_KEY).first
    HashWithIndifferentAccess.new decoded
  end
end

Cela fait beaucoup de code, je sais, mais nous allons le revoir ensemble.

  • la méthode JsonWebToken.encode va s’occuper d’encoder le payload en rajoutant une expiration à 24 heures par défaut. Nous utilisons aussi la même clé de chiffrement que celle configurée avec Rails

  • la méthode JsonWebToken.decode va décoder le jeton JWT et récupérer le payload. Nous utilisons ensuite la classe HashWithIndifferentAccess fournie par Rails qui permet de récupérer une valeur d’un Hash avec un Symbol ou un String.

Et voilà. Afin de charger le fichier dans notre application, il faut spécifier le dossier lib dans la liste des autoload de Ruby on Rails. Pour cela, rajoutez la configuration suivante dans le fichier application.rb:

lib/json_web_token.rb
# ...
module MarketPlaceApi
  class Application < Rails::Application
    # ...
    config.eager_load_paths << Rails.root.join('lib')
  end
end

Et voilà. Il est temps de faire un commit.

$ git add . && git commit -m "Setup JWT gem"

Le contrôleur de jeton

Nous avons donc mis en place le système de génération d’un jeton JWT. Il est maintenant temps de créer une route qui va générer ce jeton. Les actions que nous allons implémenter seront gérées en tant que services RESTful: la connexion sera gérée par une demande POST à l’action create.

Pour débuter, nous allons commencer par créer le contrôleur du jeton d’authentification et la méthode create dans le namespace /api/v1. Avec Rails, une commande suffit:

$ rails generate controller api::v1::tokens create

Nous allons modifier un peu la route afin de respecter les conventions REST:

config/routes.rb
Rails.application.routes.draw do
  namespace :api, defaults: { format: :json } do
    namespace :v1 do
      # ...
      resources :tokens, only: [:create]
    end
  end
end

Avant d’aller plus loin, nous allons mettre les tests fonctionnels. Les tests sont:

  • si j’envoie un couple courriel / mot de passe valide, je reçois un jeton

  • dans le cas contraire, j’ai une réponse de type forbidden.

Les tests se matérialisent donc comme ceci:

test/controllers/api/v1/tokens_controller_test.rb
require 'test_helper'

class Api::V1::TokensControllerTest < ActionDispatch::IntegrationTest
  setup do
    @user = users(:one)
  end

  test 'should get JWT token' do
    post api_v1_tokens_url, params: { user: { email: @user.email, password: 'g00d_pa$$' } }, as: :json
    assert_response :success

    json_response = JSON.parse(response.body)
    assert_not_nil json_response['token']
  end

  test 'should not get JWT token' do
    post api_v1_tokens_url, params: { user: { email: @user.email, password: 'b@d_pa$$' } }, as: :json
    assert_response :unauthorized
  end
end

Vous vous demandez sûrement: "mais comment peux tu connaître le mot de passe de l’utilisateur?". Il suffit tout simplement d’utiliser la méthode BCrypt::Password.create dans les fixtures des utilisateurs:

test/fixtures/users.yml
one:
  email: one@one.org
  password_digest: <%= BCrypt::Password.create('g00d_pa$$') %>

A ce moment précis, si vous lancez les tests vous obtenez deux erreurs:

$ rake test

........E

Error:
Api::V1::TokensControllerTest#test_should_get_JWT_token:
JSON::ParserError: 767: unexpected token at ''


Failure:
Expected response to be a <401: unauthorized>, but was a <204: No Content>

C’est normal. Il est maintenant temps d’implémenter la logique pour créer le jeton JWT. Elle est très simple.

config/routes.rb
class Api::V1::TokensController < ApplicationController
  def create
    @user = User.find_by_email(user_params[:email])
    if @user&.authenticate(user_params[:password])
      render json: {
        token: JsonWebToken.encode(user_id: @user.id),
        email: @user.email
      }
    else
      head :unauthorized
    end
  end

  private

  # Only allow a trusted parameter "white list" through.
  def user_params
    params.require(:user).permit(:email, :password)
  end
end

Cela fait beaucoup de code mais c’est très simple:

  1. On filtre toujours les paramètres avec la méthode user_params

  2. On récupère l’utilisateur avec la méthode User.find_by_email (qui est une méthode "magique" de Active Record puisque le champ email est présent en base) et on récupère l’utilisateur

  3. On utilise la méthode User#authenticate (qui existe grâce à la gemme bcrypt) avec le mot de passe en paramètre. Bcrypt va hasher le mot de passe et vérifier s’il correspond à l’attribut password_digest. La fonction renvoie true si tout s’est bien passé, false dans le cas contraire.

  4. Dans le cas où le mot de passe correspond au hash, nous renvoyons un JSON contenant le token généré avec la classe JsonWebToken. Dans le cas contraire, nous renvoyons une réponse vide avec un en-tête unauthorized

Toujours là? Ne vous inquiétez pas, c’est fini! Maintenant vos tests doivent passer.

$ rake test

...........

Finished in 0.226196s, 48.6304 runs/s, 70.7351 assertions/s.
11 runs, 16 assertions, 0 failures, 0 errors, 0 skips

Il est temps de faire un commit qui va contenir toutes nos modifications:

$ git add . && git commit -m "Setup tokens controller"

Utilisateur connecté

Nous avons donc mis en place la logique suivante: l’API retourne un jeton d’authentification si les paramètres passés d’authentification sont corrects.

Nous allons maintenant implémenter la logique suivante: A chaque fois que ce client demandera une page protégée, nous devrons retrouver l’utilisateur à partir de ce jeton d’authentification que l’utilisateur aura passé dans l’en-tête HTTP.

Dans notre cas, nous utiliserons l’en-tête HTTP Authorization qui est souvent utilisé pour ça. Personnellement, je trouve que c’est la meilleure manière parce que cela donne un contexte à la requête sans polluer l’URL avec des paramètres supplémentaires.

Nous allons donc créer une méthode current_user pour répondre à nos besoins. C’est-à-dire retrouver l’utilisateur grâce à son jeton d’authentification qui est envoyé sur chaque requête.

Quand il s’agit de l’authentification, j’aime ajouter toutes les méthodes associées dans un fichier séparé. Il suffit ensuite d’inclure le fichier dans le ApplicationController. De cette façon, il est très facile à tester de manière isolée. Créons-donc le fichier dans le répertoire controllers/concerns avec une méthode current_user que nous implémenterons juste après:

app/controllers/concerns/authenticable.rb
module Authenticable
  def current_user
    # TODO
  end
end

Ensuite, créons un répertoire concerns sous tests/controllers/ et un fichier authenticable_test.rb pour nos tests d’authentification:

$ mkdir test/controllers/concerns
$ touch test/controllers/concerns/authenticable_test.rb

Comme d’habitude, nous commençons par écrire nos tests. Dans ce cas, notre méthode current_user va chercher un utilisateur par le jeton d’authentification dans l’en-tête HTTP Authorization. Le test est assez basique:

test/controllers/concerns/authenticable_test.rb
# ...
class AuthenticableTest < ActionDispatch::IntegrationTest
  setup do
    @user = users(:one)
    @authentication = MockController.new
  end

  test 'should get user from Authorization token' do
    @authentication.request.headers['Authorization'] = JsonWebToken.encode(user_id: @user.id)
    assert_equal @user.id, @authentication.current_user.id
  end

  test 'should not get user from empty Authorization token' do
    @authentication.request.headers['Authorization'] = nil
    assert_nil @authentication.current_user
  end
end

Vous vous demandez sûrement "mais d’ou provient MockController??". En fait il s’agit d’un Mock, c’est à dire une classe qui imite le comportement d’une autre dans le but de tester un comportement.

Nous pouvons définir la classe MockController juste au dessus de notre test:

test/controllers/concerns/authenticable_test.rb
# ...
class MockController
  include Authenticable
  attr_accessor :request

  def initialize
    mock_request = Struct.new(:headers)
    self.request = mock_request.new({})
  end
end
# ...

La classe MockController inclue simplement notre module Authenticable que nous allons tester. Elle contient un attribut request qui contient une simple Struct qui imite le comportement d’une requête Rails en contenant un attribut headers de type Hash.

Ensuite nous pouvons implémenter nos deux tests juste après

test/controllers/concerns/authenticable_test.rb
# ...
class AuthenticableTest < ActionDispatch::IntegrationTest
  setup do
    @user = users(:one)
    @authentication = Authentication.new
  end

  test 'should get user from Authorization token' do
    @authentication.request.headers['Authorization'] = JsonWebToken.encode(user_id: @user.id)
    assert_not_nil @authentication.current_user
    assert_equal @user.id, @authentication.current_user.id
  end

  test 'should not get user from empty Authorization token' do
    @authentication.request.headers['Authorization'] = nil
    assert_nil @authentication.current_user
  end
end

Nos tests doivent échouer. Implémentons donc le code pour qu’ils passent:

app/controllers/concerns/authenticable.rb
module Authenticable
  def current_user
    return @current_user if @current_user

    header = request.headers['Authorization']
    return nil if header.nil?

    decoded = JsonWebToken.decode(header)

    @current_user = User.find(decoded[:user_id]) rescue ActiveRecord::RecordNotFound
  end
end

Et voilà! Nous récupérons le jeton dans l’en-tête Authorization et nous cherchons l’utilisateur correspondant. Rien de bien sorcier.

Maintenant nos test doivent passer:

$ rake test
.............
13 runs, 19 assertions, 0 failures, 0 errors, 0 skips

Nous n’avons plus qu’à inclure le module Authenticable dans la classe ApplicationController:

app/controllers/application_controller.rb
class ApplicationController < ActionController::API
  # ...
  include Authenticable
end

Et maintenant il est temps de commiter nos changements:

$ git add . && git commit -m "Adds authenticable module for managing authentication methods"

Authentification avec le jeton

L’autorisation joue un rôle important dans la construction des applications car, contrairement à l’authentification qui permet d’identifier l’utilisateur, l’autorisation nous aide à définir ce qu’il a le droit de faire.

Nous avons une route pour mettre à jour l’utilisateur mais il y a un problème: n’importe qui peut mettre à jour n’importe quel utilisateur. Dans cette section, nous allons mettre en œuvre une méthode qui exigera que l’utilisateur soit connecté afin d’empêcher tout accès non autorisé.

Autoriser les actions

Il est maintenant temps de mettre à jour notre fichier users_controller.rb pour refuser l’accès à certaines actions. Nous allons aussi implémenter la méthode current_user sur l’action update et destroy afin de s’assurer que l’utilisateur qui est connecté ne sera capable de mettre à jour que ses données et qu’il ne pourra supprimer que (et uniquement) son compte.

Nous allons donc découper notre test should update user et should destroy user en deux tests

Commençons par la mise à jour du test should update user.

test/controllers/api/v1/users_controller_test.rb
# ...
class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest
  # ...
  test "should update user" do
    patch api_v1_user_url(@user),
      params: { user: { email: @user.email } },
      headers: { Authorization: JsonWebToken.encode(user_id: @user.id) },
      as: :json
    assert_response :success
  end

  test "should forbid update user" do
    patch api_v1_user_url(@user), params: { user: { email: @user.email } }, as: :json
    assert_response :forbidden
  end
end

Vous voyez que maintenant nous devons ajouter une en-tête Authorization pour que le modification de l’utilisateur soit acceptée. Si nous ne le faisons pas, nous voulons recevoir une réponse de type forbidden.

Nous pouvons imaginer à peu près la même chose pour le test should forbid destroy user:

test/controllers/api/v1/users_controller_test.rb
# ...
class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest
  # ...
  test "should destroy user" do
    assert_difference('User.count', -1) do
      delete api_v1_user_url(@user), headers: { Authorization: JsonWebToken.encode(user_id: @user.id) }, as: :json
    end
    assert_response :no_content
  end

  test "should forbid destroy user" do
    assert_no_difference('User.count') do
      delete api_v1_user_url(@user), as: :json
    end
    assert_response :forbidden
  end
end

Et comme vous pouvez vous y attendre, si nous exécutons les tests de notre controller utilisateurs, ils devraient échouer:

$ rails test test/controllers/api/v1/users_controller_test.rb
..F

Failure:
Expected response to be a <2XX: success>, but was a <403: Forbidden>

..F

Failure:
"User.count" didn t change by -1.
Expected: 0
  Actual: 1

La solution est assez simple. Nous allons ajouter un before_action qui appellera la méthode check_owner pour les actions update et destroy. Ainsi nous vérifierons que l’utilisateur correspondant au jeton JWT est le même que l’utilisateur qui doit être mis à jour.

Voici l’implémentation:

spec/controllers/api/v1/users_controller_test.rb
class Api::V1::UsersController < ApplicationController
  before_action :set_user, only: %i[show update destroy]
  before_action :check_owner, only: %i[update destroy]
  # ...

  private
  # ...
  def check_owner
    head :forbidden unless @user.id == current_user&.id
  end
end

Et voilà! L’implémentation est vraiment simple. Il est donc temps de commiter:

$ git commit -am "Adds authorization for the users controller"

Et comme nous arrivons à la fin de notre chapitre, il est temps d’appliquer toutes nos modifications sur la branche master en faisant un merge:

$ git checkout master
$ git merge chapter04

Conclusion

Vous l’avez fait! Vous êtes à mi-chemin! Ce chapitre a été long et difficile, mais c’est un grand pas en avant sur la mise en place d’un mécanisme solide pour gérer l’authentification utilisateur et nous commençons même à gratter la surface pour de simples règles d’autorisation.

Dans le prochain chapitre, nous nous concentrerons sur la personnalisation de la sortie JSON pour l’utilisateur avec fast_jsonapi et l’ajout d’un modèle de produit en donnant à l’utilisateur la possibilité de créer un produit et le publier pour la vente.

Produits des utilisateurs

Dans le chapitre précédent, nous avons implémenté le mécanisme d’authentification que nous allons utiliser tout au long de l’application.

Pour l’instant nous avons une implémentation très simple du modèle User mais le moment de vérité est venu. Nous allons personnaliser la sortie JSON et ajouter une deuxième ressource: les produits de l’utilisateur. Ce sont les éléments que l’utilisateur vendra dans l’application.

Si vous êtes familier avec Rails, vous savez peut-être déjà de quoi je parle. Mais pour ceux qui ne le savent pas, nous allons associer le modèle User au modèle Product en utilisant les méthodes has_many et belongs_to de Active Record.

Dans ce chapitre, nous allons construire le modèle de Product à partir de zéro, l’associer à l’utilisateur et créer les entrées nécessaires pour que tout client puisse accéder aux informations.

Vous pouvez cloner le projet jusqu’à ce point:

$ git checkout tags/checkpoint_chapter05

Avant de début, et comme d’habitude quand nous commençons de nouvelles fonctionnalités, nous créons une nouvelle branche:

$ git checkout -b chapter05

Le modèle du produit

Nous commencerons d’abord par créer un modèle de Product puis nous y ajouterons quelques validations et enfin nous l’associerons au modèle User. Comme le modèle User, le Product sera entièrement testé et sera automatiquement supprimé si l’utilisateur est supprimé.

Les fondements du produit

Le modèle Product aura besoin de plusieurs champs: un attribut price pour le prix du produit, un booléen published pour savoir si le produit est prêt à être vendu ou non, un title pour définir un titre de produit sexy, et enfin et surtout un user_id pour associer ce produit particulier à un utilisateur. Comme vous le savez peut-être déjà, nous le générons avec la commande rails generate:

$ rails generate model Product title:string price:decimal published:boolean user:belongs_to
Running via Spring preloader in process 1476
      invoke  active_record
      create    db/migrate/20190608205942_create_products.rb
      create    app/models/product.rb
      invoke    test_unit
      create      test/models/product_test.rb
      create      test/fixtures/products.yml

Comme vous pouvez le remarquer, nous avons utilisé le type belongs_to pour l’attribut. Ceci est un raccourci qui va créer une colonne user_id de type int et aussi ajouter une clé étrangère sur le champ users.id.

De plus, user_id sera aussi défini comme un index. C’est une bonne pratique pour les clés d’associations car cela optimise les requêtes de la base de données. Ce n’est pas obligatoire, mais je vous le recommande vivement.

Le fichier de migration devrait ressembler à ceci:

db/migrate/20190608205942_create_products.rb
class CreateProducts < ActiveRecord::Migration[6.0]
  def change
    create_table :products do |t|
      t.string :title
      t.decimal :price
      t.boolean :published
      t.belongs_to :user, null: false, foreign_key: true

      t.timestamps
    end
  end
end

Il suffit ensuite de lancer les migrations:

$ rake db:migrate

A ce moment si vous lancez les tests, un test doit échouer:

$ rake test
....E

Error:
Api::V1::UsersControllerTest#test_should_destroy_user:
ActiveRecord::InvalidForeignKey: SQLite3::ConstraintException: FOREIGN KEY constraint failed

rails test test/controllers/api/v1/users_controller_test.rb:43

"Quoi?! Mais je n’ai pas touché aux utilisateurs!" dites-vous très certainement. Ce que j’ai vu dans le code d’autres développeurs, lorsqu’ils travaillent avec des associations, c’est qu’ils oublient la destruction des dépendances entre les modèles. Ce que je veux dire par là, c’est que si un utilisateur est supprimé, les produits de l’utilisateur devraient l’être aussi.

Donc pour tester cette interaction entre les modèles, nous avons besoin d’un utilisateur avec un des produits. Puis, nous supprimerons cet utilisateur en espérant que les produits disparaissent avec lui. Rails à déjà généré cela pour nous. Jetez un coup d’œil à la fixture des produits:

test/fixtures/products.yml
one:
  title: MyString
  price: 9.99
  published: false
  user: one
# ...

Vous pouvez voir que cette fixture n’utilise pas l’attribut user_id mais user. Cela signifie que le produit one aura un attribut user_id correspondant à l’identifiant de l’utilisateur one.

Il faut donc spécifier une suppression en cascade afin de supprimer le produit one lorsque l’utilisateur one est supprimé. Commençons par le test unitaire:

test/models/user_test.rb
# ...
class UserTest < ActiveSupport::TestCase
  # ...
  test 'destroy user should destroy linked product' do
    assert_difference('Product.count', -1) do
      users(:one).destroy
    end
  end
end

Il suffit de modifier le modèle User et lui spécifier la relation has_many avec l’option dependent: :destroy. Nous verrons plus tard ce que cette méthode fait plus en détails.

test/models/user_test.rb
# ...
class User < ApplicationRecord
  # ...
  has_many :products, dependent: :destroy
end

Et voilà. Faisons un commit:

$ git add . && git commit -m "Generate product model"

Validations des produits

Comme nous l’avons vu avec l’utilisateur, les validations sont une partie importante lors de la construction de tout type d’application. Cela nous permet d’empêcher toute donnée indésirable d’être enregistrée dans la base de données. Pour le produit, nous devons nous assurer, par exemple, que le prix est un nombre et qu’il n’est pas négatif.

Voici donc notre premier test pour le modèle des produits:

test/models/product_test.rb
# ...
class ProductTest < ActiveSupport::TestCase
  test "Should have a positive price" do
    product = products(:one)
    product.price = -1
    assert_not product.valid?
  end
end

Il nous faut maintenant ajouter l’implémentation pour faire passer le test:

app/models/product.rb
class Product < ApplicationRecord
  validates :title, :user_id, presence: true
  validates :price, numericality: { greater_than_or_equal_to: 0 }, presence: true
  belongs_to :user
end

Les tests passent désormais:

$ rake test
................

Commitons ces changements et continuons d’avancer:

$ git commit -am "Adds some validations to products"

Point d’entrée pour nos produits

Il est maintenant temps de commencer à construire les points d’entrée des produits. Pour l’instant, nous allons juste construire cinq actions REST. Dans le prochain chapitre, nous allons personnaliser la sortie JSON en implémentant la gemme fast_jsonapi.

Nous devons d’abord créer le products_controller, et nous pouvons facilement y parvenir avec la commande ci-dessous:

$ rails generate controller api::v1::products
      create  app/controllers/api/v1/products_controller.rb
      invoke  test_unit
      create    test/controllers/api/v1/products_controller_test.rb

La commande ci-dessus va générer pas mal de fichiers qui vont nous permettre de commencer à travailler rapidement. Ce que je veux dire par là, c’est qu’il va générer le contrôleur et les fichiers de test déjà scopés à la version 1 de l’API.

En guise d’échauffement, nous allons commencer par construire l’action du show pour le produit.

Action d’affichage d’un produit

Comme d’habitude, nous commençons par ajouter quelques test du contrôleur des produits. La stratégie ici est très simple, il suffit de créer un seul produit et de s’assurer que la réponse du serveur est celle que nous attendons.

test/controllers/api/v1/products_controller_test.rb
# ...
class Api::V1::ProductsControllerTest < ActionDispatch::IntegrationTest
  setup do
    @product = products(:one)
  end

  test "should show product" do
    get api_v1_product_url(@product), as: :json
    assert_response :success

    json_response = JSON.parse(self.response.body)
    assert_equal @product.title, json_response['title']
  end
end

Nous ajoutons ensuite le code pour faire passer le test:

app/controllers/api/v1/products_controller.rb
class Api::V1::ProductsController < ApplicationController
  def show
    render json: Product.find(params[:id])
  end
end

Attendez! N’exécutez pas encore les tests. N’oubliez pas que nous devons ajouter la route au fichier routes.rb:

config/routes.rb
Rails.application.routes.draw do
  namespace :api, defaults: { format: :json } do
    namespace :v1 do
      resources :users, only: %i[show create update destroy]
      resources :tokens, only: [:create]
      resources :products, only: [:show]
    end
  end
end

Maintenant, nous nous assurons que les tests passent:

$ rake test
.................

Comme vous pouvez déjà le constater, les tests et l’implémentation sont très simples. En fait, cela ressemble beaucoup à ce que nous avons fait pour les utilisateurs.

Liste des produits

Il est maintenant temps de créer une entrée pour une liste de produits qui pourrait permettre d’afficher le catalogue de produits d’un marché par exemple. Pour ce point d’accès, nous n’exigeons pas que l’utilisateur soit connecté. Comme d’habitude, nous allons commencer à écrire quelques tests:

test/controllers/api/v1/products_controller_test.rb
# ...
class Api::V1::ProductsControllerTest < ActionDispatch::IntegrationTest
  setup do
    @product = products(:one)
  end

  test "should show products" do
    get api_v1_products_url(), as: :json
    assert_response :success
  end

  test "should show product" do
    get api_v1_product_url(@product), as: :json
    assert_response :success

    json_response = JSON.parse(self.response.body)
    assert_equal @product.title, json_response['title']
  end
end

Passons maintenant à la mise en œuvre, qui, pour l’instant, va être une petite méthode:

app/controllers/api/v1/products_controller.rb
class Api::V1::ProductsController < ApplicationController
  def index
    render json: Product.all
  end
  #...
end

Et n’oubliez pas, vous devez ajouter la route correspondante dans le fichier config/routes.rb:

config/routes.rb
Rails.application.routes.draw do
  namespace :api, defaults: { format: :json } do
    namespace :v1 do
      # ....
      resources :products, only: %i[show index]
    end
  end
end

Dans les chapitres suivants, nous allons améliorer ce point d’entré et donner la possibilité de recevoir des paramètres pour les filtrer. Commitons ces changements et continuons d’avancer:

$ git add . && git commit -m "Finishes modeling the product model along with user associations"

Création des produits

Créer des produits est un peu plus délicat parce que nous aurons besoin d’une configuration supplémentaire. La stratégie que nous suivrons est d’attribuer le produit créé à l’utilisateur propriétaire du jeton JWT fourni d’en l’en-tête HTTP Authorization.

Notre premier arrêt sera donc le fichier products_controller_test.rb.

test/controllers/api/v1/products_controller_test.rb
# ...
class Api::V1::ProductsControllerTest < ActionDispatch::IntegrationTest
  # ...

  test 'should create product' do
    assert_difference('Product.count') do
      post api_v1_products_url,
           params: { product: { title: @product.title, price: @product.price, published: @product.published } },
           headers: { Authorization: JsonWebToken.encode(user_id: @product.user_id) },
           as: :json
    end
    assert_response :created
  end

  test 'should forbid create product' do
    assert_no_difference('Product.count') do
      post api_v1_products_url,
           params: { product: { title: @product.title, price: @product.price, published: @product.published } },
           as: :json
    end
    assert_response :forbidden
  end
end

Wow! Nous avons ajouté beaucoup de code. Si vous vous souvenez, les tests sont en fait les mêmes que ceux de la création de l’utilisateur excepté quelques changements mineurs.

De cette façon, nous pouvons voir l’utilisateur et lui créer un produit qui lui est associé. Mais attendez il y a mieux. Si nous adoptons cette approche, nous pouvons augmenter la portée de notre mécanisme d’autorisation. Dans ce cas, si vous vous souvenez, nous avons construit la logique pour obtenir l’utilisateur à partir de l’en-tête Authorization et lui avons assigné une méthode current_user. C’est donc assez facile à mettre en place en ajoutant simplement l’en-tête d’autorisation dans la requête et en récupérant l’utilisateur à partir de celui-ci. Alors faisons-le:

app/controllers/api/v1/products_controller.rb
class Api::V1::ProductsController < ApplicationController
  before_action :check_login, only: %i[create]
  # ...

  def create
    product = current_user.products.build(product_params)
    if product.save
      render json: product, status: :created
    else
      render json: { errors: product.errors }, status: :unprocessable_entity
    end
  end

  private

  def product_params
    params.require(:product).permit(:title, :price, :published)
  end
end

Comme vous pouvez le voir, nous protégeons l’action de création avec la méthode check_login, et sur l’action create nous construisons le produit en associant l’utilisateur courant. J’ai ajouté cette méthode très simpliste au concern authenticable.rb:

app/controllers/concerns/authenticable.rb
module Authenticable
  # ...
  protected

  def check_login
    head :forbidden unless self.current_user
  end
end

Une dernière chose avant de faire vos tests: la route nécessaire:

config/routes.rb
Rails.application.routes.draw do
  namespace :api, defaults: { format: :json } do
    namespace :v1 do
      # ...
      resources :products, only: %i[show index create]
    end
  end
end

Si vous faites les tests maintenant, ils devraient tous passer:

$ rake test
....................

Mise à jour des produits

J’espère que maintenant vous comprenez la logique pour construire les actions à venir. Dans cette section, nous nous concentrerons sur l’action de mise à jour qui fonctionnera de manière similaire à celle de création. Nous avons juste besoin d’aller chercher le produit dans la base de données et de le mettre à jour.

Nous ajoutons d’abord l’action aux routes pour ne pas oublier plus tard:

config/routes.rb
Rails.application.routes.draw do
  namespace :api, defaults: { format: :json } do
    namespace :v1 do
      # ...
      resources :products, only: %i[show index create update]
    end
  end
end

Avant de commencer à coder certains tests je veux juste préciser que, de la même manière que pour l’action create, nous allons délimiter le produit à l’utilisateur courant. Nous voulons nous assurer que le produit que nous mettons à jour appartient bien à l’utilisateur. Nous allons donc chercher ce produit dans l’association user.products fournie par Active Record.

Tout d’abord, nous ajoutons quelques tests:

test/controllers/api/v1/products_controller_test.rb
require 'test_helper'

class Api::V1::ProductsControllerTest < ActionDispatch::IntegrationTest
  # ...

  test 'should update product' do
    patch api_v1_product_url(@product),
          params: { product: { title: @product.title } },
          headers: { Authorization: JsonWebToken.encode(user_id: @product.user_id) },
          as: :json
    assert_response :success
  end

  test 'should forbid update product' do
    patch api_v1_product_url(@product),
          params: { product: { title: @product.title } },
          headers: { Authorization: JsonWebToken.encode(user_id: users(:two).id) },
          as: :json
    assert_response :forbidden
  end
end
Note
J’ai ajouté une fixture correspondant à un deuxième utilisateur dans le but de vérifier que celui-ci ne peut pas modifier le produit du premier utilisateur.

Les tests peuvent paraître complexes, mais en jetant un coup d’œil, ils sont presque identiques à ceux des utilisateurs.

Maintenant implémentons le code pour faire passer nos tests avec succès:

app/controllers/api/v1/products_controller.rb
class Api::V1::ProductsController < ApplicationController
  before_action :set_product, only: %i[show update]
  before_action :check_login, only: %i[create]
  before_action :check_owner, only: %i[update]

  # ...

  def create
    product = current_user.products.build(product_params)
    if product.save
      render json: product, status: :created
    else
      render json: { errors: product.errors }, status: :unprocessable_entity
    end
  end

  def update
    if @product.update(product_params)
      render json: @product
    else
      render json: @product.errors, status: :unprocessable_entity
    end
  end

  private
  # ...

  def check_owner
    head :forbidden unless @product.user_id == current_user&.id
  end

  def set_product
    @product = Product.find(params[:id])
  end
end

Comme vous pouvez le constater, l’implémentation est assez simple. Nous allons simplement récupérer le produit auprès de l’utilisateur connecté et nous le mettons simplement à jour. Nous avons également ajouté cette action au before_action, pour empêcher tout utilisateur non autorisé de mettre à jour un produit.

Si nous lançons les tests, ils devraient passer:

$ rake test
......................

Suppression des produits

Notre dernier arrêt pour les route des produits, sera l’action destroy. Vous pouvez maintenant imaginer à quoi cela ressemblerait. La stratégie ici sera assez similaire à l’action de create et update. Ce qui signifie que nous allons récupérer l’utilisateur connecté puis récupérer le produit auprès de l’association user.products et enfin le supprimer en retournant un code 204.

Recommençons par ajouter la route:

config/routes.rb
Rails.application.routes.draw do
  namespace :api, defaults: { format: :json } do
    namespace :v1 do
      resources :users, only: %i[show create update destroy]
      resources :tokens, only: [:create]
      resources :products
    end
  end
end

Après cela, nous devons ajouter quelques tests:

test/controllers/api/v1/products_controller_test.rb
# ...
class Api::V1::ProductsControllerTest < ActionDispatch::IntegrationTest
  # ...

  test "should destroy product" do
    assert_difference('Product.count', -1) do
      delete api_v1_product_url(@product), headers: { Authorization: JsonWebToken.encode(user_id: @product.user_id) }, as: :json
    end
    assert_response :no_content
  end

  test "should forbid destroy user" do
    assert_no_difference('Product.count') do
      delete api_v1_user_url(@product), headers: { Authorization: JsonWebToken.encode(user_id: users(:two).id) }, as: :json
    end
    assert_response :forbidden
  end
end

Maintenant, ajoutons simplement le code nécessaire pour faire passer les tests:

app/controllers/api/v1/products_controller.rb
class Api::V1::ProductsController < ApplicationController
  before_action :set_product, only: %i[show update destroy]
  before_action :check_login, only: %i[create]
  before_action :check_owner, only: %i[update destroy]

  # ...

  def destroy
    @product.destroy
    head 204
  end

  # ...
end

Comme vous pouvez le voir, l’implémentation fait le travail en trois lignes. Nous pouvons lancer les tests pour nous assurer que tout est bon.

$ rake test
........................

Après cela, nous commitons les changements.

$ git commit -am "Adds the products create, update and destroy action"

Remplir la base de données

Avant de continuer avec plus de code, remplissons la base de données avec de fausses données. Pour faire cela, nous allons utiliser des seeds.

Avec le fichier db/seeds.rb, Rails nous donne un moyen de fournir facilement et rapidement des valeurs par défaut à une nouvelle installation. C’est un simple fichier Ruby qui donne un accès complet à toutes les classes et méthodes de l’application. Vous n’avez donc pas besoin de tout saisir manuellement avec la console Rails mais vous pouvez simplement utiliser le fichier db/seeds.rb avec la commande rake db:seed.

Commençons donc par créer un utilisateur:

db/seeds.rb
User.delete_all
user = User.create! email: 'toto@toto.fr', password: 'toto123'
puts "Created a new user: #{user.email}"

Et maintenant vous pouvez créer l’utilisateur en éxecutant simplement la commande suivante:

$ rake db:seed
Created a new user: toto@toto.fr

Ca fonctionne. Je ne sais pas vous, mais moi j’aime bien avoir des données factices qui remplissent correctement ma base de données de test. Seulement je n’ai pas toujours l’inspiration pour donner du sens à mes seed alors j’utilise la gemme faker. Installons là:

$ bundle add faker

Maintenant nous pouvons l’utiliser pour créer cinq utilisateurs d’un coup avec des email différent.

db/seeds.rb
User.delete_all

5.times do
  user = User.create! email: Faker::Internet.email, password: 'locadex1234'
  puts "Created a new user: #{user.email}"
end

Et voyons le résultat:

$ rake db:seed
Created a new user: barbar@greenholt.io
Created a new user: westonpaucek@ortizbotsford.net
Created a new user: ricardo@schneider.com
Created a new user: scott@moenerdman.biz
Created a new user: chelsie@wiza.net

Et voilà. Mais nous pouvons aller plus loin en créant des produit associés à ces utilisateurs:

db/seeds.rb
Product.delete_all
User.delete_all

3.times do
  user = User.create! email: Faker::Internet.email, password: 'locadex1234'
  puts "Created a new user: #{user.email}"

  2.times do
    product = Product.create!(
      title: Faker::Commerce.product_name,
      price: rand(1.0..100.0),
      published: true,
      user_id: user.id
    )
    puts "Created a brand new product: #{product.title}"
  end
end

Et voilà. Le résultat est bluffant. En une commande nous pouvons créer trois utilisateurs et six produits:

$ rake db:seed
Created a new user: tova@beatty.org
Created a brand new product: Lightweight Steel Hat
Created a brand new product: Ergonomic Aluminum Lamp
Created a new user: tommyrunolfon@tremblay.biz
Created a brand new product: Durable Plastic Car
Created a brand new product: Ergonomic Leather Shirt
Created a new user: jordon@torp.io
Created a brand new product: Incredible Paper Hat
Created a brand new product: Sleek Concrete Pants

commitons les changements:

$ git commit -am "Create a seed to populate database"

Et comme nous arrivons à la fin de notre chapitre, il est temps d’appliquer toutes nos modifications sur la branche master en faisant un merge:

$ git checkout master
$ git merge chapter05

Conclusion

J’espère que vous avez apprécié ce chapitre. C’est un long travail, mais le code que nous avons créé est une excellente base pour l’application principale.

Dans le chapitre suivant, nous allons nous concentrer sur la personnalisation de la sortie des modèles utilisateur et produits à l’aide de la gemme fast_jsonapi. Elle nous permettra de filtrer facilement les attributs à afficher et à gérer les associations comme des objets embarqués par exemple.

Modélisation du JSON

Dans le chapitre précédent, nous avons ajouté les produits à l’application et construit toutes les routes nécessaires. Nous avons également associé un produit à un utilisateur et restreint certaines des actions de products_controller.

Maintenant, vous devriez être satisfaits de tout ce travail. Mais nous avons encore du pain sur la planche. Actuellement, nous avons une sortie JSON qui n’est pas parfaite. La sortie JSON ressemble à celle-ci:

{
  "products": [
      {
          "id": 1,
          "title": "Tag Case",
          "price": "98.7761933800815",
          "published": false,
          "user_id": 1,
          "created_at": "2018-12-20T12:47:26.686Z",
          "updated_at": "2018-12-20T12:47:26.686Z"
      },
    ]
}

Or nous voulons une sortie qui ne contienne pas les champs user_id, created_at et updated_at.

De plus, une partie importante et difficile lors de la création de votre API est de décider le format de sortie. Heureusement, certaines organisations ont déjà fait face à ce genre de problème et elles ont ainsi établi certaines conventions que vous allez découvrir dans ce chapitre.

Vous pouvez clôner le projet jusqu’à ce point avec:

$ git checkout tags/checkpoint_chapter06

Commençons une nouvelle branche pour ce chapitre:

$ git checkout -b chapter06

Présentation de JSON:API

Une partie importante et difficile lors de la création de votre API est de décider le format de sortie. Heureusement, certaines conventions existent déjà.

L’une d’elles, certainement la plus utilisée est JSON:API. La documentation de JSON:API nous donne quelques règles à suivre concernant le formatage du document JSON.

Ainsi, notre document doit contenir ces clefs:

  • data: qui doit contenir les données que nous renvoyons

  • errors qui doit contenir un tableau des erreurs qui sont survenues.

  • meta qui contient un objet meta

Le contenu de la clé data est lui aussi assez strict:

  • il doit posséder une clé `type`qui décrit le type du modèle JSON (si c’est un article, un utilisateur, etc..)

  • les propriétés de l’objet doivent être placées dans une clé attributes et les undescore (_) sont remplacés par des tirets (-)

  • les liaisons de l’objets doivent être placées dans une clé relationships

Dans ce chapitre, nous allons personnaliser la sortie JSON en utilisant la gemme fast_jsonapi de Netflix qui respecte toutes les normes JSON:API.

Installons donc la gemme fast_jsonapi:

$ bundle add fast_jsonapi

Vous devriez être prêts à continuer avec ce tutoriel.

Sérialiser l’utilisateur

FastJSON API utilise des sérialiseurs. Les sérialiseurs représentent des classes Ruby qui seront responsables de convertir un modèle en un Hash ou un JSON.

Nous devons d’abord ajouter un fichier user_serializer.rb. Nous pouvons le faire manuellement, mais la gemme fournit une interface en ligne de commande pour le faire:

$ rails generate serializer User email
      create  app/serializers/user_serializer.rb

Ceci a créé un fichier appelé user_serializer.rb sous le répertoire app/serializers, qui devrait ressembler au fichier suivant:

app/serializers/user_serializer.rb
class UserSerializer
  include FastJsonapi::ObjectSerializer
  attributes :email
end

Ce serializer va nous permettre de convertir notre objet User en JSON qui implémente correctement la norme JSON:API. Nous avons spécifié l’attribut email afin qu’il soit présent dans le tableau data.

Essayons tout cela dans la console Rails avec rails console:

2.6.3 :001 > UserSerializer.new( User.first ).serializable_hash
=> {:data=>{:id=>"25", :type=>:user, :attributes=>{:email=>"tova@beatty.org"}}}

Et voilà. Comme vous le voyez, tout se fait très facilement. Nous pouvons donc utiliser notre nouveau serializer dans notre controller:

app/controllers/api/v1/users_controller.rb
class Api::V1::UsersController < ApplicationController
  # ...
  def show
    render json: UserSerializer.new(@user).serializable_hash
  end

  def update
    if @user.update(user_params)
      render json: UserSerializer.new(@user).serializable_hash
    else
      # ...
    end
  end

  def create
    # ...
    if @user.save
      render json: UserSerializer.new(@user).serializable_hash, status: :created
    else
      # ...
    end
  end

  # ...
end

Assez facile, non? Cependant nous devrions avoir un test qui échoue. Essayez par vous même:

$ rake test

Failure:
Expected: "one@one.org"
  Actual: nil

Vous pouvez voir que pour une raison quelconque, la réponse n’est pas tout à fait ce que nous attendions. C’est parce que la gemme modifie la réponse que nous avions précédemment définie. Donc pour faire passer les tests, il suffit de mettre à jour notre test:

test/controllers/api/v1/users_controller_test.rb
# ...
class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest
  # ...
  test "should show user" do
    # ...
    assert_equal @user.email, json_response['data']['attributes']['email']
  end
  # ...
end

Si vous faites les tests maintenant, ils devraient passer:

$ rake test

# Running:

........................

Commitons ces changements et continuons d’avancer:

$ git add .
$ git commit -am "Adds user serializer for customizing the json output"

Sérialiser les produits

Maintenant que nous comprenons comment fonctionne la gemme de sérialisation, il est temps de personnaliser la sortie des produits. La première étape est identique à celle pour l’utilisateur, nous avons besoin d’un sérialiseur de produit, alors faisons-le:

$ rails generate serializer Product title price published
      create  app/serializers/product_serializer.rb

Ajoutons maintenant les attributs à sérialiser pour le produit, comme nous l’avons fait avec l’utilisateur dans la section précédente:

app/serializers/product_serializer.rb
class ProductSerializer
  include FastJsonapi::ObjectSerializer
  attributes :title, :price, :published
end

Et voilà. Ce n’est pas plus compliqué que cela. Modifions un petit peu notre contrôleur.

app/controllers/api/v1/products_controller.rb
class Api::V1::ProductsController < ApplicationController
  # ...
  def index
    @products = Product.all
    render json: ProductSerializer.new(@products).serializable_hash
  end

  def show
    render json: ProductSerializer.new(@product).serializable_hash
  end

  def create
    product = current_user.products.build(product_params)
    if product.save
      render json: ProductSerializer.new(product).serializable_hash, status: :created
    else
      # ...
    end
  end

  def update
    if @product.update(product_params)
      render json: ProductSerializer.new(@product).serializable_hash
    else
      # ...
    end
  end
  # ...
end

Et nous mettons à jour notre test fonctionnel:

app/controllers/api/v1/products_controller.rb
# ...
class Api::V1::ProductsControllerTest < ActionDispatch::IntegrationTest
  # ...
  test 'should show product' do
    # ...
    assert_equal @product.title, json_response['data']['attributes']['title']
  end
  # ...
end

Vous pouvez lancer les tests pour vérifier mais ils devraient encore être bons. Commitons ces petits changements:

$ git add .
$ git commit -m "Adds product serializer for custom json output"

Sérialiser les associations

Nous avons travaillé avec des sérialiseurs et vous remarquerez peut-être que c’est très simple. Dans certains cas, la décision difficile est de savoir comment nommer vos routes ou comment structurer la sortie JSON afin que votre solution soit pérenne. Lorsque vous travaillez avec des associations entre les modèles sur une API, il existe de nombreuses approches que vous pouvez prendre.

Nous n’avons pas à nous soucier de ce problème dans notre cas, la norme JSON:API l’a fait pour nous!

Pour résumer, nous avons une association de type has_many entre l’utilisateur et le modèle de produit.

app/models/user.rb
class User < ApplicationRecord
  has_many :products, dependent: :destroy
  # ...
end
app/models/product.rb
class Product < ApplicationRecord
  belongs_to :user
  # ...
end

C’est une bonne idée d’intégrer les utilisateurs dans les sortie JSON des produits. Cela rendra la sortie plus lourde mais ça évitera au client de l’API d’éxecuter d’autres requêtes pour récupérer les informations des utilisateurs liées aux produits. Cette méthode peut vraiment vous éviter un énorme goulet d’étranglement.

Théorie de l’injection des relations

Imaginez un scénario où vous allez chercher les produits dans l’API, mais dans ce cas, vous devez afficher une partie des informations de l’utilisateur.

Une solution possible serait d’ajouter l’attribut user_id au product_serializer pour que nous puissions récupérer l’utilisateur correspondant plus tard. Cela peut sembler être une bonne idée, mais si vous vous souciez de la performance, ou si les transactions de votre base de données ne sont pas assez rapides, vous devriez reconsidérer cette approche. Vous devez comprendre que pour chaque produit que vous récupérez, vous allez devoir récupérer son utilisateur correspondant.

Face à ce problème, il y a plusieurs alternatives possibles.

Intégrer dans un attribut meta

Une bonne solution à mon avis est d’intégrer les identifiants des utilisateurs liés aux produits dans un attribut meta, donc nous avons une sortie JSON comme:

{
  "meta": { "user_ids": [1,2,3] },
  "data": [

  ]
}

Cela peut nécessiter une configuration supplémentaire sur le terminal de l’utilisateur, afin que le client puisse récupérer ses utilisateurs à partir de ces user_ids.

Incorporer l’objet dans l’attribut

Une autre solution, est d’incorporer l’objet user dans l’objet product. Ce qui peut rendre la première requête un peu plus lente, mais de cette façon le client n’a pas besoin de faire une autre requête supplémentaire. Un exemple des résultats escomptés est présenté ci-dessous:

{
  "data":
  [
    {
        "id": 1,
        "type": "product",
        "attributes": {
          "title": "First product",
          "price": "25.02",
          "published": false,
          "user": {
            "id": 2,
            "attributes": {
              "email": "stephany@lind.co.uk",
              "created_at": "2014-07-29T03:52:07.432Z",
              "updated_at": "2014-07-29T03:52:07.432Z",
              "auth_token": "Xbnzbf3YkquUrF_1bNkZ"
            }
          }
        }
    }
  ]
}

Le problème de cette approche est que nous devons dupliquer les objets User pour tous les produits qui appartiennent au même utilisateur:

{
  "data":
  [
    {
        "id": 1,
        "type": "product",
        "attributes": {
          "title": "First product",
          "price": "25.02",
          "published": false,
          "user": {
            "id": 2,
            "type": "user",
            "attributes": {
              "email": "stephany@lind.co.uk",
              "created_at": "2014-07-29T03:52:07.432Z",
              "updated_at": "2014-07-29T03:52:07.432Z",
              "auth_token": "Xbnzbf3YkquUrF_1bNkZ"
            }
          }
        }
    },
    {
        "id": 2,
        "type": "product",
        "attributes": {
          "title": "Second product",
          "price": "25.02",
          "published": false,
          "user": {
            "id": 2,
            "type": "user",
            "attributes": {
              "email": "stephany@lind.co.uk",
              "created_at": "2014-07-29T03:52:07.432Z",
              "updated_at": "2014-07-29T03:52:07.432Z",
              "auth_token": "Xbnzbf3YkquUrF_1bNkZ"
            }
          }
        }
    }
  ]
}

Incorporer les relation dans include

La troisième solution, choisie par la norme JSON:API, est un mélange des deux premières.

Nous allons inclure toutes les relations dans une clé include qui contiendra tous les relations des objets précédemment cités. Aussi, chaque objet inclura une clé relationships définissant la relation et qu’il faudra retrouver dans la clé include.

Un JSON vaut mille mots:

{
  "data":
  [
    {
        "id": 1,
        "type": "product",
        "attributes": {
          "title": "First product",
          "price": "25.02",
          "published": false
        },
        "relationships": {
          "user": {
            "id": 1,
            "type": "user"
          }
        }
    },
    {
        "id": 2,
        "type": "product",
        "attributes": {
          "title": "Second product",
          "price": "25.02",
          "published": false
        },
        "relationships": {
          "user": {
            "id": 1,
            "type": "user"
          }
        }
    }
  ],
  "include": [
    {
      "id": 2,
      "type": "user",
      "attributes": {
        "email": "stephany@lind.co.uk",
        "created_at": "2014-07-29T03:52:07.432Z",
        "updated_at": "2014-07-29T03:52:07.432Z",
        "auth_token": "Xbnzbf3YkquUrF_1bNkZ"
      }
    }
  ]
}

Vous voyez la différence? Cette solution réduit drastiquement la taille du JSON et donc la bande passante utilisée.

Application de l’injection des relations

Nous allons donc incorporer l’objet utilisateur dans le produit. Commençons par ajouter quelques tests.

Nous allons simplement modifier le test Products#show afin de vérifier que nous récupérons:

test/controllers/api/v1/products_controller_test.rb
# ...
class Api::V1::ProductsControllerTest < ActionDispatch::IntegrationTest
  # ...
  test 'should show product' do
    get api_v1_product_url(@product), as: :json
    assert_response :success

    json_response = JSON.parse(response.body, symbolize_names: true)
    assert_equal @product.title, json_response.dig(:data, :attributes, :title)
    assert_equal @product.user.id.to_s, json_response.dig(:data, :relationships, :user, :data, :id)
    assert_equal @product.user.email, json_response.dig(:included, 0, :attributes, :email)
  end
  # ...
end

Nous vérifions maintenant trois choses sur le JSON qui est retourné:

  1. il contient le titre du produit

  2. il contient l’identifiant de l’utilisateur lié au produit

  3. les données de l’utilisateur sont incluses dans la clé include

Note
Vous avez sûrement remarqué que j’ai choisi d’utiliser la méthode Hash#dig. C’est une méthode Ruby qui permet de récupérer des éléments dans un Hash imbriqué en évitant les erreurs si un élément n’est pas présent.

Pour faire passer ce test nous allons commencer par inclure la relation dans le serializer:

app/serializers/product_serializer.rb
class ProductSerializer
  include FastJsonapi::ObjectSerializer
  attributes :title, :price, :published
  belongs_to :user
end

Cet ajout aura pour effet de rajouter une clé relatioship contenant l’identifiant de l’utilisateur:

{
  "data": {
      "id": "1",
      "type": "product",
      "attributes": {
          "title": "Durable Marble Lamp",
          "price": "11.55",
          "published": true
      },
      "relationships": {
          "user": {
              "data": {
                  "id": "1",
                  "type": "user"
              }
          }
      }
  }
}

Cela nous permet donc de corriger nos deux premières assertions. Nous voulons maintenant inclure les attributs de l’utilisateur qui possède le produit. Pour faire cela, nous devons simplement passer une option :include au serializer instancié dans le controller. Alors faisons le:

app/serializers/product_serializer.rb
class Api::V1::ProductsController < ApplicationController
  # ...
  def show
    options = { include: [:user] }
    render json: ProductSerializer.new(@product, options).serializable_hash
  end
  # ...
end

Et voilà. Maintenant voilà à quoi le JSON devrait ressembler:

{
  "data": {
    ...
  },
  "included": [
    {
      "id": "1",
      "type": "user",
      "attributes": {
        "email": "staceeschultz@hahn.info"
      }
    }
  ]
}

L’implémentation est très simple: il suffit d’ajouter une ligne au sérialiseur du produit:

app/serializers/product_serializer.rb
class ProductSerializer < ActiveModel::Serializer
  attributes :id, :title, :price, :published
  has_one :user
end

Maintenant, tous les tests devraient passer:

$ rake test

# Running:

........................

Faisons un commit pour fêter ça:

$ git commit -am "Add user relationship to product"

Récupérer les produits pour des utilisateurs

Vous avez compris le principe? Nous avons inclus les informations de l’utilisateur dans le JSON des produits. Nous pouvons faire la même choses en incluant les informations des produits liés à un utilisateur pour la page /api/v1/users/1.

Commençons par le test:

app/controllers/api/v1/users_controller.rb
# ...
class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest
  # ...
  test "should show user" do
    get api_v1_user_url(@user), as: :json
    assert_response :success

    json_response = JSON.parse(self.response.body, symbolize_names: true)
    assert_equal @user.email, json_response.dig(:data, :attributes, :email)
    assert_equal @user.products.first.id.to_s, json_response.dig(:data, :relationships, :products, :data, 0, :id)
    assert_equal @user.products.first.title, json_response.dig(:included, 0, :attributes, :title)
  end
  # ...
end

Ensuite le serializer:

app/serializers/user_serializer.rb
class UserSerializer
  include FastJsonapi::ObjectSerializer
  attributes :email
  has_many :products
end

Et pour terminer le contrôleur:

app/controllers/api/v1/users_controller.rb
class Api::V1::UsersController < ApplicationController
  # ...
  def show
    options = { include: [:products] }
    render json: UserSerializer.new(@user, options).serializable_hash
  end
  # ...
end

Et voilà. Nous obtenons un JSON de cette forme:

{
  "data": {
    "id": "1",
    "type": "user",
    "attributes": {
      "email": "staceeschultz@hahn.info"
    },
    "relationships": {
      "products": {
        "data": [
          { "id": "1", "type": "product" },
          { "id": "2", "type": "product" }
        ]
      }
    }
  },
  "included": [
    {
      "id": "1",
      "type": "product",
      "attributes": {
        "title": "Durable Marble Lamp",
        "price": "11.5537474980286",
        "published": true
      },
      "relationships": {
        "user": {
          "data": {
            "id": "1",
            "type": "user"
          }
        }
      }
    },
    {
        ...
    }
  ]
}

C’était vraiment facile. Faisons un commit:

$ git commit -am "Add products relationship to user#show"

Rechercher les produits

Dans cette dernière section, nous continuerons à renforcer l’action Products#index en mettant en place un mécanisme de recherche très simple pour permettre à n’importe quel client de filtrer les résultats. Cette section est facultative car elle n’aura aucun impact sur les modules de l’application. Mais si vous voulez pratiquer davantage avec le TDD, je vous recommande de compléter cette dernière étape.

J’utilise Ransack ou pg_search pour construire des formulaires de recherche avancée extrêmement rapidement. Mais ici, comme le but est d’apprendre et que la recherche que nous allons effectuer est très simple, je pense que nous pouvons construire un moteur de recherche à partir de zéro. Nous devons simplement considérer les critères par lesquels nous allons filtrer les attributs. Accrochez-vous bien à vos sièges, ça va être un voyage difficile.

Nous filtrerons donc les produits selon les critères suivants:

  • Par titre

  • Par prix

  • Trier par date de création

Cela peut sembler court et facile, mais croyez-moi, cela vous donnera mal à la tête si vous ne le planifiez pas.

Le mot-clé by

Nous allons créer un scope pour trouver les enregistrements qui correspondent à un motif particulier de caractère. Appelons-le filter_by_title.

Nous allons commencer par ajouter quelques fixtures avec différents produits afin de tester:

test/fixtures/products.yml
one:
  title: TV Plosmo Philopps
  price: 9999.99
  published: false
  user: one

two:
  title: Azos Zeenbok
  price: 499.99
  published: false
  user: two

another_tv:
  title: Cheap TV
  price: 99.99
  published: false
  user: two

Et maintenant nous pouvons construire les tests:

test/models/product_test.rb
# ...
class ProductTest < ActiveSupport::TestCase
  # ...
  test "should filter products by name" do
    assert_equal 2, Product.filter_by_title('tv').count
  end

  test 'should filter products by name and sort them' do
    assert_equal [products(:another_tv), products(:one)], Product.filter_by_title('tv').sort
  end
end

Les tests suivants s’assurent que la méthode Product.filter_by_title va rechercher correctement les produits en fonction de leurs titres. Nous utilisons le terme tv en minuscule afin de s’assurer que notre recherche ne sera pas sensible à la casse.

L’implémentation est très simple en utilisant un scope.

app/models/product.rb
class Product < ApplicationRecord
  # ...
  scope :filter_by_title, lambda { |keyword|
    where('lower(title) LIKE ?', "%#{keyword.downcase}%")
  }
end
Note
Le scoping vous permet de spécifier des requêtes couramment utilisées qui peuvent être référencées comme des appels de méthode sur les modèles. Avec ces scopes vous pouvez aussi chaîner avec les méthodes d’Active Record comme where, joins et includes car un scope retourne toujours un objet ActiveRecord::Relation. Je vous invite à jeter un œil à la documentation de Rails

L’implémentation est suffisante pour que nos tests passent:

$ rake test
..........................

Par prix

Pour filtrer par prix, les choses peuvent devenir un peu plus délicates. Nous allons briser la logique de filtrer par prix en deux méthodes différentes: l’une qui va chercher les produits plus grands que le prix reçu et l’autre qui va chercher ceux qui sont sous ce prix. De cette façon, nous garderons une certaine flexibilité et nous pouvons facilement tester les scope.

Commençons par construire les tests du scope above_or_equal_to_price:

test/models/product_test.rb
# ...
class ProductTest < ActiveSupport::TestCase
  # ...
  test 'should filter products by price and sort them' do
    assert_equal [products(:two), products(:one)], Product.above_or_equal_to_price(200).sort
  end
end

L’implémentation est très très simple:

app/models/product.rb
class Product < ApplicationRecord
  # ...
  scope :above_or_equal_to_price, lambda { |price|
    where('price >= ?', price)
  }
end

L’implémentation est suffisante pour que nos tests passent:

$ rake test
...........................

Vous pouvez maintenant imaginer le comportement de la méthode opposée. Voici les tests:

test/models/product_test.rb
# ...
class ProductTest < ActiveSupport::TestCase
  # ...
  test 'should filter products by price lower and sort them' do
    assert_equal [products(:another_tv)], Product.below_or_equal_to_price(200).sort
  end
end

Et l’implémentation:

app/models/product.rb
class Product < ApplicationRecord
  # ...
  scope :below_or_equal_to_price, lambda { |price|
    where('price <= ?', price)
  }
end

Pour notre bien, faisons les tests et vérifions que tout est beau et vert:

$ rake test
............................

Comme vous pouvez le voir, nous n’avons pas eu beaucoup de problèmes. Ajoutons simplement une autre scope pour trier les enregistrements par date de dernière mise à jour. Dans le cas où le propriétaire des produits décide de mettre à jour certaines données il voudra sûrement trier ses produits par date de création.

Tri par date de création

Ce scope est très facile. Ajoutons d’abord quelques tests:

test/models/product_test.rb
# ...
class ProductTest < ActiveSupport::TestCase
  # ...
  test 'should sort product by most recent' do
    # we will touch some products to update them
    products(:two).touch
    products(:one)

    assert_equal [products(:another_tv), products(:one), products(:two)], Product.recent.to_a
  end
end

Et l’implémentation:

app/models/product.rb
class Product < ApplicationRecord
  # ...
  scope :recent, lambda {
    order(:updated_at)
  }
end

Tous nos tests devraient passer:

$ rake test
.............................

Commitons nos changements:

$ git commit -am "Adds search scopes on the product model"

Moteur de recherche

Maintenant que nous avons la base pour le moteur de recherche que nous utiliserons dans l’application, il est temps de mettre en œuvre une méthode de recherche simple mais puissante. Elle s’occupera de gérer toute la logique pour récupérer les enregistrements des produits.

La méthode consistera à enchaîner tous les scope que nous avons construits précédemment et à retourner le résultat. Commençons par ajouter quelques tests:

test/models/product_test.rb
# ...
class ProductTest < ActiveSupport::TestCase
  # ...
  test 'search should not find "videogame" and "100" as min price' do
    search_hash = { keyword: 'videogame', min_price: 100 }
    assert Product.search(search_hash).empty?
  end

  test 'search should fin cheap TV' do
    search_hash = { keyword: 'tv', min_price: 50, max_price: 150 }
    assert_equal [products(:another_tv)], Product.search(search_hash)
  end

  test 'should get all product when no parameters' do
    assert_equal Product.all.to_a, Product.search({})
  end

  test 'search should filter by product ids' do
    search_hash = { product_ids: [products(:one).id] }
    assert_equal [products(:one)], Product.search(search_hash)
  end
end

Nous avons ajouté un tas de code mais je vous assure que l’implémentation est très facile. Vous pouvez aller plus loin et ajouter quelques tests supplémentaires mais, dans mon cas, je n’ai pas trouvé cela nécessaire.

app/models/product.rb
class Product < ApplicationRecord
  # ...
  def self.search(params = {})
    products = params[:product_ids].present? ? Product.find(params[:product_ids]) : Product.all

    products = products.filter_by_title(params[:keyword]) if params[:keyword]
    products = products.above_or_equal_to_price(params[:min_price].to_f) if params[:min_price]
    products = products.below_or_equal_to_price(params[:max_price].to_f) if params[:max_price]
    products = products.recent(params[:recent]) if params[:recent].present?

    products
  end
end

Il est important de noter que nous retournons les produits en tant qu’objet ActiveRecord::Relation afin de pouvoir enchaîner d’autres méthodes en cas de besoin ou les paginer comme nous allons le voir dans les derniers chapitres. Il suffit de mettre à jour l’action Product#index pour récupérer les produits à partir de la méthode de recherche:

app/controllers/api/v1/products_controller.rb
class Api::V1::ProductsController < ApplicationController
  # ...
  def index
    @products = Product.search(params)
    render json: ProductSerializer.new(@products).serializable_hash
  end
  # ...
end

Nous pouvons exécuter l’ensemble de la suite de tests, pour nous assurer que l’application est en bonne santé jusqu’ici:

$ rake test

.................................
33 runs, 49 assertions, 0 failures, 0 errors, 0 skips

Commitons ces changements:

$ git commit -am "Adds search class method to filter products"

Et comme nous arrivons à la fin de notre chapitre, il est temps d’appliquer toutes nos modifications sur la branche master en faisant un merge:

$ git checkout master
$ git merge chapter06

Conclusion

Jusqu’à présent, et grâce à la gemme fast_jsonapi, c’était facile. Sur les chapitres à venir, nous allons commencer à construire le modèle Order qui associera les utilisateurs aux produits.

Création des commandes

Dans les chapitres précédents nous avons traité les associations entre les produits et les modèles utilisateurs. Nous avons aussi vu comment bien les sérialiser en les optimisant afin de pouvoir scaler, (c’est-à-dire s’adapter facilement à une forte demande sur notre application). Maintenant, il est temps de commencer à passer des commandes. Cela va être une situation plus complexe parce que nous allons gérer les associations entre les trois modèles. Nous devons être assez malins pour gérer la sortie JSON que nous fournissons.

Dans ce chapitre, nous allons faire plusieurs choses:

  • Créer un modèle de commande avec les spécifications correspondantes

  • Gérer l’association de sortie JSON entre l’utilisateur de la commande et les modèles de produits

  • Envoyer un courriel de confirmation avec le récapitulatif de la commande

Maintenant que tout est clair, nous pouvons commencer à travailler. Vous pouvez cloner le projet jusqu’à ce point avec:

$ git checkout tags/checkpoint_chapter07

Créons une nouvelle branche afin de commencer à travailler:

$ git checkout -b chapter07

Modélisation de la commande

Si vous vous souvenez des associations entre les modèles, vous devez vous souvenir que le modèle Order est associé aux modèles User et Product. C’est en fait très simple de gérer cela avec Rails. La partie délicate est lors de la sérialisation de ces objets. J’en parlerai plus en détail plus tard.

Commençons par créer le modèle de la commande:

$ rails generate model order user:belongs_to total:decimal

La commande ci-dessus va générer le modèle Order. Je profite de la méthode des belongs_to pour créer la clé étrangère correspondante pour que la commande appartienne à un utilisateur. Elle ajoute aussi la directive belongs_to dans le modèle des commandes. Migrons la base de données:

$ rake db:migrate

Il est maintenant temps de créer quelques tests dans le fichier order_test.rb:

test/models/order_test.rb
# ...
class OrderTest < ActiveSupport::TestCase
  test 'Should have a positive total' do
    order = orders(:one)
    order.total = -1
    assert_not order.valid?
  end
end

L’implémentation est assez simple:

app/models/order.rb
class Order < ApplicationRecord
  belongs_to :user
  validates :total, numericality: { greater_than_or_equal_to: 0 }
  validates :total, presence: true
end

N’oubliez pas d’ajouter la relation orders à nos utilisateur en spécifiant la suppression en cascade:

app/models/user.rb
class User < ApplicationRecord
  # ...
  has_many :products, dependent: :destroy
  has_many :orders, dependent: :destroy
  # ...
end

Les tests devraient passer:

$ rake test
..................................
34 runs, 50 assertions, 0 failures, 0 errors, 0 skips

Et commitons tout cela:

$ git add . && git commit -m "Generate orders"

Les commandes et les produits

Nous devons établir la liaison entre la commande et le produit. Cela se fait avec une association many-to-many car de nombreux produits seront placés sur plusieurs commandes et les commandes auront plusieurs produits. Dans ce cas, nous avons donc besoin d’un modèle supplémentaire qui joindra ces deux autres objets et mappera l’association appropriée. Générons ce modèle:

$ rails generate model placement order:belongs_to product:belongs_to

Migrons la base de données:

$ rake db:migrate

L’implémentation est la suivante:

app/models/product.rb
class Product < ApplicationRecord
  belongs_to :user
  has_many :placements, dependent: :destroy
  has_many :products, through: :placements
  # ...
end
app/models/order.rb
class Order < ApplicationRecord
  has_many :placements, dependent: :destroy
  has_many :products, through: :placements
  # ...
end

Si vous avez suivi le tutoriel jusqu’à présent, l’implémentation est déjà là, grâce au type de belongs_to que nous passons au générateur de commandes du modèle. Nous devrions ajouter l’option inverse_of au modèle de placement pour chaque appel aux belongs_to. Cela donne un petit coup de pouce lors du référencement de l’objet parent.

app/models/placement.rb
class Placement < ApplicationRecord
  belongs_to :order
  belongs_to :product, inverse_of: :placements
end

Et maintenant, lançons tous les tests des modèles afin de nous assurer que tout est bon:

$ rake test
..................................
34 runs, 50 assertions, 0 failures, 0 errors, 0 skips

Maintenant que tout est beau et vert, commitons les changements:

$ git add . && git commit -m "Associates products and orders with a placements model"

Exposer le modèle d’utilisateur

Il est maintenant temps de préparer le contrôleur des commandes à exposer les bonnes commandes. Si vous vous souvenez des chapitres précédents où l’on avait utilisé fast_jsonapi vous devez vous rappeler que c’était vraiment facile.

Définissons d’abord quelles actions nous allons mettre en place:

  1. Une action d’indexation pour récupérer les commandes des utilisateurs en cours

  2. Une action show pour récupérer une commande particulière de l’utilisateur courant

  3. Une action de création pour passer réellement la commande

Commençons par l’action index. Nous devons d’abord créer le contrôleur de commandes:

$ rails generate controller api::v1::orders

Jusqu’ici, et avant de commencer à taper du code, nous devons nous demander:

Est-ce que je dois laisser les routes de ma commande imbriqués dans le UsersController ou bien dois je les isoler?

La réponse est vraiment simple: cela dépend de la quantité d’informations que vous voulez exposer au développeur.

Dans notre cas, nous n’allons pas le faire car nous allons récupérer les commandes de utilisateur sur la route /orders. Commençons par quelques tests:

test/controllers/api/v1/orders_controller_test.rb
# ...
class Api::V1::OrdersControllerTest < ActionDispatch::IntegrationTest
  setup do
    @order = products(:one)
  end

  test 'should forbid orders for unlogged' do
    get api_v1_orders_url, as: :json
    assert_response :forbidden
  end

  test 'should show orders' do
    get api_v1_orders_url,
      headers: { Authorization: JsonWebToken.encode(user_id: @order.user_id) },
      as: :json
    assert_response :success

    json_response = JSON.parse(response.body)
    assert_equal @order.user.orders.count, json_response['data'].count
  end
end

Si nous exécutons la suite de tests maintenant, comme vous pouvez vous y attendre, les deux tests échoueront. C’est normal car nous n’avons même pas défini ni les bonnes routes ni l’action. Commençons donc par ajouter les routes:

config/routes.rb
Rails.application.routes.draw do
  namespace :api, defaults: { format: :json } do
    namespace :v1 do
      resources :orders, only: [:index]
      # ...
    end
  end
end

Nous allons donc générer un nouveau serializer pour les commandes:

$ rails generate serializer order

Et ajoutons les relations:

app/serializers/order_serializer.rb
class OrderSerializer
  include FastJsonapi::ObjectSerializer
  belongs_to :user
  has_many :products
end

Il est maintenant temps d’implémenter le contrôleur des commandes:

app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
  before_action :check_login, only: %i[index]

  def index
    render json: OrderSerializer.new(current_user.orders).serializable_hash
  end
end

Et maintenant nos tests devraient passer:

$ rake test
....................................
36 runs, 53 assertions, 0 failures, 0 errors, 0 skips

Nous aimons nos commits très petits. Alors commitons dès maintenant:

$ git add . && git commit -m "Adds the index action for order"

Afficher une seule commande

Comme vous pouvez déjà l’imaginer, cette route est très facile. Nous n’avons qu’à mettre en place quelques configurations (routes, action du contrôleur) et ce sera tout pour cette section. Nous allons aussi inclure les produits liés à cette commande dans le JSON de sortie.

Commençons par ajouter quelques tests:

spec/controllers/api/v1/orders_controller_test.rb
# ...
class Api::V1::OrdersControllerTest < ActionDispatch::IntegrationTest
  # ...
  test 'should show orders' do
    get api_v1_orders_url, headers: { Authorization: JsonWebToken.encode(user_id: @order.user_id) },  as: :json
    assert_response :success

    json_response = JSON.parse(response.body)
    assert_equal @order.user.orders.count, json_response['data'].count
  end
end

Comme vous pouvez le voir, la deuxième partie du test vérifie que le produit est inclus dans le JSON.

Ajoutons l’implémentation pour faire passer nos tests. Sur le fichier routes.rb ajoutez l’action show aux routes des commandes:

config/routes.rb
# ...
Rails.application.routes.draw do
  # ...
  resources :orders, only: %i[index show]
  # ...
end

Et l’implémentation devrait ressembler à ceci:

app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
  before_action :check_login, only: %i[index show]
  # ...
  def show
    order = current_user.orders.find(params[:id])

    if order
      options = { include: [:products] }
      render json: OrderSerializer.new(order, options).serializable_hash
    else
      head 404
    end
  end
end

Tous nos tests passent désormais:

$ rake test
.....................................
37 runs, 55 assertions, 0 failures, 0 errors, 0 skips

Commitons les changements et passons à l’action Product#create.

$ git commit -am "Adds the show action for order"

Placement et commandes

Il est maintenant temps de donner la possibilité à l’utilisateur de passer quelques commandes. Cela ajoutera de la complexité à l’application, mais ne vous inquiétez pas, nous allons faire les choses une étape à la fois.

Avant de lancer cette fonctionnalité, prenons le temps de réfléchir aux implications de la création d’une commande dans l’application. Je ne parle pas de la mise en place d’un service de transactions comme Stripe ou Braintree mais de choses comme:

  • la gestion des produits en rupture de stock

  • la diminution de l’inventaire de produits

  • ajouter une certaine validation pour le placement de la commande pour s’assurer qu’il y a suffisamment de produits au moment où la commande est passée

On dirait qu’il reste un paquet de chose à faire mais croyez-moi: vous êtes plus près que vous ne le pensez et ce n’est pas aussi dur que ça en a l’air. Pour l’instant, gardons les choses simples et supposons que nous avons toujours assez de produits pour passer un nombre quelconque de commandes. Nous nous soucions juste de la réponse du serveur pour le moment.

Si vous vous rappelez le modèle de commande, nous avons besoin de trois choses: un total pour la commande, l’utilisateur qui passe la commande et les produits pour la commande. Compte tenu de cette information, nous pouvons commencer à ajouter quelques tests:

test/controllers/api/v1/orders_controller_test.rb
# ...
class Api::V1::OrdersControllerTest < ActionDispatch::IntegrationTest
  setup do
    # ...
    @order_params = { order: {
      product_id: [products(:one).id, products(:two).id],
      total: 50
    } }
  end

  # ...

  test 'should forbid create order for unlogged' do
    assert_no_difference('Order.count') do
      post api_v1_orders_url, params: @order_params, as: :json
    end
    assert_response :forbidden
  end

  test 'should create order with two products' do
    assert_difference('Order.count', 1) do
      post api_v1_orders_url,
        params: @order_params,
        headers: { Authorization: JsonWebToken.encode(user_id: @order.user_id) },
        as: :json
    end
    assert_response :created
  end
end

Comme vous pouvez le voir, nous sommes en train de créer une variable order_params avec les données de la commande. Vous voyez le problème ici? Je l’expliquerai plus tard. Ajoutons simplement le code nécessaire pour faire passer ce test.

Nous devons d’abord ajouter l’action aux routes:

config/routes.rb
# ...
Rails.application.routes.draw do
  # ...
  resources :orders, only: %i[index show create]
  # ...
end

Ensuite, la mise en œuvre qui est facile:

app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
  before_action :check_login, only: %i[index show create]
  # ...

  def create
    order = current_user.orders.build(order_params)

    if order.save
      render json: order, status: 201
    else
      render json: { errors: order.errors }, status: 422
    end
  end

  private

  def order_params
    params.require(:order).permit(:total, product_ids: [])
  end
end

Et maintenant, nos tests devraient tous passer:

$ rake test
.......................................
39 runs, 59 assertions, 0 failures, 0 errors, 0 skips

Ok donc tout va bien. Nous devrions maintenant passer au chapitre suivant, non? Laissez-moi faire une pause avant. Nous avons de graves erreurs sur l’application et elles ne sont pas liées au code lui-même mais sur la partie métier.

Ce n’est pas parce que les tests passent que l’application remplit la partie métier de l’application. Je voulais en parler parce que dans de nombreux cas, c’est super facile de simplement recevoir des paramètres et de construire des objets à partir de ces paramètres. Dans notre cas, nous ne pouvons pas nous fier aux données que nous recevons. En effet, nous laissons ici le client fixer le total de la commande! Ouais, c’est fou!

Nous devons donc ajouter quelques validations et calculer le total de la commande dans le modèle. De cette façon, nous ne recevons plus cet attribut total et nous avons un contrôle complet sur cet attribut. Alors faisons-le.

Nous devons d’abord ajouter quelques tests pour le modèle de commande:

test/models/order_test.rb
# ...
class OrderTest < ActiveSupport::TestCase

  setup do
    @order = orders(:one)
  end

  test 'Should set total' do
    order = Order.new user_id: @order.user_id
    order.products << products(:one)
    order.products << products(:two)
    order.save

    assert_equal (@product1.price + @product2.price), order.total
  end
end

Nous pouvons maintenant ajouter l’implémentation:

app/models/order.rb
class Order < ApplicationRecord
  # ...
  def set_total!
    self.total = self.products.map(&:price).sum
  end
end

Juste avant que vous ne lanciez vos tests, nous avons besoin de mettre à jour l’usine de commande:

Nous pouvons maintenant hooker la méthode set_total! à un rappel before_validation pour s’assurer qu’il a le bon total avant la validation.

Note
Le hook est une méthode qui se déclenchera automatiquement lors de l’exécution
app/models/order.rb
class Order < ApplicationRecord
  before_validation :set_total!
  # ...
end

A ce stade, nous nous assurons que le total est toujours présent et supérieur ou égal à zéro, ce qui signifie que nous pouvons supprimer ces validations et supprimer les spécifications. Nos tests devraient passer maintenant:

$ rake test

...........F

Failure:
OrderTest#test_Should_have_a_positive_total [/home/arousseau/github/madeindjs/market_place_api/test/models/order_test.rb:14]:
Expected true to be nil or false


rails test test/models/order_test.rb:11

............................

Finished in 0.542600s, 73.7191 runs/s, 110.5786 assertions/s.

Oups! Nous obtenons une failure sur notre précédent test Should have a positive total. C’est logique puisque le total de la commande se calcule dynamiquement. Nous pouvons donc tout simplement supprimer ce test qui est devenu obsolète.

Nos tests doivent continuer à passer. Commitons nos changements:

$ git commit -am "Adds the create method for the orders controller"

Envoyer un email de confirmation

La dernière section de ce chapitre sera d’envoyer un courriel de confirmation à l’utilisateur qui vient de créer une commande. Si vous le voulez, vous pouvez sauter cette étape et passer au chapitre suivant! Cette section est plus à un bonus.

Vous êtes peut-être familier avec la manipulation des courriels avec Rails, je vais essayer de rendre cela simple et rapide:

Nous commençons par créer le order_mailer avec un mail nommé send_confirmation:

$ rails generate mailer order_mailer send_confirmation

Maintenant, nous pouvons ajouter quelques tests pour les mails de commandes que nous venons de créer:

test/mailers/order_mailer_test.rb
# ...
class OrderMailerTest < ActionMailer::TestCase

  setup do
    @order = orders(:one)
  end

  test "should be set to be delivered to the user from the order passed in" do
    mail = OrderMailer.send_confirmation(@order)
    assert_equal "Order Confirmation", mail.subject
    assert_equal [@order.user.email], mail.to
    assert_equal ['no-reply@marketplace.com'], mail.from
    assert_match "Order: ##{@order.id}", mail.body.encoded
    assert_match "You ordered #{@order.products.count} products", mail.body.encoded
  end

end

J’ai simplement copié/collé les tests de la documentation et je les ai adaptés à nos besoins. Nous devons maintenant nous assurer que ces tests passent.

Tout d’abord, nous ajoutons la méthode OrderMailer#send_confirmation:

app/mailers/order_mailer.rb
class OrderMailer < ApplicationMailer
  default from: 'no-reply@marketplace.com'
  def send_confirmation(order)
    @order = order
    @user = @order.user
    mail to: @user.email, subject: 'Order Confirmation'
  end
end

Après avoir ajouté ce code, nous devons maintenant ajouter les vues correspondantes. C’est une bonne pratique d’inclure une version texte en plus de la version HTML.

<%# app/views/order_mailer/send_confirmation.txt.erb %>
Order: #<%= @order.id %>
You ordered <%= @order.products.count %> products:
<% @order.products.each do |product| %>
  <%= product.title %> - <%= number_to_currency product.price %>
<% end %>
<!-- app/views/order_mailer/send_confirmation.html.erb -->
<h1>Order: #<%= @order.id %></h1>
<p>You ordered <%= @order.products.count %> products:</p>
<ul>
  <% @order.products.each do |product| %>
    <li><%= product.title %> - <%= number_to_currency product.price %></li>
  <% end %>
</ul>

Maintenant, nos tests devraient passer:

$ rake test
........................................
40 runs, 66 assertions, 0 failures, 0 errors, 0 skips

Et maintenant, il suffit d’appeler la méthode OrderMailer#send_confirmation dans l’action de création sur le contrôleur des ordres:

app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
  # ...
  def create
    order = current_user.orders.build(order_params)

    if order.save
      OrderMailer.send_confirmation(order).deliver
      render json: order, status: 201
    else
      render json: { errors: order.errors }, status: 422
    end
  end
  # ...
end

Pour être sûr que nous n’avons rien cassé, lançons tous les tests:

$ rake test
........................................
40 runs, 66 assertions, 0 failures, 0 errors, 0 skips

Commitons tout ce que nous venons de faire pour terminer cette section:

$ git add . && git commit -m "Adds order confirmation mailer"

Et comme nous arrivons à la fin de notre chapitre, il est temps d’appliquer toutes nos modifications sur la branche master en faisant un merge:

$ git checkout master
$ git merge chapter07

Conclusion

Ça y est! Vous avez réussi! Vous pouvez vous applaudir. Je sais que ça a été long mais c’est presque fini, croyez moi.

Sur les chapitres à venir, nous continuerons à travailler sur le modèle de commande pour ajouter des validations lors de la passation d’une commande. Certains scénarios sont:

  • Que se passe-t-il lorsque les produits ne sont pas disponibles?

  • Diminuer la quantité du produit en cours lors de la passation d’une commande

Le prochain chapitre sera court, mais il est très important pour la santé de l’application. Alors ne le sautez pas.

Améliorer les commandes

Précédemment nous avons amélioré notre API pour passer des commandes et envoyer un e-mail de confirmation à l’utilisateur (juste pour améliorer l’expérience utilisateur). Ce chapitre va s’occuper de quelques validations sur le modèle de commande afin de s’assurer qu’elle est valide. C’est-à-dire:

  • Diminuer la quantité du produit en cours lors de la passation d’une commande

  • Que se passe-t-il lorsque les produits ne sont pas disponibles?

Nous aurons aussi besoin de mettre à jour un peu la sortie JSON pour les commandes. Mais ne divulgâchons pas la suite.

Maintenant que tout est clair, nous pouvons mettre les mains dans le cambouis. Vous pouvez cloner le projet jusqu’à ce point avec:

$ git checkout tags/checkpoint_chapter08

Créons une nouvelle branche afin de commencer à travailler:

$ git checkout -b chapter08

Diminution de la quantité de produit

Dans cette partie nous travaillerons sur la mise à jour de la quantité du produit pour nous assurer que chaque commande livrera le produit réel. Actuellement, le modèle de produit n’a pas d’attribut de quantité. Alors faisons-le:

$ rails generate migration add_quantity_to_products quantity:integer

Attendez! N’exécutez pas encore cette migration! Nous allons y apporter une petite modification. Comme bonne pratique, j’aime ajouter des valeurs par défaut pour la base de données juste pour être sûr de ne pas tout gâcher avec des valeurs nulles. C’est un cas parfait!

Votre fichier de migration devrait ressembler à ceci:

db/migrate/20190621105101_add_quantity_to_products.rb
class AddQuantityToProducts < ActiveRecord::Migration[6.0]
  def change
    add_column :products, :quantity, :integer, default: 0
  end
end

Maintenant nous pouvons lancer la migration:

$ rake db:migrate

Et n’oublions pas de mettre à jour les fixtures en ajoutant le champs quantity (j’ai choisi la valeur 5 totalement par hasard).

test/fixtures/products.yml
one:
  # ...
  quantity: 5

two:
  # ...
  quantity: 5

another_tv:
  # ...
  quantity: 5

Il est maintenant temps de diminuer la quantité du Product une fois l'`Order` passée. La première chose qui vous vient probablement à l’esprit est de le faire dans le modèle Order et c’est une erreur fréquente. Lorsque vous travaillez avec des associations Many-to-Many, nous oublions totalement le modèle de jointure qui dans ce cas est Placement. Le Placement est un meilleur endroit pour gérer cela car nous avons accès à la commande et au produit. Ainsi, nous pouvons facilement diminuer le stock du produit.

Avant de commencer à implémenter le code, nous devons changer la façon dont nous gérons la création de la commande car nous devons maintenant accepter une quantité pour chaque produit. Si vous vous souvenez, nous attendons un tableau d’identifiants de produits. Je vais essayer de garder les choses simples et je vais envoyer un tableau de Hash avec les clefs product_id et quantity.

Un exemple rapide serait quelque chose comme cela:

product_ids_and_quantities = [
  { product_id: 1, quantity: 4 },
  { product_id: 3, quantity: 5 }
]

Ça va être difficile, alors restez avec moi. Construisons d’abord des tests unitaires:

test/models/order_test.rb
# ...
class OrderTest < ActiveSupport::TestCase
  # ...

  test 'builds 2 placements for the order' do
    @order.build_placements_with_product_ids_and_quantities [
      { product_id: @product1.id, quantity: 2 },
      { product_id: @product2.id, quantity: 3 },
    ]

    assert_difference('Placement.count', 2) do
      @order.save
    end
  end
end

Et maintenant l’implémentation

app/models/order.rb
class Order < ApplicationRecord
  # ...

  # @param product_ids_and_quantities [Array<Hash>] something like this `[{product_id: 1, quantity: 2}]`
  # @yield [Placement] placements build
  def build_placements_with_product_ids_and_quantities(product_ids_and_quantities)
    product_ids_and_quantities.each do |product_id_and_quantity|
      placement = placements.build(product_id: product_id_and_quantity[:product_id])
      yield placement if block_given?
    end
  end
end

Et maintenant, si nous lançons les tests, ils devraient passer:

$ rake test
........................................
40 runs, 60 assertions, 0 failures, 0 errors, 0 skips

Les build_placements_with_product_ids_and_quantities construiront les objets Placement et une fois que nous déclencherons la méthode de sauvegarde de l’ordre, tout sera inséré dans la base de données. Une dernière étape avant de valider ceci est de mettre à jour orders_controller_test avec son implémentation.

Tout d’abord, nous mettons à jour le fichier orders_controller_test:

test/controllers/api/v1/orders_controller_test.rb
# ...
class Api::V1::OrdersControllerTest < ActionDispatch::IntegrationTest
  setup do
    @order = products(:one)
    @order_params = {
      order: {
        product_ids_and_quantities: [
          { product_id: products(:one).id, quantity: 2 },
          { product_id: products(:two).id, quantity: 3 },
        ]
      }
    }
  end

  # ...

  test 'should create order with two products and placements' do
    assert_difference('Order.count', 1) do
      assert_difference('Placement.count', 2) do
        post api_v1_orders_url, params: @order_params, as: :json
            headers: { Authorization: JsonWebToken.encode(user_id: @order.user_id) },
      end
    end
    assert_response :created
  end
end

Nous devons ensuite mettre un peu à jour notre contrôleur des commandes:

app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
  # ...

  def create
    order = Order.create! user: current_user
    order.build_placements_with_product_ids_and_quantities(order_params[:product_ids_and_quantities])

    if order.save
      render json: order, status: :created
    else
      render json: { errors: order.errors }, status: :forbidden
    end
  end

  private

  def order_params
    params.require(:order).permit(product_ids_and_quantities: [:product_id, :quantity])
  end
end

Notez que j’ai aussi modifié la méthode OrdersController#order_params.

Enfin et surtout, nous devons mettre à jour le fichier d’usine des produits afin d’attribuer une valeur de quantité élevée pour avoir au moins quelques produits en stock.

Commitons nos changements avant d’aller plus loin:

$ git add .
$ git commit -m "Allows the order to be placed along with product quantity"

Avez-vous remarqué que nous ne mettons pas à jour la quantité des produits? Actuellement, il n’y a aucun moyen d’en faire le suivi. Cela peut être corrigé très facilement, en ajoutant simplement un attribut de quantité au modèle Placement de sorte que pour chaque produit, nous sauvegardons la quantité correspondante. Commençons par créer la migration:

$ rails generate migration add_quantity_to_placements quantity:integer

Comme pour la migration des attributs de quantité de produit, nous devrions ajouter une valeur par défaut égale à 0. N’oubliez pas que c’est facultatif mais c’est mieux. Le fichier de migration devrait ressembler à cela:

db/migrate/20190621114614_add_quantity_to_placements.rb
class AddQuantityToPlacements < ActiveRecord::Migration[5.2]
  def change
    add_column :placements, :quantity, :integer, default: 0
  end
end

Lancez ensuite la migration:

$ rake db:migrate

Ajoutons l’attribut quantity dans les fixtures:

test/fixtures/placements.yml
one:
  # ...
  quantity: 5

two:
  # ...
  quantity: 5

Il ne nous reste plus qu’à mettre à jour la méthode build_placements_with_product_ids_and_quantities pour ajouter la quantité pour les placements:

app/models/order.rb
class Order < ApplicationRecord
  # ...

  # @param product_ids_and_quantities [Array<Hash>] something like this `[{product_id: 1, quantity: 2}]`
  # @yield [Placement] placements build
  def build_placements_with_product_ids_and_quantities(product_ids_and_quantities)
    product_ids_and_quantities.each do |product_id_and_quantity|
      placement = placements.build(
        product_id: product_id_and_quantity[:product_id],
        quantity: product_id_and_quantity[:quantity],
      )
      yield placement if block_given?
    end
  end
end

Maintenant, nos tests devraient passer:

$ rake test
........................................
40 runs, 61 assertions, 0 failures, 0 errors, 0 skips

Commitons nos changement:

$ git add .
$ git commit -m "Adds quantity to placements"

Étendre le modèle de placement

Il est temps de mettre à jour la quantité du produit une fois la commande enregistrée ou plus précisément: une fois le placement créé. Pour se faire, nous allons ajouter une méthode et la connecter au callback after_create.

Commençons simplement par ajouter quelques tests:

test/models/placement_test.rb
# ...
class PlacementTest < ActiveSupport::TestCase
  setup do
    @placement = placements(:one)
  end

  test 'decreases the product quantity by the placement quantity' do
    product = @placement.product

    assert_difference('product.quantity', -@placement.quantity) do
      @placement.decrement_product_quantity!
    end
  end
end

La mise en œuvre est assez simple comme le montre le code suivant.

app/models/placement.rb
class Placement < ApplicationRecord
  # ...
  after_create :decrement_product_quantity!

  def decrement_product_quantity!
    product.decrement!(:quantity, quantity)
  end
end

Commitons nos changement:

$ git commit -am "Decreases the product quantity by the placement quantity"

Validation du stock des produits

Depuis le début du chapitre, nous avons ajouté l’attribut quantity au modèle de produit. il est maintenant temps de valider que la quantité de produit est suffisante pour que la commande soit passée. Afin de rendre les choses plus intéressantes, nous allons le faire à l’aide d’un validateur personnalisé.

Note
vous pouvez consulter la documentation.

Tout d’abord, nous devons créer un répertoire de validators dans le répertoire app (Rails le charge par défaut) et ensuite créons un fichier dedans:

$ mkdir app/validators
$ touch app/validators/enough_products_validator.rb

Avant de commencer à implémenter la classe, nous devons nous assurer d’ajouter un test au modèle de commande pour vérifier si la commande peut être passée.

test/models/order_test.rb
# ...
class OrderTest < ActiveSupport::TestCase
  # ...

  test "an order should command not too much product than available" do
    @order.placements << Placement.new(product_id: @product1.id, quantity: (1 + @product1.quantity))

    assert_not @order.valid?
  end
end

Comme vous pouvez le voir sur les tests suivants, nous nous assurons d’abord que placement_2 essaie de demander plus de produits que ce qui est disponible. Donc dans ce cas la commande n’est pas supposée être valide.

Le test est en train d’échouer. Faisons le passer en implémentant le code pour le validateur:

app/validators/enough_products_validator.rb
class EnoughProductsValidator < ActiveModel::Validator
  def validate(record)
    record.placements.each do |placement|
      product = placement.product
      if placement.quantity > product.quantity
        record.errors[product.title.to_s] << "Is out of stock, just #{product.quantity} left"
      end
    end
  end
end

J’ajoute simplement un message pour chacun des produits en rupture de stock, mais vous pouvez le gérer différemment si vous le souhaitez. Il ne nous reste plus qu’à ajouter ce validateur au modèle Order comme cela:

app/models/order.rb
class Order < ApplicationRecord
  include ActiveModel::Validations
  # ...
  validates_with EnoughProductsValidator
  # ...
end

Et maintenant, si vous lancez vos tests, tout devrait être beau et vert:

$ rake test
..........................................
42 runs, 63 assertions, 0 failures, 0 errors, 0 skips

Commitons nos changements:

$ git add .
$ git commit -m "Adds validator for order with not enough products on stock"

Mettre à jour le prix total

Réalisez vous que le prix total est mal calculé? Actuellement, nous ajoutons le prix des produits sur la commande, quelle que soit la quantité demandée. Permettez-moi d’ajouter le code pour clarifier le problème:

Actuellement, dans le modèle de commande, nous avons cette méthode pour calculer le montant à payer:

app/models/order.rb
class Order < ApplicationRecord
  # ...
  def set_total!
    self.total = products.map(&:price).sum
  end
  # ...
end

Maintenant, au lieu de calculer le total en additionnant simplement les prix des produits, nous devons le multiplier par la quantité. Alors mettons d’abord à jour les tests:

test/models/order_test.rb
# ...
class OrderTest < ActiveSupport::TestCase
  # ...

  test "Should set total" do
    @order.placements = [
      Placement.new(product_id: @product1.id, quantity: 2),
      Placement.new(product_id: @product2.id, quantity: 2)
    ]
    @order.set_total!
    expected_total = (@product1.price * 2) + (@product2.price * 2)

    assert_equal expected_total, @order.total
  end
end

L’implémentation est assez simple:

app/models/order.rb
class Order < ApplicationRecord
  # ...
  def set_total!
    self.total = self.placements
                     .map{ |placement| placement.product.price * placement.quantity }
                     .sum
  end
  # ...
end

Et maintenant, les tests devraient passer:

$ rake test
..........................................
42 runs, 63 assertions, 0 failures, 0 errors, 0 skips

Commitons nos changements et récapitulons tout ce que nous venons de faire:

$ git commit -am "Updates the total calculation for order"

Et comme nous arrivons à la fin de notre chapitre, il est temps d’appliquer toutes nos modifications sur la branche master en faisant un merge:

$ git checkout master
$ git merge chapter08

Conclusion

Oh vous êtes ici! Permettez-moi de vous féliciter! Cela fait un long chemin depuis le premier chapitre. Mais vous êtes à un pas de plus. En fait, le chapitre suivant sera le dernier. Alors essayez d’en tirer le meilleur.

Le dernier chapitre portera sur la façon d’optimiser l’API en utilisant la pagination, la mise en cache et les tâches d’arrière-plan. Donc bouclez vos ceintures, ça va être un parcours mouvementé.

Optimisations

Bienvenue dans le dernier chapitre du livre. Le chemin a été long mais vous n’êtes qu’à un pas de la fin. Dans le chapitre précédent, nous avons terminé la modélisation du modèle de commandes. Nous pourrions dire que le projet est maintenant terminé mais je veux couvrir quelques détails importants sur l’optimisation. Les sujets que je vais aborder ici seront:

  • la pagination

  • la mise en cache

  • l’optimisation des requêtes SQL

  • l’activation de CORS

J’essaierai d’aller aussi loin que possible en essayant de couvrir certains scénarios courants. J’espère que ces scenarii vous seront utiles pour certains de vos projets.

Si vous commencez à lire à ce stade, vous voudrez probablement que le code fonctionne, vous pouvez le cloner comme ça:

$ git checkout tags/checkpoint_chapter09

Créons une nouvelle branche pour ce chapitre:

$ git checkout -b chapter09

Pagination

Une stratégie très commune pour optimiser la récupération d’enregistrements dans une base de données est de charger seulement une quantité limitée en les paginant. Si vous êtes familier avec cette technique, vous savez qu’avec Rails c’est vraiment très facile à mettre en place avec des gemmes telles que will_paginate ou kaminari.

La seule partie délicate ici est de savoir comment gérer la sortie JSON pour donner assez d’informations au client sur la façon dont le tableau est paginé. Dans la section précédente, j’ai partagé quelques ressources sur les pratiques que j’allais suivre ici. L’une d’entre elles était http://jsonapi.org/ qui est une page incontournable des signets.

Si nous lisons la section sur le format, nous arriverons à une sous-section appelée Top Level. Pour vous expliquer rapidement, ils mentionnent quelque chose sur la pagination:

"meta": méta-information sur une ressource, telle que la pagination.

Ce n’est pas très descriptif mais au moins nous avons un indice sur ce qu’il faut regarder ensuite au sujet de l’implémentation de la pagination. Ne vous inquiétez pas, c’est exactement ce que nous allons faire ici.

Commençons par la liste des produits.

Les produits

Nous allons commencer par paginer la liste des produits car nous n’avons aucune restriction d’accès. Cela nous facilitera les tests. Nous devons d’abord ajouter la gemme de kaminari à notre Gemfile:

$ bundle add kaminari

Maintenant nous pouvons aller à l’action Products#index et ajouter les méthodes de pagination comme indiqué dans la documentation:

app/controllers/api/v1/products_controller.rb
class Api::V1::ProductsController < ApplicationController
  # ...
  def index
    @products = Product.page(params[:page])
                       .per(params[:per_page])
                       .search(params)

    render json: ProductSerializer.new(@products).serializable_hash
  end
  # ...
end

Jusqu’à présent, la seule chose qui a changé est la requête sur la base de données pour limiter le résultat à 25 par page (ce qui est la valeur par défaut). Mais nous n’avons toujours pas ajouté d’informations supplémentaires à la sortie JSON.

Nous devons fournir les informations de pagination sur la balise meta dans le formulaire suivant:

{
  "data": [
    ...
  ],
  "links": {
    "first": "/api/v1/products?page=1",
    "last": "/api/v1/products?page=30",
    "prev": "/api/v1/products",
    "next": "/api/v1/products?page=2"
  }
}

Maintenant que nous avons la structure finale de la balise meta, il ne nous reste plus qu’à la sortir sur la réponse JSON. Ajoutons d’abord quelques tests:

test/controllers/api/v1/products_controller_test.rb
# ...
class Api::V1::ProductsControllerTest < ActionDispatch::IntegrationTest
  # ...
  test 'should show products' do
    get api_v1_products_url, as: :json
    assert_response :success

    json_response = JSON.parse(response.body, symbolize_names: true)
    assert_not_nil json_response.dig(:links, :first)
    assert_not_nil json_response.dig(:links, :last)
    assert_not_nil json_response.dig(:links, :prev)
    assert_not_nil json_response.dig(:links, :next)
  end
  # ...
end

Le test que nous venons d’ajouter devrait échouer:

$ rake test
......................F

Failure:
Api::V1::ProductsControllerTest#test_should_show_products [test/controllers/api/v1/products_controller_test.rb:13]:
Expected nil to not be nil.

Ajoutons les informations de pagination. Nous allons en faire une partie dans un concern séparé afin de mieux découpler notre code:

app/controllers/concerns/paginable.rb
# app/controllers/concerns/paginable.rb
module Paginable
  protected

  def current_page
    (params[:page] || 1).to_i
  end

  def per_page
    (params[:per_page] || 20).to_i
  end
end

Et maintenant nous pouvons l’utiliser dans le contrôleur.

app/controllers/api/v1/products_controller.rb
class Api::V1::ProductsController < ApplicationController
  include Paginable
  # ...

  def index
    @products = Product.page(current_page)
                       .per(per_page)
                       .search(params)

    options = {
      links: {
        first: api_v1_products_path(page: 1),
        last: api_v1_products_path(page: @products.total_pages),
        prev: api_v1_products_path(page: @products.prev_page),
        next: api_v1_products_path(page: @products.next_page),
      }
    }

    render json: ProductSerializer.new(@products, options).serializable_hash
  end
end

Maintenant, si nous vérifions les spécifications, elles devraient toutes passer:

$ rake test
..........................................
42 runs, 65 assertions, 0 failures, 0 errors, 0 skips

Maintenant que nous avons fait une superbe optimisation pour la route de la liste des produits, c’est au client de récupérer la page avec le bon paramètre per_page pour les enregistrements.

Commitons ces changements et continuons avec la liste des commandes.

$ git add .
$ git commit -m "Adds pagination for the products index action to optimize response"

Liste des commandes

Maintenant, il est temps de faire exactement la même chose pour la route de la liste des commandes. Cela devrait être très facile à mettre en œuvre. Mais d’abord, ajoutons quelques tests au fichier orders_controller_test.rb:

test/controllers/api/v1/orders_controller_test.rb
# ...
class Api::V1::OrdersControllerTest < ActionDispatch::IntegrationTest
  # ...
  test 'should show orders' do
    get api_v1_orders_url, headers: { Authorization: JsonWebToken.encode(user_id: @order.user_id) }, as: :json
    assert_response :success

    json_response = JSON.parse(response.body)
    assert_equal @order.user.orders.count, json_response['data'].count
    assert_not_nil json_response.dig(:links, :first)
    assert_not_nil json_response.dig(:links, :last)
    assert_not_nil json_response.dig(:links, :prev)
    assert_not_nil json_response.dig(:links, :next)
  end
  # ...
end

Et, comme vous vous en doutez peut-être déjà, nos tests ne passent plus:

$ rake test
......................................F

Failure:
Api::V1::OrdersControllerTest#test_should_show_orders [test/controllers/api/v1/orders_controller_test.rb:28]:
Expected nil to not be nil.

Transformons le rouge en vert:

app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
  include Paginable
  # ...

  def index
    @orders = current_user.orders
                          .page(current_page)
                          .per(per_page)

    options = {
      links: {
        first: api_v1_orders_path(page: 1),
        last: api_v1_orders_path(page: @orders.total_pages),
        prev: api_v1_orders_path(page: @orders.prev_page),
        next: api_v1_orders_path(page: @orders.next_page),
      }
    }

    render json: OrderSerializer.new(@orders, options).serializable_hash
  end
  # ...
end

Les tests devraient maintenant passer:

$ rake test
..........................................
42 runs, 67 assertions, 0 failures, 0 errors, 0 skips

Faisons un commit avant d’avancer

$ git commit -am "Adds pagination for orders index action"

Factorisation de la pagination

Si vous avez suivi ce tutoriel ou si vous êtes un développeur Rails expérimenté, vous aimez probablement garder les choses DRY. Vous avez sûrement remarqué que le code que nous venons d’écrire est dupliqué. Je pense que c’est une bonne habitude de nettoyer un peu le code une fois la fonctionnalité implémentée.

Nous allons d’abord commencer par nettoyer ces tests que nous avons dupliqués dans le fichier orders_controller_test.rb et products_controller_test.rb:

assert_not_nil json_response.dig(:links, :first)
assert_not_nil json_response.dig(:links, :last)
assert_not_nil json_response.dig(:links, :next)
assert_not_nil json_response.dig(:links, :prev)

Afin de le factoriser, nous allons déplacer ces assertions dans le fichier test_helper.rb dans une méthode que nous utiliserons:

test/test_helper.rb
# ...
class ActiveSupport::TestCase
  # ...
  def assert_json_response_is_paginated json_response
    assert_not_nil json_response.dig(:links, :first)
    assert_not_nil json_response.dig(:links, :last)
    assert_not_nil json_response.dig(:links, :next)
    assert_not_nil json_response.dig(:links, :prev)
  end
end

Cet exemple partagé peut maintenant être utilisé pour remplacer les cinq tests des fichiers orders_controller_test.rb et products_controller_test.rb:

test/controllers/api/v1/orders_controller_test.rb
# ...
class Api::V1::OrdersControllerTest < ActionDispatch::IntegrationTest
  # ...
  test 'should show orders' do
    get api_v1_orders_url, headers: { Authorization: JsonWebToken.encode(user_id: @order.user_id) }, as: :json
    assert_response :success

    json_response = JSON.parse(response.body, symbolize_names: true)
    assert_equal @order.user.orders.count, json_response[:data].count
    assert_json_response_is_paginated json_response
  end
  # ...
end
test/controllers/api/v1/products_controller_test.rb
# ...
class Api::V1::ProductsControllerTest < ActionDispatch::IntegrationTest
  # ...
  test 'should show products' do
    get api_v1_products_url, as: :json
    assert_response :success

    json_response = JSON.parse(response.body, symbolize_names: true)
    assert_not_nil json_response.dig(:links, :first)
    assert_not_nil json_response.dig(:links, :last)
    assert_not_nil json_response.dig(:links, :next)
    assert_not_nil json_response.dig(:links, :prev)
  end
  # ...
end

Et les deux tests devraient passer.

$ rake test
..........................................
42 runs, 71 assertions, 0 failures, 0 errors, 0 skips

Maintenant que nous avons fait cette simple factorisation pour les tests, nous pouvons passer à l’implémentation de la pagination pour les contrôleurs et nettoyer les choses. Si vous vous souvenez de l’action d’indexation pour les deux contrôleurs de produits et de commandes, ils ont tous les deux le même format de pagination. Alors déplaçons cette logique dans une méthode appelée get_links_serializer_options sous le fichier paginable.rb, de cette façon nous pouvons y accéder sur tout contrôleur qui aurait besoin de pagination.

app/controllers/concerns/paginable.rb
module Paginable
  protected

  def get_links_serializer_options links_paths, collection
    {
      links: {
        first: send(links_paths, page: 1),
        last: send(links_paths, page: collection.total_pages),
        prev: send(links_paths, page: collection.prev_page),
        next: send(links_paths, page: collection.next_page),
      }
    }
  end
  # ...
end

Il suffit ensuite d’utiliser cette méthode dans nos deux contrôleurs:

app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < ApplicationController
  include Paginable
  # ...

  def index
    @orders = current_user.orders
                          .page(current_page)
                          .per(per_page)

    options = get_links_serializer_options('api_v1_orders_path', @orders)

    render json: OrderSerializer.new(@orders, options).serializable_hash
  end
  # ...
end
app/controllers/api/v1/products_controller.rb
class Api::V1::ProductsController < ApplicationController
  include Paginable
  # ...

  def index
    @products = Product.page(current_page)
                       .per(per_page)
                       .search(params)

    options = get_links_serializer_options('api_v1_products_path', @products)

    render json: ProductSerializer.new(@products, options).serializable_hash
  end
  # ...
end

Lançons les tests pour nous assurer que tout fonctionne:

$ rake test
..........................................
42 runs, 71 assertions, 0 failures, 0 errors, 0 skips

Ce serait un bon moment pour commiter les changements et passer à la prochaine section sur la mise en cache.

$ git commit -am "Factorize pagination"

Mise en cache

Il y a actuellement une implémentation pour faire de la mise en cache avec la gemme fast_jsonapi qui est vraiment facile à manipuler. Bien que dans les anciennes versions de la gemme, cette implémentation peut changer, elle fait le travail.

Si nous effectuons une demande à la liste des produits, nous remarquerons que le temps de réponse prend environ 174 millisecondes en utilisant cURL

$ curl -w 'Total: %{time_total}\n' -o /dev/null -s http://localhost:3000
Total: 0,137088
Note
L’option -w nous permet de récupérer le temps de la requête, -o redirige la réponse vers un fichier et -s masque l’affichage de cURL

En ajoutant seulement une ligne à la classe ProductSerializer, nous verrons une nette amélioration du temps de réponse!

app/serializers/order_serializer.rb
class OrderSerializer
  # ...
  cache_options enabled: true, cache_length: 12.hours
end
app/serializers/product_serializer.rb
class ProductSerializer
  # ...
  cache_options enabled: true, cache_length: 12.hours
end
app/serializers/user_serializer.rb
class UserSerializer
  # ...
  cache_options enabled: true, cache_length: 12.hours
end

Et c’est tout! Vérifions l’amélioration:

$ curl -w 'Total: %{time_total}\n' -o /dev/null -s http://localhost:3000/products
Total: 0,054786
$ curl -w 'Total: %{time_total}\n' -o /dev/null -s http://localhost:3000/products/
Total: 0,032341

Nous sommes donc passés de 137 ms à 40 ms. L’amélioration est donc énorme! Committons une dernière fois nos changements.

$ git commit -am "Adds caching for the serializers"

Requêtes N+1

Les requêtes N+1 sont une plaie qui peuvent avoir un impact énorme sur les performances d’une application. Ce phénomène se produit souvent lorsqu’on utilise un ORM car il génère automatiquement les requêtes SQL pour nous. Cet outil bien pratique est à double tranchant car il peut générer un grand nombre de requêtes SQL.

Quelque chose à savoir avec les requêtes SQL est qu’il vaut mieux faire en sorte de limiter leur nombre. En d’autres termes, une grosse requête est souvent plus performante que cent petites.

Voici un exemple où l’on veut récupérer tous les utilisateurs qui ont déjà créé un produit. Ouvrez la console Rails avec rails console et exécutez le code Ruby suivant:

Product.all.map { |product| product.user }

La console interactive de Rails nous montre les requêtes SQL qui sont générées. Voyez par vous même:

Product Load (0.5ms)  SELECT "products".* FROM "products"
User Load (0.2ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 28], ["LIMIT", 1]]
User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 28], ["LIMIT", 1]]
User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 29], ["LIMIT", 1]]
User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 29], ["LIMIT", 1]]
User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 30], ["LIMIT", 1]]
User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 30], ["LIMIT", 1]]

On voit ici que nous générons une grande quantité de requêtes:

  • Product.all = 1 requête pour récupérer les recettes

  • product.user = 1 requête SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT 1 [["id", 1]] par produit récupéré

D’où le nom de "requête N+1" puisque nous effectuons une requête par liaison enfant.

Nous pouvons corriger cela simplement en utilisant includes. includes va pré-charger les objets enfants dans une seule requête. Son utilisation est très facile. Si nous reprenons l’exemple précédent, voici le résultat:

Product.includes(:user).all.map { |product| product.user }

La console interactive de Rails nous montre les requêtes SQL qui sont générées. Voyez par vous même:

Product Load (0.3ms)  SELECT "products".* FROM "products"
User Load (0.8ms)  SELECT "users".* FROM "users" WHERE "users"."id" IN (?, ?, ?)  [["id", 28], ["id", 29], ["id", 30]]

Rails effectue une deuxième requête qui va récupérer tous les utilisateurs d’un coup.

Prevention des requêtes N+1

Imaginons que nous voulons ajouter les propriétaires des produits pour la route /products. Nous avons déjà vu que avec la librairie fast_jsonapi il est très facile de le faire:

app/controllers/api/v1/products_controller.rb
class Api::V1::ProductsController < ApplicationController
  # ...
  def index
    # ...
    options = get_links_serializer_options('api_v1_products_path', @products)
    options[:include] = [:user]

    render json: ProductSerializer.new(@products, options).serializable_hash
  end
  # ...
end

Maintenant effectuons une requête avec cURL. Je vous rappelle que nous devons obtenir un jeton d’authentification avant d’accéder à la page.

$ curl -X POST --data "user[email]=ockymarvin@jacobi.co" --data "user[password]=locadex1234"  http://localhost:3000/api/v1/tokens
Note
"ockymarvin@jacobi.co" correspond à un utilisateur créé dans mon application avec le seed. Dans votre cas, il sera sûrement différent du mien puisque nous avons utilisé la librairie Faker.

A l’aide du token obtenu, nous pouvons maintenant effectuer une requête pour accéder aux produits

$ curl --header "Authorization=ey..." http://localhost:3000/api/v1/products

Vous voyez très certainement passer plusieurs requêtes dans la console Rails exécutant le serveur web.

Started GET "/api/v1/products" for 127.0.0.1 at 2019-06-26 13:36:19 +0200
Processing by Api::V1::ProductsController#index as JSON
   (0.1ms)  SELECT COUNT(*) FROM "products"
   app/controllers/concerns/paginable.rb:9:in `get_links_serializer_options'
  Product Load (0.2ms)  SELECT "products".* FROM "products" LIMIT ? OFFSET ?  [["LIMIT", 20], ["OFFSET", 0]]
  ↳ app/controllers/api/v1/products_controller.rb:16:in `index'
  User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 36], ["LIMIT", 1]]
  ↳ app/controllers/api/v1/products_controller.rb:16:in `index'
   (0.5ms)  SELECT "products"."id" FROM "products" WHERE "products"."user_id" = ?  [["user_id", 36]]
   app/controllers/api/v1/products_controller.rb:16:in `index'
  CACHE User Load (0.0ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 36], ["LIMIT", 1]]
  ↳ app/controllers/api/v1/products_controller.rb:16:in `index'
  CACHE User Load (0.0ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 36], ["LIMIT", 1]]
  ↳ app/controllers/api/v1/products_controller.rb:16:in `index'
  CACHE User Load (0.0ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 36], ["LIMIT", 1]]

Il est donc malheureusement très facile de créer une requête N+1. Heureusement, il existe une gemme qui permet de nous alerter lorsque ce genre de situation arrive: Bullet. Bullet va nous prévenir (par mail, notification growl, Slack, console, etc..) lorsqu’il trouve une requête N+1.

Pour l’installer, nous ajoutons la gem au GemFile

$ bundle add bullet --group development

Et il suffit de mettre à jour la configuration de notre application pour l’environnement de développement. Dans notre cas nous allons uniquement activer le mode rails_logger qui va s’afficher

config/environments/development.rb
Rails.application.configure do
  # ...
  config.after_initialize do
    Bullet.enable = true
    Bullet.rails_logger = true
  end
end

Redémarrez le serveur web et relancez la dernière requête avec cURL:

$ curl --header "Authorization=ey..." http://localhost:3000/api/v1/products

Et regardez la console Rails. Bullet nous indique qu’il vient de détecter une requête N+1.

GET /api/v1/products
USE eager loading detected
  Product => [:user]
  Add to your finder: :includes => [:user]

Il nous indique même comment la corriger:

Add to your finder: :includes ⇒ [:user]

Corrigeons donc notre erreur donc le contrôleur:

app/controllers/api/v1/products_controller.rb
class Api::V1::ProductsController < ApplicationController
  # ...
  def index
    @products = Product.includes(:user)
                       .page(current_page)
                       .per(per_page)
                       .search(params)

    options = get_links_serializer_options('api_v1_products_path', @products)
    options[:include] = [:user]

    render json: ProductSerializer.new(@products, options).serializable_hash
  end
  # ...
end

Et voilà! Il est maintenant temps de faire notre commit.

$ git commit -am "Add bullet to avoid N+1 query"

Activation des CORS

Dans cette dernière section, je vais vous parler d’un dernier problème que vous allez sûrement rencontrer si vous êtes amenés à travailler avec votre API.

Lors de la première requête d’un site externe (via une requête AJAX par exemple), vous aller rencontrer une erreur de ce genre:

Failed to load https://example.com/: No ‘Access-Control-Allow-Origin' header is present on the requested resource. Origin ‘https://anfo.pl' is therefore not allowed access. If an opaque response serves your needs, set the request’s mode to ‘no-cors' to fetch the resource with CORS disabled.

"Mais qu’est ce que signifie Access-Control-Allow-Origin??". Le comportement que vous observez est l’effet de l’implémentation CORS des navigateurs. Avant la standardisation de CORS, il n’y avait aucun moyen d’appeler un terminal API sous un autre domaine pour des raisons de sécurité. Ceci a été (et est encore dans une certaine mesure) bloqué par la politique de la même origine.

CORS est un mécanisme qui a pour but de permettre les requêtes faites en votre nom et en même temps de bloquer certaines requêtes faites par des scripts malhonnêtes et est déclenché lorsque vous faites une requête HTTP à:

  • un domaine différent

  • un sous-domaine différent

  • un port différent

  • un protocole différent

Nous devons manuellement activer cette fonctionnalité afin que n’importe quel client puisse effectuer des requêtes sur notre API.

Rails nous permet de faire ça très facilement. Jetez un coup d’œil au fichier cors.rb situé dans le dossier initializers.

config/initializers/cors.rb
# ...

# Rails.application.config.middleware.insert_before 0, Rack::Cors do
#   allow do
#     origins 'example.com'
#
#     resource '*',
#       headers: :any,
#       methods: [:get, :post, :put, :patch, :delete, :options, :head]
#   end
# end

Vous voyez. Il suffit de dé-commenter le code et de le modifier un peut pour limiter l’accès à certaines actions ou bien certains verbes HTTP. Dans notre cas, cette configuration nous convient très bien pour le moment.

config/initializers/cors.rb
# ...

Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins 'example.com'
    resource '*',
      headers: :any,
      methods: [:get, :post, :put, :patch, :delete, :options, :head]
  end
end

Nous devons aussi installer la gemme rack-cors qui est commentée dans le Gemfile:

$ bundle add rack-cors

Et voilà! Il est maintenant temps de faire notre dernier commit et de merger nos modifications sur la branche master.

$ git commit -am "Activate CORS"
$ git checkout master
$ git merge chapter09

Conclusion

Si vous arrivez à ce point, cela signifie que vous en avez fini avec le livre. Bon travail! Vous venez de devenir un grand développeur API Rails, c’est sûr.

Nous avons donc construit ensemble une API solide et complète. Celle-ci possède toutes les qualité pour détrôner Amazon, soyez en sûr. Merci d’avoir traversé cette grande aventure avec moi, j’espère que vous avez apprécié le voyage autant que moi.

Je tiens à vous rappeler que tout le code source de ce livre est disponible au format Asciidoctor sur GitHub. Ainsi n’hésitez pas à forker le projet si vous voulez l’améliorer ou corriger une faute qui m’aurait échappée.

Si vous avez aimé ce livre, n’hésitez pas à me le faire savoir par mail contact@rousseau-alexandre.fr. Je suis ouvert à toutes critiques, bonne ou mauvaise, autour d’un bonne bière :) .


1. La clé GPG vous permet de vérifier l’identité de l’auteur des sources que vous téléchargez.