Paramètres de requête avancés avec Express et Joi

Les paramètres de requête transforment une route Express simple en véritable interface de recherche. Pagination, filtres combinés, tri, recherche textuelle et sélection de champs permettent de construire des API souples sans multiplier les endpoints. Cette flexibilité exige toutefois une validation stricte, car les données reçues dans req.query proviennent directement du client.

Joi fournit une couche de contrôle expressive pour décrire les types attendus, les valeurs autorisées et les relations entre plusieurs paramètres. Associé à Express, il aide à rejeter rapidement les requêtes incohérentes avant qu’elles n’atteignent la base de données ou la logique métier.

Les cas avancés apparaissent lorsque les paramètres deviennent imbriqués, répétables ou conditionnels. Une URL comme ?page=2&limit=20&sort=-createdAt&status=published reste facile à gérer, tandis qu’une requête combinant plusieurs filtres, des bornes de dates et des tableaux demande une stratégie plus structurée.

Cette approche améliore aussi la sécurité et la maintenance. Elle limite les valeurs inattendues, évite les conversions implicites mal maîtrisées et fournit des messages d’erreur homogènes pour les consommateurs de l’API.

Comprendre la forme réelle de req.query

Express expose les paramètres après le ? dans req.query, mais leur forme dépend du parseur configuré. Par défaut, page=3 arrive généralement comme une chaîne de caractères. Joi peut convertir cette valeur en nombre lorsque l’option convert est active, ce qui évite de multiplier les appels à Number() dans les contrôleurs.

Les tableaux peuvent être transmis avec une notation répétée, comme tag=javascript&tag=node, ou avec une syntaxe indexée ou imbriquée selon le parseur utilisé. Avant d’écrire le schéma, il est donc utile de vérifier la structure effectivement produite par l’application. Une configuration explicite rend le comportement plus prévisible :

app.set('query parser', 'extended');

Avec le parseur étendu, des structures telles que filter[status]=published peuvent devenir des objets imbriqués. Cette possibilité est pratique, mais elle doit rester encadrée par un schéma Joi précis. Autoriser une profondeur ou un nombre de clés excessif peut également augmenter le coût du traitement.

Définir un schéma de pagination robuste

La pagination constitue un bon exemple de validation combinée. page doit être un entier positif, tandis que limit doit rester dans une plage raisonnable afin d’empêcher une requête de charger des milliers de lignes. Les valeurs par défaut doivent être appliquées au moment de la validation.

const querySchema = Joi.object({
  page: Joi.number().integer().min(1).default(1),
  limit: Joi.number().integer().min(1).max(100).default(20),
  sort: Joi.string()
    .valid('createdAt', '-createdAt', 'name', '-name')
    .default('-createdAt')
});

Une fois les données validées, le contrôleur peut calculer l’offset à partir de valeurs fiables :

const { value, error } = querySchema.validate(req.query, {
  abortEarly: false,
  convert: true,
  stripUnknown: true
});

if (error) {
  return res.status(400).json({
    message: 'Paramètres de requête invalides',
    details: error.details.map(detail => detail.message)
  });
}

const offset = (value.page - 1) * value.limit;

abortEarly: false rassemble toutes les erreurs au lieu de s’arrêter à la première. stripUnknown: true élimine les clés non prévues, ce qui est utile lorsqu’un client envoie des options obsolètes ou mal orthographiées.

Valider les filtres, tableaux et valeurs imbriquées

Les filtres multi-valeurs doivent être décrits explicitement. Un statut peut être unique ou répété, mais chaque élément doit appartenir à une liste contrôlée. Joi permet d’accepter les deux formes avec Joi.alternatives() ou de normaliser systématiquement l’entrée dans un tableau.

const filterSchema = Joi.object({
  status: Joi.array()
    .items(Joi.string().valid('draft', 'published', 'archived'))
    .single()
    .unique(),
  category: Joi.string().trim().max(80),
  minPrice: Joi.number().min(0),
  maxPrice: Joi.number().min(0),
  fields: Joi.array()
    .items(Joi.string().valid('id', 'name', 'price', 'createdAt'))
    .single()
    .unique()
});

L’option single() accepte aussi status=published et le convertit en tableau contenant un seul élément. unique() évite les répétitions inutiles. Pour les recherches par intervalle, la validation des deux bornes ne suffit pas toujours : une règle personnalisée doit vérifier que minPrice ne dépasse pas maxPrice.

Les filtres imbriqués peuvent être représentés par un objet :

const schema = Joi.object({
  filter: Joi.object({
    status: Joi.array().items(Joi.string().valid('draft', 'published')).single(),
    ownerId: Joi.string().guid({ version: 'uuidv4' })
  }).default({})
});

Cette structure clarifie le contrat d’API et empêche l’ajout arbitraire de propriétés dans filter.

Gérer le tri et la recherche sans ouvrir de faille

Le tri transmis par le client ne doit jamais être concaténé directement dans une requête SQL ou MongoDB. Même si la valeur paraît anodine, une liste blanche de colonnes et de directions reste indispensable. Le schéma Joi peut accepter une notation compacte comme -createdAt, puis le code applicatif la convertir en { column, direction }.

const sortSchema = Joi.string()
  .pattern(/^-?[a-zA-Z][a-zA-Z0-9_]*$/)
  .custom((value, helpers) => {
    const descending = value.startsWith('-');
    const column = descending ? value.slice(1) : value;

    if (!['createdAt', 'name', 'price'].includes(column)) {
      return helpers.error('any.invalid');
    }

    return { column, direction: descending ? 'desc' : 'asc' };
  });

La recherche textuelle mérite des limites similaires. Imposer une longueur maximale, supprimer les espaces inutiles et refuser certains formats excessifs réduit le risque d’abus. Le paramètre q peut ensuite alimenter une recherche indexée, plutôt qu’une construction libre de requête.

Pour choisir un moteur adapté aux recherches plus riches, la comparaison des solutions de recherche aide à distinguer les besoins d’indexation, de tolérance aux fautes et d’hébergement.

Créer un middleware de validation réutilisable

Répéter schema.validate(req.query) dans chaque route augmente les divergences de comportement. Un middleware générique peut recevoir un schéma, remplacer req.query par la version normalisée et transmettre une erreur standardisée au gestionnaire Express.

function validateQuery(schema) {
  return (req, res, next) => {
    const { error, value } = schema.validate(req.query, {
      abortEarly: false,
      convert: true,
      stripUnknown: true
    });

    if (error) {
      return res.status(400).json({
        code: 'INVALID_QUERY',
        errors: error.details.map(({ path, message, type }) => ({
          path: path.join('.'),
          message,
          type
        }))
      });
    }

    req.query = value;
    next();
  };
}

La route devient alors lisible :

app.get('/products', validateQuery(productQuerySchema), async (req, res, next) => {
  try {
    const products = await searchProducts(req.query);
    res.json(products);
  } catch (error) {
    next(error);
  }
});

Dans certaines applications, il est préférable de placer les données validées dans req.validatedQuery plutôt que de modifier req.query. Cette convention évite toute ambiguïté lorsque plusieurs middlewares lisent la requête.

Tester les scénarios limites et préparer la production

Les tests doivent couvrir les valeurs normales, les conversions, les paramètres absents et les combinaisons invalides. Vérifiez notamment limit=0, page=-1, une date mal formée, un tableau vide, des statuts répétés et un intervalle inversé. Les tests d’intégration doivent confirmer le code HTTP, la structure de l’erreur et l’absence d’appel à la base lorsque la validation échoue.

Les règles métier peuvent être ajoutées avec when, or, and ou custom. Par exemple, un paramètre cursor peut être incompatible avec page, tandis que q peut devenir obligatoire lorsque mode=search. Ces contraintes doivent rester dans le schéma lorsqu’elles décrivent le contrat d’entrée, et dans le service lorsqu’elles dépendent du contexte métier.

En production, surveillez la taille des URL, le nombre maximal de paramètres et les délais de validation. Une API déployée dans un environnement cloud doit aussi prévoir une journalisation prudente : les valeurs de recherche peuvent contenir des informations sensibles et ne devraient pas être inscrites intégralement dans les logs. Les ressources et pratiques de la zone cloud peuvent accompagner cette réflexion sur le déploiement et l’observabilité.

Adoptez enfin une convention stable pour les erreurs : code machine, chemin du paramètre et message lisible. Cette uniformité facilite le travail du frontend, des clients mobiles et des équipes qui automatisent leurs tests avec OpenAPI ou des collections Postman.

Commencez par centraliser les schémas Joi de vos routes de recherche, imposez des listes blanches pour le tri et les filtres, puis ajoutez des tests pour chaque combinaison sensible. Vous obtiendrez des endpoints Express plus prévisibles, plus sûrs et plus simples à faire évoluer.