Benjamin Geer

Créer une application hypermédia en Rust

Benjamin Geer
Table des matières

Ceci est le troisième article d’une série :

  1. Créer des applications hypermédia sécurisées pour un monde aux ressources limitées, qui présente la série
  2. Créer une application hypermédia en Go
  3. l’article présent

Aperçu de l’application #

L’application qui sert d’exemple dans Hypermedia Systems est une application CRUD qui gère une liste de contacts. Le livre montrer comment créer une application hypermédia qui offre bonne interface utilisateur, en alternative aux applications monopages. Grâce à une minuscule bibliothèque JavaScript (htmx) et à quelques attributs déclaratifs sur les éléments HTML, toutes sortes d’interactions utilisateur peuvent récupérer des fragments de code HTML depuis le serveur et ne modifier que certaines parties de la page. Par exemple, quand on passe d’une page à une autre dans la liste des contacts ou qu’on supprime un contact, la liste affichée s’actualise sans que la page ne soit rechargée.

Réalisons cette application en Rust, avec deux objectifs :

  1. La rendre plus réaliste en ajoutant une interface utilisateur internationalisée, une base de données relationnelle avec un index de recherche en texte intégral, une meilleure validation des entrées, une pagination par curseur et des tests d’interface utilisateur.
  2. La rendre facile à maintenir, en limitant au maximum le code répétitif et en tirant parti des vérifications effectuées lors de la compilation pour éviter les bugs.

Nous utiliserons dans un premier temps une base de données SQLite, puis passerons à PostgreSQL dans une étape ultérieure. Le code source des deux versions est disponible ici :

Bibliothèques #

Notre première tâche est de choisir quelques bibliothèques.

Templates HTML #

Notre interface utilisateur est basée sur des templates HTML. Il y a un template de mise en page qui fournit un en-tête et affiche des messages d’information (comme « Le contact a été ajouté ») en réponse aux actions de l’utilisateur·ice. D’autres templates, comme un formulaire permettant de modifier un contact, peuvent être imbriqués dans cette mise en page. Il y a aussi des templates qui génèrent des fragments de code HTML qui remplacent une ou plusieurs parties de la page affichée. Par exemple, quand on clique sur un lien de pagination, la liste des contacts s’actualise ainsi que les liens de pagination.

Il nous faudra donc un moteur de template, de préférence à typage sûr. Les moteurs de template à typage sûr se déclinent essentiellement en deux catégories : celle qui permet d’écrire quasiment du code HTML standard, et celle qui permet d’exprimer la structure d’un document HTML dans un langage de programmation. Je préfère la première catégorie, car plus la syntaxe des templates se rapproche du HTML, plus il sera facile pour un concepteur UX, ou toute personne maîtrisant HTML, de travailler sur ces templates sans avoir à connaître Rust.

J’ai choisi Askama, qui est facile à utiliser et compile les templates en Rust. Comme sa syntaxe est basée sur celle de Jinja, les environnements de développement qui prennent en charge Jinja fonctionneront aussi avec Askama. Pour l’utiliser, on écrit les templates dans des fichiers HTML. Pour chaque template, on écrit une définition de struct en Rust qui contient les variables du template et qui se réfère au fichier HTML via une macro. Dans les implémentations de nos points de terminaison HTTP, nous appellerons la méthode render de cette struct pour générer du code HTML.

Internationalisation #

Nous souhaitons que notre application prenne en charge différentes langues. Dans cet exemple, j’ai réalisé la prise en charge de l’anglais et du français. Pour faciliter la maintenance, nous voulons éviter de créer une version différente de chaque template pour chaque langue, ou d’encombrer les templates avec les différentes traductions d’un même message. Il nous faut donc une bibliothèque permettant d’appeler une fonction depuis un template et d’obtenir la traduction d’un message dans la langue cible.

Un point important à prendre en compte est le choix des concepteurs de la bibliothèque quant au type d’identifiant qui représente un message. Certaines bibliothèques (comme gettext) utilisent le texte entier du message dans la langue par défaut comme identifiant de message. Les limites de cette approche sont clairement exposées dans le contract social de Project Fluent:

Tout d’abord, cela signifie que toute modification apportée à la chaîne source invalide toutes les traductions de cette chaîne. Cela alourdit considérablement la charge de travail des développeur·euses, qui doivent veiller à ne jamais modifier les messages dans la langue source, car cela entraînerait la nécessité de mettre à jour toutes les traductions.

D’autre part, cela complique l’utilisation de plusieurs messages qui ont la même chaîne source mais qui devraient être traduits différemment.

Je préfère donc les bibliothèques qui utilisent des identifiants de message uniques choisis par les développeur·euses.

Un autre critère important dans le choix d’une bibliothèque concerne la manière dont elle gère les pluriels. Alors que les pluriels sont relativement simples en français (il n’y a que deux catégories, le singulier et le pluriel), de nombreuses langues comportent davantage de catégories ainsi que des règles syntaxiques qui nécessitent d’adapter la phrase de manière plus complexe en fonction du nombre exprimé. Une bonne bibliothèque d’internationalisation doit implémenter les règles de pluriel CLDR d’Unicode, qui prennent en charge plus de 200 langues.

J’avais envie d’essayer Project Fluent, mais il semblerait que ce projet ait été abandonné. J’ai donc opté pour karatepe, qui offre les fonctionnalités dont on a besoin. Karatepe fournit un langage dédié permettant de définir les messages et d’écrire leurs traductions, ainsi qu’une macro qui compile les définitions en fonctions Rust. Voici, par exemple, une définition de message qui prend un paramètre numérique, amount :

1
contacts_count(amount: ..)

Et voici sa traduction en français :

1
2
3
4
contacts_count(amount) = ({amount} {amount => {
    one => contact
    _ => contacts
  }} au total)

La définition du message est compilée en une fonction Rust, contacts_count, que nous pouvons appeler directement depuis un template. Lors de l’exécution, nous pouvons accéder à la traduction d’un message via la Translation correspondant à la langue cible. Nous verrons ci-dessous comment obtenir celle qui convient.

Puisque nous internationalisons l’application, nous pouvons aussi améliorer la validité interculturelle des noms de champs : au lieu de first_name (« prénom ») et last_name (« nom de famille »), qui ne conviennent pas aux langues (comme le chinois) dans lesquelles le nom de famille vient en premier, nous suivrons la recommandation de l’ontologie FOAF, qui préconise given_name (« nom personnel ») et family_name (« nom de famille »).

Accès à la base de données et migration de schéma #

Nous pourrions utiliser un mapping object-relationnel (ORM), mais à mon avis il vaut mieux écrire notre code SQL nous-mêmes. Les ORM peuvent générer des requêtes inefficaces.1 Écrire notre propre code SQL nous permet de tirer pleinement parti des fonctionnalités du SQL moderne et de ses possibilités d’optimisation des requêtes, notamment en concevant conjointement les requêtes et les index, et en affinant les requêtes en tenant compte des plans d’exécution choisis par l’optimiseur de requêtes de la base de données. (Voir mon article Optimiser des requêtes PostgreSQL avec un jeu de données ouvert pour un exemple.)

À mon avis, l’idéal est d’avoir une bibliothèque qui implémente la sûreté du typage pour les requêtes SQL, de sorte que celles-ci soient vérifiées lors de la compilation et renvoient automatiquement des structs Rust. C’est exactement ce que fait la crate SQLx. Elle peut même renvoyer nos propres structs en dérivant son trait FromRow.

Par exemple, imaginons que nous ayons mal orthographié le nom d’une colonne (familyname au lieu de family_name) dans une requête :

1
2
3
4
5
6
7
8
let contact: Contact = sqlx::query_as!(
    Contact,
    "SELECT id, familyname, given_name, phone, email
    FROM contact WHERE id = $1",
    id
)
.fetch_one(&mut *tx)
.await?;

Par défaut, la macro query_as! se connectera à la base de données de développement lors de la compilation pour vérifier la requête, et nous obtiendrons une erreur de compilation :

1
2
error: error returned from database: (code: 1) no such column: familyname
   --> src/db.rs:236:23

SQLx utilise aussi des instructions préparées pour prévenir les attaques par injection SQL et fournit un outil en ligne de commande pour gérer les migrations de schéma.

Framework de serveur HTTP #

Nous utiliserons axum, qui implémente des conversions à typage sûr entre les requêtes et les structs Rust via Serde, et qui est facile à utiliser avec Askama.

Tests d’interface utilisateur #

S’il s’agissait de créer une application monopage, notre serveur aurait une API JSON, et nous pourrions écrire des tests vérifiant qu’il renvoie les données correctes pour différentes requêtes. Mais notre application renvoie du code HTML. Nous allons donc écrire des tests analogues aux tests d’API JSON, en nous concentrant sur les fonctionnalités pratiques des réponses HTML. À l’aide de la crate dom_query, nous analyserons ces réponses et utiliserons des sélecteurs CSS pour vérifier le contenu de certains éléments. Nous aurons ainsi la liberté de modifier considérablement la conception graphique des pages sans avoir à changer nos tests.

Conception de la base de données #

(Si vous avez déjà lu la version Go de cet article, vous pouvez sauter cette section.)

Notre base de données est très simple. Il y a une seule table, contact :

1
2
3
4
5
6
7
CREATE TABLE IF NOT EXISTS contact (
  id INTEGER PRIMARY KEY,
  family_name TEXT NOT NULL,
  given_name TEXT NOT NULL,
  phone TEXT,
  email TEXT UNIQUE NOT NULL
) STRICT;

Il y a toutefois quelques détails à prendre en compte.

J’ai défini la table avec l’option STRICT. Par défaut, SQLite dispose d’un système de types très flexible, ce que certaines personnes apprécient. Je préfère obtenir des erreurs quand il y a un bug dans mon programme. Avec l’option STRICT, SQLite effectue une vérification stricte des types lors de l’exécution, en plus des vérifications effectuées par sqlc lors de la compilation.

Les colonnes pouvant prendre la valeur NULL ajoutent un peu de complexité à notre code. Par conséquent, pour simplifier les choses dans cet exemple, nous n’autoriserons que la colonne phone à prendre la valeur NULL.

L’application devrait afficher la liste des contacts triée selon un certain ordre. Nous utiliserons ORDER BY family_name, given_name, email. Pour optimiser les performances, nous ajoutons un index correspondant à cet ordre de tri :

1
2
CREATE INDEX IF NOT EXISTS idx_contact_sort
ON contact (family_name, given_name, email);

Comme l’application exige que les adresses e-mail soient uniques, j’ai mis une contrainte UNIQUE sur le champ email, et nous aurons besoin de faire une requête comme celle-ci :

1
SELECT 1 FROM contact WHERE email = ?;

Pour que cette requête soit efficace, nous créons un index sur la colonne email :

1
CREATE INDEX IF NOT EXISTS idx_contact_email ON contact (email);

Nous souhaitons disposer d’un index de recherche en texte intégral nous permettant de trouver les contacts dont le nom correspond à une chaîne de caractères. L’utilisateur pourra saisir les premières lettres de given_name et/ou de family_name pour obtenir une liste des contacts correspondants. Heureusement, SQLite propose un module de recherche en texte intégral, qui s’appelle FTS5. Pour l’utiliser, il faut créer une table virtuelle pour y stocker l’index :

1
2
3
4
5
6
7
8
CREATE VIRTUAL TABLE IF NOT EXISTS contact_fts USING fts5(
    family_name,
    given_name,
    phone UNINDEXED,
    email UNINDEXED,
    content = 'contact',
    content_rowid = 'id'
);

Nous utiliserons des déclencheurs pour que la table contact_fts reste synchronisée avec la table contact. Et voilà qui conclut notre schéma de base de données.

Architecture logicielle #

Étant donné la simplicité de cette application CRUD, une architecture modèle-vue-contrôleur suffira. Les vues sont des templates HTML et les contrôleurs sont des routes HTTP. Le type principal du modèle est Contact, que nous verrons dans un instant. La couche d’abstraction de base de données est tout simplement une struct qui contient le pool de connexions et dispose de méthodes comme celle-ci :

1
2
3
pub async fn get(&self, id: ContactId) -> Result<Contact, DatabaseError> {
    // ...
}

Analyser au lieu de valider #

Nous allons appliquer le principe d’Alexis King, Parse, don’t validate. En bref, cela veut dire que, plutôt que de représenter, par exemple, une adresse mail sous la forme d’un &str, on utilise le motif newtype en créant un type dédié, appelé Email, qui ne peut être construit qu’à partir d’une entrée valide. Cela présente plusieurs avantages :

Nous utiliserons la bibliothèque nutype pour définir nos newtypes. Elle propose toute une gamme de fonctions de validation intégrées, par exemple pour la validation à l’aide d’expressions régulières. Comme il s’agit d’une application internationalisée, nous utiliserons des expressions régulières Unicode pour prendre en charge :

Par exemple, ma modeste tentative d’expression régulière pour les noms accepte des groupes de lettres et de caractères Unicode, séparés par des espaces et/ou des signes de ponctuation :

1
^[\p{L}\p{M}]+(?:(?:\p{P}\p{Zs}|[\p{P}\p{Zs}])[\p{L}\p{M}]+)*\p{P}?$

Donc, si vous vous appelez 曼玉-فاتن, tout va bien. Nous pouvons maintenant définir un newtype :

1
2
3
4
5
6
7
#[nutype(
    validate(regex = NAME_REGEX, len_char_max = 50),
    derive(Debug, Display, PartialEq, Clone, AsRef, Serialize),
    derive_unchecked(sqlx::Type),
)]
#[sqlx(transparent)]
pub struct GivenName(String);

Cela génère un constructeur, GivenName::try_new, qui prend une chaîne de caractères en paramètre et renvoie un Result<GivenName, GivenNameError>. Nous avons également précisé que nous souhaitons que SQLx puisse construire un GivenName sans validation, car nous partons du principe que les données de la base de données ont déjà été validées. Nous avons également la possibilité de ne pas faire confiance à la base de données et d’analyser le GivenName de manière classique au niveau de la couche de base de données.

Après avoir défini quelques autres newtypes de la même façon, nous pouvons définir Contact:

1
2
3
4
5
6
7
8
#[derive(Debug, PartialEq, sqlx::FromRow)]
pub struct Contact {
    pub id: ContactId,
    pub family_name: FamilyName,
    pub given_name: GivenName,
    pub phone: Option<Phone>,
    pub email: Email,
}

Quand l’utilisateur·ice est en train de créer un nouveau contact, celui-ci n’a pas encore d’identifiant. Nous définissons donc ContactContent à cet effet :

1
2
3
4
5
6
7
#[derive(Debug, PartialEq, Serialize)]
pub struct ContactContent {
    pub family_name: FamilyName,
    pub given_name: GivenName,
    pub phone: Option<Phone>,
    pub email: Email,
}

Que doit-il se passer quand l’utilisateur·ice envoie un formulaire pour créer un contact ?

Nous ne voulons pas qu’axum désérialise les données du formulaire et construise directement un ContactContent, car l’analyse s’arrêterait dès la première erreur. Nous souhaitons plutôt pouvoir collecter plusieurs erreurs de validation et les stte toutes à l’utilisateur·ice. Le formulaire comporte un élément <span class="error"> sous chaque champ de saisie pour afficher ces erreurs. Nous pouvons représenter les données du formulaire non analysées sous la forme d’une struct comme celle-ci :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
use serde_with::{NoneAsEmptyString, serde_as};

#[serde_as]
#[derive(Debug, Default, Deserialize)]
pub struct ContactForm {
    pub family_name: String,
    pub given_name: String,

    #[serde_as(as = "NoneAsEmptyString")]
    pub phone: Option<String>,

    pub email: String,
}

Nous aimerions analyser un ContactForm et obtenir soit un ContactContent, soit une collection d’erreurs. Il n’existe pas encore de moyen d’automatiser complètement cette tâche, mais nous pouvons écrire une petite fonction pour l’accomplir en utilisant la bibliothèque frunk d’outils de programmation fonctionnelle. Nous allons utiliser son type Validated, qui regroupe les résultats de plusieurs opérations. S’il n’y a pas d’erreurs, Validated::into_result produit une HList, qui est une liste statiquement typée dont les éléments peuvent être de types différents. À partir d’une HList, nous pouvons facilement construire notre ContactContent. Si des erreurs se sont produites, nous les récupérons dans un Vec.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
use frunk::prelude::*;
use frunk_core::hlist_pat;

impl ContactForm {
    pub fn parse(&self) -> Result<ContactContent, Vec<ParseError>> {
        let validated = FamilyName::try_new(&self.family_name)
            .map_err(ParseError::from)
            .into_validated()
            + GivenName::try_new(&self.given_name).map_err(ParseError::from)
            + self.phone.as_ref()
                .map(|phone| Phone::try_new(phone)
                    .map_err(ParseError::from))
                .transpose()
            + Email::try_new(&self.email).map_err(ParseError::from);

        validated
            .into_result()
            .map(
                |hlist_pat!(family_name, given_name, phone, email)|
                    ContactContent {
                        family_name,
                        given_name,
                        phone,
                        email,
                    },
            )
    }
}

Notre type ParseError contient l’erreur d’origine (GivenNameError, etc.), ce qui nous permet de déterminer facilement quels champs ont produit des erreurs. Nous pouvons ensuite transmettre au template le ContactForm et un ensemble de messages d’erreur traduits.

Si la validation a réussi, nous pouvons transmettre le ContactContent à la méthode Database::add, qui prend la forme suivante :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
pub async fn add(&self, contact: &ContactContent) -> Result<(), Error> {
    let mut tx = self.pool.begin().await?;

    sqlx::query!(
        r#"INSERT INTO contact (family_name, given_name, phone, email)
            VALUES ($1, $2, $3, $4)"#,
        &contact.family_name,
        &contact.given_name,
        &contact.phone,
        &contact.email
    )
    .execute(&mut *tx)
    .await?;

    tx.commit().await?;
    Ok(())
}

Gestion des erreurs #

À qui la faute ? #

Notre stratégie de gestion des erreurs fait la distinction entre les erreurs imputables au client et celles imputables au serveur. Nous pouvons utiliser la crate thiserror pour simplifier l’imbrication des erreurs dans d’autres erreurs :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
use thiserror::Error;

#[derive(Error, Debug)]
pub enum Error {
    #[error("Client error: {0}")]
    Client(#[from] ClientError),

    #[error("Server error: {0}")]
    Server(ServerError),
}

#[derive(Error, Debug)]
pub enum ClientError {
    #[error("Page not found")]
    NotFound,

    // ...
}

#[derive(Error, Debug)]
pub enum ServerError {
    #[error("Database error: {0}")]
    Database(#[from] DatabaseError),

    // ...
}

#[derive(Error, Debug)]
pub enum DatabaseError {
    #[error("{0}")]
    Sqlx(#[from] sqlx::Error),
}

impl<E> From<E> for Error
where
    E: Into<ServerError>,
{
    fn from(err: E) -> Self {
        Error::Server(err.into())
    }
}

Si l’erreur est imputable au client, nous allons simplement la signaler à l’utilisateur·ice, par exemple en affichant « Page introuvable ». Si l’erreur est imputable au serveur (par exemple, s’il s’agit d’une DatabaseError), nous afficherons « Erreur interne du serveur » et enregistrerons les détails de l’erreur dans le journal.

Centraliser la gestion des erreurs #

J’aime centraliser la gestion des erreurs pour qu’elle n’encombre pas le reste du code. Avec axum, une route renvoie un Result dont le type d’erreur est n’importe quel type implémentant le trait IntoResponse. Par conséquent, avec un peu d’infrastructure, nous pouvons utiliser l’opérateur ? dans nos routes pour enregistrer l’erreur dans le journal si nécessaire et renvoyer une page d’erreur à l’aide de notre template error.html. Nous créons un type d’erreur appelé ErrorResponse qui contient simplement un code d’état HTTP et du code HTML, puis nous implémentons IntoResponse pour celui-ci. Nous écrivons ensuite une méthode sur notre type Error qui produit une ErrorResponse, à l’aide du template, avec un message d’erreur traduit, et qui enregistre l’erreur dans le journal si elle est imputable au serveur. Cette méthode doit prendre un paramètre &Translation pour que le modèle puisse récupérer le message d’erreur dans la langue cible :

1
2
3
4
5
impl Error {
    pub fn to_error_response(self, t: &Translation) -> ErrorResponse {
        // ...
    }
}

Enfin, nous écrivons un trait d’extension pour Result, pour convertir n’importe quelle erreur en ErrorResponse :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
pub trait ResultErrorTranslationExt<T, E>
where
    E: std::error::Error,
    Error: From<E>,
{
    fn tr_err(self, t: &Translation) -> Result<T, ErrorResponse>;
}

impl<T, E> ResultErrorTranslationExt<T, E> for Result<T, E>
where
    E: std::error::Error,
    Error: From<E>,
{
    fn tr_err(self, t: &Translation) -> Result<T, ErrorResponse> {
        self.map_err(|err| Error::from(err).to_error_response(t))
    }
}

Nous pouvons désormais créer des routes qui renvoient Result<Html<String>, ErrorResponse> et qui gèrent les erreurs de manière très concise. Par exemple, si la méthode Database::get peut renvoyer une DatabaseError, nous pouvons l’appeler de la façon suivante :

1
2
let t: &Translation = // ...
let contact = app.db.get(id).await.tr_err(t)?;

Les routes et les langues #

Avant de définir les points de terminaison HTTP, il convient de déterminer comment l’application saura quelle langue utiliser. L’en-tête de requête Accept-Language permet au navigateur de fournir une liste, classée par ordre de préférence, des langues choisies par l’utilisateur·ice, mais il faut aussi lui permettre de sélectionner une langue spécifiquement pour cette application :

Cet en-tête sert d’indication lorsque le serveur ne peut pas déterminer la langue du contenu cible autrement (par exemple, utiliser une URL spécifique qui dépend d’une décision explicite de l’utilisateur·ice). Le serveur ne doit jamais passer outre un choix explicite de langue de l’utilisateur·ice. Le contenu d’Accept-Language est souvent hors du contrôle de l’utilisateur·ice (par exemple lors d’un voyage). Un·e utilisateur·ice peut aussi vouloir visiter une page dans une langue différente de celle de l’interface utilisateur.

Ajoutons donc un sélecteur de langue dans l’en-tête de la page et insérons une étiquette de langue BCP 47 au début du chemin d’accès de chaque point de terminaison. Pour le chemin d’accès racine, nous pouvons utiliser l’en-tête Accept-Language et rediriger vers un chemin d’accès contenant une étiquette de langue.

Avec la crate karatepe, l’application charge une Translation pour chaque langue au démarrage. Nous les conservons dans un HashMap (avec icu::locale::LanguageIdentifier comme type de clé) dans l’état partagé de l’application, ainsi que la Database. Voici notre struct AppState :

1
2
3
4
pub struct AppState {
    pub db: Database,
    pub translations: Arc<HashMap<LanguageIdentifier, Translation>>,
}

Le Router d’axum peut conserver cette struct pour que routes puissent y accéder. Nous le configurons ainsi :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
pub fn init_router(app: AppState) -> Router {
    Router::new()
        .route("/", get(index)) // checks Accept-Language
        .route(
            "/{lang}/contacts",
            get(contacts::contacts_get).delete(contacts::contacts_delete),
        )
        .route(
            "/{lang}/contacts/{id}",
            get(contacts::contact_get).delete(contacts::contact_delete),
        )
        // ...
        .with_state(app)
}

Axum fournit des extracteurs que les routes peuvent utiliser pour extraire et analyser les données contenues dans la requête. Nous utilisons l’extracteur Path pour analyser l’étiquette de langue et construire un LanguageIdentifier (qui implémente serde::Deserialize), ce qui nous évite de créer notre propre newtype à cet effet. Il suffit ensuite de créer une petite fonction appelée translation qui prend un LanguageIdentifier en paramètre et renvoie la Translation correspondante issue de l’AppState.

Évitons de créer un de ces sites web agaçants dont le sélecteur de langue vous renvoie à la page d’accueil. Écrivons plutôt une fonction, uris_with_langs, qui prend le chemin d’accès de l’URL actuel, l’adapte à chaque langue disponible et renvoie la liste des chemins obtenus. Dans l’en-tête de la page, nous afficherons ces URL sous forme de liens.

Le template qui affiche un contact s’appelle show.html, et ses variables sont définies dans struct ShowContactTemplate. Notre route contact_get est donc à peu près ceci :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
pub async fn contact_get(
    State(app): State<AppState>,
    Path((lang, id)): Path<(LanguageIdentifier, ContactId)>,
    uri: Uri,
) -> Result<Html<String>, ErrorResponse> {
    let t = app.translation(&lang);
    let contact = app.db.get(id).await.tr_err(t)?;

    let template = ShowContactTemplate {
        t,
        lang,
        lang_uris: localisation::uris_with_langs(uri).tr_err(t)?,
        contact,
    };

    Ok(Html(template.render().tr_err(t)?))
}

Pagination #

(Si vous avez déjà lu la version Go de cet article, vous pouvez sauter cette section et celle d’après.)

La pagination dans Hypermedia Systems est basée sur des numéros de page, ce qui est inefficace (la base de données doit toujours récupérer toutes les pages précédant celle que vous avez demandée) et peut produire des résultats inattendus si les données sont modifiées pendant que vous parcourez les pages. Une meilleure méthode consiste à utiliser la pagination par curseurs. Lors du renvoi d’une page de résultats, la couche de base de données renvoie éventuellement deux curseurs, appelés prev et next, que la vue peut présenter sous forme de liens vers la page précédente et la page suivante. Le lien « Page précédente » contient le curseur prev comme paramètre before, et le lien « Page suivante » contient le curseur next comme paramètre after. Quelques astuces sont nécessaires pour que cela fonctionne.

Notre curseur contiendra les valeurs des colonnes family_name, given_name et email, que nous utilisons déjà pour trier les contacts. Comme il y a déjà un index sur ces trois colonnes, dans cet ordre, la base de données peut récupérer efficacement une page de lignes situées avant ou après le curseur.

Nous voulons éviter de renvoyer un curseur prev si nous avons atteint la première page, ou un curseur next si nous avons atteint la dernière. La solution est la suivante : dans une requête « page suivante », si la taille maximale d’une page est N, nous demandons N + 1 lignes. Si nous les obtenons, nous savons qu’une page suivante existe. Nous renvoyons donc un curseur next basé sur la dernière ligne de la page renvoyée. Si un curseur after nous a été transmis dans la requête, nous pouvons supposer qu’il y a une page précédente. Le curseur prev correspondra donc à la première ligne de la page renvoyée.

Nos requêtes SQL permettant d’obtenir une page de contacts doivent tenir compte du fait que les noms ne sont pas forcément uniques. Voici donc notre requête « page suivante » (avec une expression de table commune pour ne pas avoir à transmettre les mêmes paramètres plusieurs fois) :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
WITH vars AS (SELECT ? AS family_name, ? AS given_name, ? AS email)
SELECT id, family_name, given_name, phone, email
FROM contact
WHERE (family_name = (SELECT family_name FROM vars)
       AND given_name = (SELECT given_name FROM vars)
       AND email > (SELECT email FROM vars))
OR (family_name = (SELECT family_name FROM vars)
    AND given_name > (SELECT given_name FROM vars))
OR family_name > (SELECT family_name FROM vars)
ORDER BY family_name, given_name, email
LIMIT ?;

Pour les requêtes « page précédente », le principe est le même, mais dans l’ordre inverse.

Traitement des requêtes de recherche en texte intégral #

Il ne reste plus grand-chose à faire pour la recherche en texte intégral. Le point de terminaison /{lang}/contacts reçoit une requête composée d’un ou plusieurs mots ou préfixes de mots. Au niveau de la couche de base de données, nous analysons cette requête pour l’adapter à la syntaxe du module FTS5. Par exemple, G O'Mal devient "G"* + "O'Mal"* et permettra de trouver un contact qui s’appelle Grace O’Malley. Comme nous renvoyons au maximum une page de résultats de recherche en texte intégral, la route transmet des curseurs None au template, qui supprime les liens de pagination au moyen d’un remplacement hors bande réalisé par htmx.

Tests d’intégration #

Les tests unitaires de cette application n’ont rien de remarquable (à moins que vous ne vous appeliez 曼玉-فاتن), mais les tests d’intégration sont plus intéressants. J’en ai écrit deux groupes : l’une pour la couche de base de données, et l’autre pour les points de terminaison HTTP. Dans les deux cas, nous utilisons sqlx::test, qui crée une nouvelle base de données pour chaque test et l’initialise avec une fixture, de sorte que les tests soient isolés les uns des autres. Comme mentionné plus haut, les tests des points de terminaison HTTP analysent les réponses HTML et vérifient le contenu de certains éléments. Nous utilisons le même Router que dans l’application, et analysons les réponses à l’aide de dom_query. Par exemple, pour tester une requête GET vers /{lang}/contacts/{id}/edit, le point de terminaison permettant de modifier un contact existant, nous pouvons procéder ainsi :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#[sqlx::test(fixtures("test-contacts"))]
async fn edit_contact_get(pool: SqlitePool) {
    let server = init_server(pool).await;
    let response = server.get("/en/contacts/1/edit").await;

    assert_eq!(response.status_code(), StatusCode::OK);
    let doc = Document::from(response.text());

    let email_label = doc.select("label[for='email']").text().to_string();
    assert_eq!(email_label, "Email");

    let email = doc.select("#email").attr("value").unwrap().to_string();
    assert_eq!(email, "foo1@bar.com");

    // ...
}

Comme nous examinons uniquement le texte de libellés et de champs de formulaire à l’aide de sélecteurs CSS simples, ce genre de test a peu de chances d’être affecté par des modifications apportées à la mise en page.

PostgreSQL #

Nous n’avons pas à modifier grand-chose pour que notre application utilise PostgreSQL à la place de SQLite.

Recherche en texte intégral #

(Si vous avez déjà lu la version Go de cet article, vous pouvez sauter cette section.)

Tout comme SQLite, Postgres intègre une fonctionnalité de recherche en texte intégral, et celle-ci est encore plus simple à configurer. Nous pouvons stocker les noms des contacts, transformés en valeurs tsvector, dans une colonne générée de la table contact, avec un index GIN pour accélérer les requêtes :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
CREATE TABLE IF NOT EXISTS contact (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  family_name TEXT NOT NULL,
  given_name TEXT NOT NULL,
  phone TEXT,
  email TEXT UNIQUE NOT NULL,
  textsearch tsvector GENERATED ALWAYS AS (
    to_tsvector(
      'simple',
      COALESCE(family_name, '') || ' ' || COALESCE(given_name, '')
    )
  ) STORED
);

CREATE INDEX textsearch_idx ON contact USING GIN (textsearch);

La requête SQL qui trouve les lignes correspondantes utilise l’opérateur de recherche en texte intégral, @@ :

1
2
3
4
5
SELECT id, family_name, given_name, phone, email
FROM contact, to_tsquery($1) query
WHERE query @@ textsearch
ORDER BY ts_rank_cd(textsearch, query)
LIMIT $2;

La syntaxe de la chaîne à transmettre à to_tsquery ne nécessite que quelques ajustements pour fonctionner avec Postgres : si l’utilisateur·ice saisit G O'Mal, nous transmettons l’argument 'G':* & 'O''Mal':*.

Tests d’intégration avec Testcontainers #

Nous pouvons utiliser Testcontainers et faire tourner Postgres dans un conteneur Docker éphémère. La macro #[sqlx:test] exécute les tests en parallèle et les isole les uns des autres en créant une nouvelle base de données dans le même conteneur pour chaque test.

Il y a ici un petit problème à résoudre : #[sqlx:test] génère du code qui lit l’URL de la base de données de test à partir d’une variable d’environnement ou d’un fichier .env, mais nous ne connaissons cette URL qu’une fois que Testcontainers a démarré le conteneur Docker. La solution, basée sur un exemple créé par Matilda Smeds, consiste à utiliser la crate linktime pour définir une fonction d’initialisation de module qui s’exécute avant les tests. Cette fonction lance le conteneur de test et définit la variable d’environnement DATABASE_URL via std::env::set_var.

Conclusion #

En suivant le principe « Analyser au lieu de valider » et en utilisant des outils de génération de code déterministes qui tirent parti des garanties offertes par le système de types de Rust, nous avons réduit au minimum le code répétitif et amélioré les chances que, si nous faisons une erreur, celle-ci soit détectée lors de la compilation plutôt qu’au moment de l’exécution. Cela devrait contribuer à faciliter la maintenance de notre code et à prévenir les bugs.


  1. See the N+1 selects problem and selecting all columns without thinking about it. ↩︎

Catégories :
Sujets :