REST API design - 07 - Filtrage et tri sans prise de tête

Query params, opérateurs de comparaison, tri multi-colonnes, recherche full-text et sélection de champs.

  1. 01 REST API design - 00 - Pourquoi le design de ton API change tout
  2. 02 REST API design - 01 - Les principes REST que personne ne lit
  3. 03 REST API design - 02 - Des URLs qui ont du sens
  4. 04 REST API design - 03 - Les méthodes HTTP, pour de vrai
  5. 05 REST API design - 04 - Les codes de statut HTTP qu'il faut connaître
  6. 06 REST API design - 05 - Body, headers et le diable dans les détails
  7. 07 REST API design - 06 - La pagination, ou comment ne pas tuer ta base
  8. 08 REST API design - 07 - Filtrage et tri sans prise de tête
  9. 09 REST API design - 08 - La validation avec Zod, gardien de ton API
  10. 10 REST API design - 09 - Erreurs : un format que tes clients vont adorer
  11. 11 REST API design - 10 - Authentification : JWT, API keys et OAuth2
  12. 12 REST API design - 11 - Versioning : quand et comment faire évoluer ton API
  13. 13 REST API design - 12 - CORS : comprendre et debugger les erreurs cross-origin
  14. 14 REST API design - 13 - Rate limiting : protéger ton API sans frustrer tes clients
  15. 15 REST API design - 14 - Caching : les bonnes réponses sont celles qu'on n'envoie pas
  16. 16 REST API design - 15 - Upload de fichiers : multipart, signed URLs et streaming
  17. 17 REST API design - 16 - Relations entre ressources : embarquer, lier ou les deux
  18. 18 REST API design - 17 - HATEOAS : des liens dans tes réponses
  19. 19 REST API design - 18 - OpenAPI : le contrat entre ton API et le monde
  20. 20 REST API design - 19 - Documentation : une API non documentee est une API inutile
  21. 21 REST API design - 20 - Testing : tester ton API sans devenir fou
  22. 22 REST API design - 21 - Webhooks : quand c'est ton API qui appelle
  23. 23 REST API design - 22 - Performance : quand chaque milliseconde compte
  24. 24 REST API design - 23 - Sécurité : les attaques que tu vas subir (et comment t'en protéger)
  25. 25 REST API design - 24 - Glossaire : tous les termes REST API en un seul endroit

07 - Filtrage et tri sans prise de tête

Ce que tu vas apprendre

  • Comment désigner des filtres propres via query params
  • Les opérateurs de comparaison (gte, lte, like) et leur syntaxe
  • Le tri multi-colonnes ascendant et descendant
  • La recherche full-text et la sélection de champs
  • Comment combiner le tout sans créer un monstre

Prerequisites


Le filtre a un million de if

Sur un projet e-commerce, le endpoint /api/products avait accumule 47 query params au fil des mois. Chaque nouveau besoin ajoutait un if (req.query.xxx) dans le controller. Le code ressemblait a un arbre de Noel de conditions imbriquees. Impossible a tester, impossible a documenter. Quand le PM a demande "filtre par fourchette de prix ET par note minimum", le dev a pose sa dem.

Le filtrage, c'est un problème résolu. Il suffit de choisir une convention et de s'y tenir.

Filtrage par query params

Egalite simple

Le cas le plus courant. Un param = un filtre.

httpGET /api/products?status=active
GET /api/products?category=electronics&brand=sony
GET /api/users?role=admin&status=active

Operateurs de comparaison

Pour les fourchettes de prix, les dates, les quantités, il faut des opérateurs. Plusieurs conventions existent. Je préféré le suffixe entre crochets :

http# Prix entre 10 et 100
GET /api/products?price[gte]=10&price[lte]=100

# Cree apres une date
GET /api/users?createdAt[gte]=2026-01-01

# Note superieure a 4
GET /api/products?rating[gt]=4

En TypeScript, un parser générique :

typescriptinterface FilterOperators {
  eq?: string;
  gt?: string;
  gte?: string;
  lt?: string;
  lte?: string;
  like?: string;
}

function parseFilters(query: Record<string, string>): Record<string, FilterOperators> {
  const filters: Record<string, FilterOperators> = {};

  for (const [key, value] of Object.entries(query)) {
    const match = key.match(/^(\w+)\[(\w+)\]$/);

    if (match) {
      const [, field, operator] = match;
      filters[field] = filters[field] || {};
      filters[field][operator as keyof FilterOperators] = value;
    } else if (!["sort", "page", "limit", "fields", "search"].includes(key)) {
      filters[key] = { eq: value };
    }
  }

  return filters;
}

// Utilisation
// ?status=active&price[gte]=10&price[lte]=100
// => { status: { eq: "active" }, price: { gte: "10", lte: "100" } }

Valeurs multiples

Pour filtrer sur plusieurs valeurs d'un meme champ, j'utilise la virgule :

http# Produits en statut active OU draft
GET /api/products?status=active,draft

# Categories electronics OU books
GET /api/products?category=electronics,books
typescriptfunction buildWhereClause(filters: Record<string, FilterOperators>) {
  const conditions: string[] = [];
  const params: unknown[] = [];
  let paramIndex = 1;

  for (const [field, ops] of Object.entries(filters)) {
    if (ops.eq && ops.eq.includes(",")) {
      const values = ops.eq.split(",");
      conditions.push(`${field} = ANY($${paramIndex})`);
      params.push(values);
      paramIndex++;
    } else if (ops.eq) {
      conditions.push(`${field} = $${paramIndex}`);
      params.push(ops.eq);
      paramIndex++;
    }
    if (ops.gte) {
      conditions.push(`${field} >= $${paramIndex}`);
      params.push(ops.gte);
      paramIndex++;
    }
    if (ops.lte) {
      conditions.push(`${field} <= $${paramIndex}`);
      params.push(ops.lte);
      paramIndex++;
    }
  }

  return {
    where: conditions.length ? `WHERE ${conditions.join(" AND ")}` : "",
    params,
  };
}

Tri

Le tri se fait avec un query param sort. Le prefixe - indique l'ordre descendant.

http# Trier par prix croissant
GET /api/products?sort=price

# Trier par prix decroissant
GET /api/products?sort=-price

# Tri multi-colonnes : par categorie puis par prix decroissant
GET /api/products?sort=category,-price
typescriptfunction parseSortParam(sort: string): { field: string; order: "ASC" | "DESC" }[] {
  const allowedFields = ["price", "name", "createdAt", "rating", "category"];

  return sort.split(",").map((s) => {
    const desc = s.startsWith("-");
    const field = desc ? s.slice(1) : s;

    if (!allowedFields.includes(field)) {
      throw new Error(`Sort field not allowed: ${field}`);
    }

    return { field, order: desc ? "DESC" : "ASC" };
  });
}

// sort=-price,name => [{ field: "price", order: "DESC" }, { field: "name", order: "ASC" }]

Le whitelist des champs triables est obligatoire. Sans ca, un utilisateur peut trier sur une colonne sans index et mettre ta base a genoux.

Recherche full-text

Pour la recherche, un param search ou q qui cherche dans plusieurs champs.

httpGET /api/products?search=wireless+headphones
GET /api/users?q=alice
typescriptrouter.get("/api/products", async (req, res) => {
  const search = req.query.search ? String(req.query.search) : null;

  let query = "SELECT * FROM products";
  const params: unknown[] = [];

  if (search) {
    query += ` WHERE to_tsvector('french', name || ' ' || description)
               @@ plainto_tsquery('french', $1)`;
    params.push(search);
  }

  const products = await db.query(query, params);
  res.json({ data: products.rows });
});

Pour les petites tables, un ILIKE '%terme%' suffit. Pour les grosses tables, PostgreSQL tsvector ou un moteur dédié (Elasticsearch, Meilisearch) sera nécessaire.

Selection de champs

Permet au client de demander seulement les champs dont il a besoin. Reduit la bande passante et ameliore les performances.

http# Seulement id, name et price
GET /api/products?fields=id,name,price
typescriptfunction parseFields(fields: string | undefined, allowed: string[]): string[] {
  if (!fields) return allowed;

  return fields
    .split(",")
    .filter((f) => allowed.includes(f));
}

router.get("/api/products", async (req, res) => {
  const allowed = ["id", "name", "price", "category", "status", "createdAt"];
  const fields = parseFields(req.query.fields as string, allowed);

  const products = await db.query(
    `SELECT ${fields.join(", ")} FROM products LIMIT 20`
  );

  res.json({ data: products.rows });
});

Attention : ne construis jamais la liste de colonnes SQL directement depuis l'input utilisateur sans whitelist. C'est une injection SQL en attente.

Tout combiner

Un endpoint complet avec filtrage, tri, recherche, sélection de champs et pagination :

httpGET /api/products?category=electronics&price[gte]=50&price[lte]=500&sort=-rating,price&fields=id,name,price,rating&search=bluetooth&page=2&limit=20

Ca parait complexe, mais chaque concern est isole dans sa propre fonction. Le controller reste propre :

typescriptrouter.get("/api/products", async (req, res) => {
  const filters = parseFilters(req.query);
  const sort = parseSortParam(String(req.query.sort || "createdAt"));
  const fields = parseFields(req.query.fields as string, ALLOWED_FIELDS);
  const page = Math.max(1, Number(req.query.page) || 1);
  const limit = Math.min(Number(req.query.limit) || 20, 100);

  const result = await productService.findAll({ filters, sort, fields, page, limit });

  res.json(result);
});

Retrouve des exemples de filtrage avances sur paltemps.fr.

Résumé

  • Utilise les query params pour le filtrage avec une syntaxe coherente (crochets pour les opérateurs)
  • Le tri avec - pour le descendant et la virgule pour le multi-colonnes
  • Whitelist obligatoire pour les champs triables et selectionnables
  • La recherche full-text merite un param dédié (search ou q)
  • Chaque concern (filtre, tri, champs, pagination) dans sa propre fonction

Precedent : La pagination | Suivant : La validation

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.