Article écrit par Nicolas Cavigneaux
Dans l’article précédent, nous avons vu comment afficher des utilisateurs fixes grâce à l’utilisation d’un contexte, d’un contrôleur et de vues.
On peut maintenant essayer d’aller plus loin en permettant de gérer les utilisateurs de manière plus dynamique, grâce à une base de données. Comme nous avons pris soin d’isoler la gestion d’utilisateurs dans un contexte, on ne devrait avoir qu’à modifier le code à cet endroit pour que la magie opère.
Pour simplifier la communication avec la base de données, nous allons utiliser Ecto qui nous offre tout l’outillage nécessaire pour pouvoir la requêter ou y persister des données.
Ecto permet donc d’écrire des requêtes SQL à travers son propre langage, mais il permet également de gérer des changesets
qui sont une encapsulation qui permet de prendre des données en entrée, les transformer et les valider.
Création d’un schéma et d’une migration
La première chose à faire pour pouvoir utiliser notre base de données, au-delà de sa configuration qu’on a faite dans l’épisode précédent, est de créer un schéma dont le but est de décrire la structure d’une table ainsi que la migration correspondante qui elle sert à créer ladite table.
On va donc commencer par modifier notre fichier lib/commentator/accounts/user.ex
pour y utiliser un schéma Ecto plutôt que notre struct
maison :
defmodule Commentator.Accounts.User do use Ecto.Schema import Ecto.Changeset schema "users" do field :name, :string field :username, :string timestamps() end end
Grâce à l’utilisation de use Ecto.Schema
, on instruit notre modèle du fait qu’on souhaite mettre en place un schéma qui sera à la fois une table de notre base de données, mais également une structure locale de notre module, l’un étant le reflet de l’autre.
On crée donc un schéma users
qui contiendra les champs name
et username
tous deux de type string
. On demande également à ce qu’Ecto gère des champs de timestamps
pour nous, à savoir inserted_at
et updated_at
qui seront tenus à jour automatiquement.
Par défaut, en définissant un schéma, Ecto va ajouter un champ id
qui servira de clé primaire en base.
Avec ce nouveau schéma on peut continuer à créer des structures %Commentator.Accounts.User
comme on le faisait avec la précédente version :
iex> %Commentator.Accounts.User{} %Commentator.Accounts.User{ __meta__: #Ecto.Schema.Metadata<:built, "users">, id: nil, inserted_at: nil, name: nil, updated_at: nil, username: nil }
On peut maintenant créer la migration pour mettre en place la table correspondante en base :
$ mix ecto.gen.migration create_users * creating priv/repo/migrations/20210920085144_create_users.exs
On édite ce nouveau fichier pour y indiquer nos champs :
defmodule Commentator.Repo.Migrations.CreateUsers do use Ecto.Migration def change do create table(:users) do add :name, :string add :username, :string, null: false add :password_hash, :string timestamps() end create unique_index(:users, [:username]) end end
On retrouve nos champs name
et username
, comme dans notre module. On a également les timestamps
. Aussi deux choses supplémentaires qui apparaissent : la génération d’un index d’unicité sur le username
pour s’assurer qu’il soit unique au niveau de la base ; et un autre champ, password_hash
, qui va nous servir par la suite pour stocker le mot de passe haché de l’utilisateur qui nous sera utile pour gérer l’authentification.
On joue la migration :
mix ecto.migrate 11:02:29.965 [info] == Running 20210920085144 Commentator.Repo.Migrations.CreateUsers.change/0 forward 11:02:29.971 [info] create table users 11:02:30.001 [info] create index users_username_index 11:02:30.005 [info] == Migrated 20210920085144 in 0.0s
On a maintenant un module User
avec la bonne structure et la table correspondante qui a été créée. On va pouvoir commencer à jouer avec à travers une console IEx
. On lance iex -S mix
:
iex(1)> alias Commentator.Repo Commentator.Repo iex(2)> alias Commentator.Accounts.User Commentator.Accounts.User iex(3)> Repo.insert(%User{name: "Nico", username: "Bounga"}) [debug] QUERY OK db=3.8ms decode=1.3ms queue=2.8ms idle=1536.5ms INSERT INTO "users" ("name","username","inserted_at","updated_at") VALUES ($1,$2,$3,$4) RETURNING "id" ["Nico", "Bounga", ~N[2021-09-20 09:27:05], ~N[2021-09-20 09:27:05]] {:ok, %Commentator.Accounts.User{ __meta__: #Ecto.Schema.Metadata<:loaded, "users">, id: 1, inserted_at: ~N[2021-09-20 09:27:05], name: "Nico", updated_at: ~N[2021-09-20 09:27:05], username: "Bounga" }} iex(4)> Repo.insert(%User{name: "Martin", username: "fuse"}) [debug] QUERY OK db=2.9ms queue=1.4ms idle=1553.3ms INSERT INTO "users" ("name","username","inserted_at","updated_at") VALUES ($1,$2,$3,$4) RETURNING "id" ["Martin", "fuse", ~N[2021-09-20 09:27:47], ~N[2021-09-20 09:27:47]] {:ok, %Commentator.Accounts.User{ __meta__: #Ecto.Schema.Metadata<:loaded, "users">, id: 2, inserted_at: ~N[2021-09-20 09:27:47], name: "Martin", updated_at: ~N[2021-09-20 09:27:47], username: "fuse" }}
Comme souvent en Elixir, on commence par aliasser les modules qu’on va utiliser pour économiser de la frappe. Ici on aliasse le Repo
et le module User
.
Une fois fait on ajoute deux entrées en base grâce à la fonction insert/2
qui s’attend à recevoir une structure compatible avec Ecto, ici des structures User
avec leurs valeurs respectives.
On reçoit en retour un tuple avec :ok
en première valeur pour nous confirmer que l’opération a réussie, le deuxième élément du tuple est le struct
User
qui a été généré par Ecto. En plus des valeurs qu’on a fournies, on récupère l’id
généré ainsi que les timestamps
.
Forts de cette nouvelle possibilité, on va pouvoir modifier notre contexte Account
pour utiliser la base de données pour gérer nos utilisateurs plutôt que d’utiliser une liste en dur.
On remplace donc le contenu du fichier lib/commentator/account.ex
par :
defmodule Commentator.Accounts do @moduledoc """ Accounts context dedicated to handle users and authentication """ alias Commentator.Repo alias Commentator.Accounts.User def list_users do Repo.all(User) end def get_user(id) do Repo.get(User, id) end def get_user_by(params) do Repo.get_by(User, params) end end
Comme vous pouvez le voir, notre interface n’a pas changé, seul son fonctionnement interne a été adapté pour utiliser la base de données. On n’aura donc pas besoin de modifier le code existant qui utilisait déjà notre contexte.
Une fois encore, on a aliassé les modules utiles. On a ensuite utilisé la fonction all/2
pour récupérer l’ensemble des utilisateurs. On a utilisé get/3
pour récupérer un User
sur la base de son id
. Finalement on a utilisé get_by/3
pour pouvoir récupérer un User
qui correspond aux paramètres fournis.
On peut tester dans notre navigateur pour s’assurer que notre application fonctionne toujours. En se rendant sur l’URL http://localhost:4000/users
, on voit nos deux utilisateurs créés précédemment dans IEx :
On peut également se rendre sur l’URL de détail d’un utilisateur http://localhost:4000/users/1
:
Tout fonctionne comme attendu, notre contexte a parfaitement rempli son rôle en isolant l’interface publique du fonctionnement sous-jacent. On a pu très simplement passer d’une liste d’utilisateurs fixe à une liste d’utilisateurs dynamique en modifiant uniquement quelques lignes de notre contexte.
Mise en place d’un formulaire
Maintenant que nous arrivons à stocker nos utilisateurs en base, il serait pratique de pouvoir en ajouter à la volée depuis l’interface web.
Pour pouvoir faire ça on va devoir ajouter une nouvelle action dans notre contrôleur UserController
, un nouveau template, une nouvelle route ainsi qu’une nouvelle fonction dans notre contexte pouvant gérer un changeset.
Commençons par ajouter la route dans le fichier lib/commentator_web/router.ex
:
scope "/", CommentatorWeb do pipe_through(:browser) get("/", PageController, :index) resources("/users", UserController, only: [:index, :show, :new, :create]) end
Plutôt que d’ajouter une nouvelle route en GET
et une en POST
manuellement comme précédemment, on a ici choisi d’utiliser la macro resources
qui est une manière plus courte et simple de mettre en place les routes classiques pour une ressource CRUD. On a précisé qu’on ne voulait que les routes correspondant à l’index, le show, le formulaire de création et l’action de création à proprement parler.
On peut passer à l’ajout de la nouvelle action dans notre contrôleur lib/commentator_web/controllers/user_controller.ex
:
def new(conn, _params) do changeset = Accounts.change_user(%User{}) render(conn, "new.html", changeset: changeset) end
Cette nouvelle action new
fait appel à une fonction de notre contexte qu’on va écrire dans un instant, la fonction Accounts.change_user/1
dont le but est de préparer un changeset Ecto qui pourra être consommé par les fonctions d’aide à la mise en place de formulaires livrées avec Phoenix.
On rend ensuite le template new.html
en s’assurant de lui fournir le changeset.
On peut maintenant créer la fonction manquante dans notre contexte Accounts
:
def change_user(%User{} = user) do User.changeset(user, %{}) end
Cette fonction qui s’attend, par pattern matching, à recevoir une structure User
ne fait qu’appeler une fonction qu’on va définir dans notre module User
. Une fois encore notre contexte cherche à exposer des méthodes publiques qui pourront être utilisées à l’extérieur sans connaître le fonctionnement sous-jacent :
def changeset(user, attrs) do user |> cast(attrs, [:name, :username]) |> validate_required([:name, :username]) |> validate_length(:username, min: 1, max: 20) end
Cette fonction User.changeset/2
est chargée du travail de fond. Elle s’attend à recevoir une structure (user
) et des attributs (attrs
).
La structure est passée à la fonction Ecto.changeset.cast/4
qui prend les attributs fournis et les nettoie en n’autorisant que ceux dont la clé est précisée dans le deuxième argument. Au passage, les valeurs des paramètres autorisés sont transformés pour correspondre aux types définis dans le schéma.
Ces valeurs nettoyées sont ensuite passées à Ecto.changeset.validate_required/3
qui s’assure que les clés qui lui sont passées en paramètres sont bien présentes dans le changeset.
Finalement, on vérifie que la longueur du username
est bien comprise entre 1 et 20 caractères.
Si toutes les conditions sont remplies, on aura un changeset valide en sortie. Sinon le changeset sera marqué comme invalide.
Il ne nous manque plus que notre formulaire HTML, l’action contrôleur create
et on devrait avoir une boucle complète qui nous permettra d’ajouter des utilisateurs.
On crée donc le fichier lib/commentator_web/templates/user/new.html.eex
:
<h1>Nouvel utilisateur</h1> <%= form_for @changeset, Routes.user_path(@conn, :create), fn f -> %> <div> <%= text_input f, :name, placeholder: "Nom" %> </div> <div> <%= text_input f, :username, placeholder: "Nom d'utilisateur" %> </div> <%= submit "Enregistrer" %> <% end %>
Dans ce template, on mixe de l’HTML classique avec quelques fonctions de génération de code HTML. Passer par ces fonctions nous apporte quelques bénéfices comme une sécurité accrue (CSRF), du remplissage automatique des valeurs, etc.
L’élément le plus notable est la fonction form_for
qui s’attend à recevoir :
- un changeset
- une URL qui est générée grâce à une fonction
- une fonction anonyme dont le but est de rendre le contenu HTML à l’intérieur du form
On crée ensuite des entrées de texte en passant en premier argument la fonction anonyme, en deuxième l’attribut du changeset concerné et des options en troisième argument.
Pour finir on génère un bouton de soumission du formulaire.
On peut désormais se rendre à l’URL http://localhost:4000/users/new
pour constater que notre formulaire s’affiche correctement :
Ce formulaire pointant vers une action inexistante, nous allons l’ajouter, mais tout d’abord il faudrait créer une fonction dans notre contexte permettant de créer et persister un utilisateur en base :
def create_user(attrs \ %{}) do %User{} |> User.changeset(attrs) |> Repo.insert() end
Notre nouvelle fonction prend les attributs du formulaire en argument. Elle va créer une structure User
vierge, y appliquer le changeset sur la base des attributs fournis puis tenter de l’insérer en base.
Il ne nous reste plus qu’à ajouter notre action contrôleur qui utilise cette fonction :
def create(conn, %{"user" => user_params}) do {:ok, user} = Accounts.create_user(user_params) conn |> put_flash(:info, "#{user.name} ajouté.") |> redirect(to: Routes.user_path(conn, :index)) end
L’action reçoit comme toujours la connexion en premier argument, pour le deuxième on met en place du pattern matching sur les paramètres reçus. On souhaite avoir une clé user
qui contiendra les informations concernant l’utilisateur à créer. On stocke ces informations dans la variable user_params
.
On tente ensuite de créer notre utilisateur à l’aide de la fonction qu’on vient d’ajouter à notre contexte Accounts
. Si la création est un succès, toujours par pattern matching, on stocke l’utilisateur nouvellement créé dans la variable user
.
Pour finir, on ajoute un message flash à la connexion, puis on redirige sur la liste des utilisateurs grâce à une fonction de génération d’URL.
Si on essaie de créer un nouvel utilisateur, il est effectivement créé en base, on est ensuite redirigé sur la liste où on peut le voir apparaître :
C’est une belle avancée, mais malheureusement, si on ne respecte pas les contraintes de validation, par exemple en omettant le username
, alors on aura le droit à une page 500 de la part de Phoenix qui nous explique le problème rencontré :
Il va donc falloir modifier notre action pour gérer les cas en erreur :
def create(conn, %{"user" => user_params}) do case Accounts.create_user(user_params) do {:ok, user} -> conn |> put_flash(:info, "#{user.name} ajouté.") |> redirect(to: Routes.user_path(conn, :index)) {:error, %Ecto.Changeset{} = changeset} -> render(conn, "new.html", changeset: changeset) end end
On utilise ici l’instruction de branchement case
dans laquelle on essaie de pattern matcher sur le succès de la création ({:ok, user}
) ou son échec ({:error, changeset
).
Dans le cas d’un échec, on récupère le changeset en erreur à l’aide du pattern matching pour pouvoir rendre à nouveau le formulaire sans pour autant perdre les informations déjà entrées par l’utilisateur.
On peut maintenant tester la soumission des informations invalides. Nous n’avons plus d’erreur, mais ce n’est pas encore parfait. La page est bien actualisée, les informations sont conservées, mais on n’a aucun retour sur les erreurs rencontrées.
Pour que l’utilisateur puisse comprendre ce qui cloche, on va modifier le formulaire pour qu’il affiche les erreurs lorsqu’il y en a :
<h1>Nouvel utilisateur</h1> <%= form_for @changeset, Routes.user_path(@conn, :create), fn f -> %> <%= if @changeset.action do %> <div class="alert alert-danger"> <p>Des erreurs empêchent la création</p> </div> <% end %> <div> <%= text_input f, :name, placeholder: "Nom" %> <%= error_tag f, :name %> </div> <div> <%= text_input f, :username, placeholder: "Nom d'utilisateur" %> <%= error_tag f, :username %> </div> <%= submit "Enregistrer" %> <% end %>
Deux ajouts ont été faits :
- un bloc avec un message est affiché si des erreurs sont présentes dans le changeset ;
- chaque input se voit complété d’un appel à
error_tag
qui va afficher les erreurs de validations relatives à l’attribut mentionné s’il y en a.
Notre formulaire est maintenant plus agréable à utiliser.
Dans le prochain article, nous verrons comment mettre en place un système d’authentification maison. Même si ce n’est pas la meilleure idée pour une application qui doit aller en production, cet exercice aura le mérite de nous permettre de mieux comprendre des éléments essentiels de Phoenix, notamment à propos des plugs.