Évaluons votre projet

Introduction à Phoenix - Découverte d'Ecto, des changesets & des forms

Publié le 4 novembre 2021 par Nicolas Cavigneaux | elixir - framework

Cet article est publié sous licence CC BY-NC-SA

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 :

Liste dynamique des utilisateurs

On peut également se rendre sur l’URL de détail d’un utilisateur http://localhost:4000/users/1 :

Détail d'un utilisateur dynamique

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 :

Formulaire d'ajout d'un utilisateur

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 :

Ajout d'un
utilisateur

Liste avec le nouvel utilisateur

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é :

Page d'erreur d'ajout utilisateur

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.

Formulaire avec affichage des erreurs

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.


L’équipe Synbioz.
Libres d’être ensemble.