Skip to content

Marques

Catalogue borné (< 1000 documents) hébergé dans l’index Meilisearch MEILISEARCH_BRANDS_INDEX (défaut brands). Pas de domaine Brand côté MySQL — lecture seule depuis Meili, alimenté par un processus externe.

Forme attendue du document : un brand peut appartenir à plusieurs types, chacun avec sa propre position. Le champ code (slug minuscule, ex : fram, cdiscount) sert de clé pour résoudre les images du brand sur disque.

{
"id": "42",
"name": "Acme",
"code": "acme",
"logoUrl": "",
"types": [
{ "type": "carrier", "position": 1 },
{ "type": "rental", "position": 5 }
]
}

Enrichissement images : chaque ressource brands reçoit un attribut images (objet { icon, logo }) résolu côté serveur à partir du champ code du document. Les fichiers sont servis comme assets first-party de l’app depuis public/assets/images/brands/<code>/ :

  • icon.webpimages.icon
  • logo.webpimages.logo

Chaque URL est construite sur APP_URL et n’est exposée que si le fichier existe réellement sur disque ; un fichier absent donne null (le client retombe sur son propre placeholder). La résolution est faite une seule fois par brand, y compris en mode ?groupBy=type (toutes les ressources explosées d’un même brand partagent le même bloc images, pour éviter N accès disque par brand). Un brand sans code exploitable donne { "icon": null, "logo": null }.

Garde anti-path-traversal : le code est re-validé contre ^[a-z0-9_-]+$ (après strtolower) avant tout accès disque — un code malformé (../../etc, chemin absolu, dotfile) ne peut jamais sortir du dossier brands/ et donne null/null.

Retourne tous les brands en un seul appel — pas de pagination, pas de limit/offset exposés au client. Deux modes :

  • Sans ?groupBy=type (défaut) : une ressource par brand, types[] intégral dans les attributs. Ordre : celui de Meili (relevance si q fourni, sinon ordre naturel de l’index).
  • Avec ?groupBy=type : chaque brand est explosé en N ressources, une par entrée de son types[]. Tri global (type ASC, position ASC) — appliqué côté serveur, ce qui garantit que les éléments d’un même type sont contigus dans data[]. meta.groups[] décrit la longueur de chaque segment.

Id composite en mode groupé : un brand qui apparaît dans plusieurs types est dupliqué dans data[]. Pour respecter la contrainte JSON:API “(type, id) unique par document”, l’id de chaque ressource explosée devient <brandId>:<typeName> (ex : 42:carrier). L’id canonique reste accessible via attributes.brandId.

  • Auth : aucune.

  • Action : ListBrandsAction.

  • Query (tous optionnels) :

    • q : recherche full-text (matche les searchableAttributes de l’index, typiquement name).
    • groupBy : seule valeur supportée = type. Active l’explosion + le tri (type, position) + meta.groups[]. Toute autre valeur ⇒ 422.
  • Pipeline : un seul appel Meili avec limit = BRANDS_MAX_RESULTS (défaut 1000), sans sort (le tri par type/position se fait PHP-side après explosion ; types.position côté Meili ne suffirait pas pour un ordre global sur un tableau imbriqué).

  • Réponse 200 OK (mode ?groupBy=type) :

{
"data": [
{ "type": "brands", "id": "42:carrier", "attributes": { "name": "Acme", "type": "carrier", "position": 1, "brandId": "42", "logoUrl": "", "images": { "icon": "https://app.example/assets/images/brands/acme/icon.webp", "logo": "https://app.example/assets/images/brands/acme/logo.webp" } } },
{ "type": "brands", "id": "17:carrier", "attributes": { "name": "Globex", "type": "carrier", "position": 2, "brandId": "17", "logoUrl": "", "images": { "icon": null, "logo": null } } },
{ "type": "brands", "id": "42:rental", "attributes": { "name": "Acme", "type": "rental", "position": 5, "brandId": "42", "logoUrl": "", "images": { "icon": "https://app.example/assets/images/brands/acme/icon.webp", "logo": "https://app.example/assets/images/brands/acme/logo.webp" } } },
{ "type": "brands", "id": "08:rental", "attributes": { "name": "Initech", "type": "rental", "position": 1, "brandId": "08", "logoUrl": "", "images": { "icon": null, "logo": null } } }
],
"links": {
"self": "https://api.example/api/brands?groupBy=type"
},
"meta": {
"totalHits": 3,
"query": "",
"groups": [
{ "type": "carrier", "count": 2 },
{ "type": "rental", "count": 2 }
]
}
}

Le brand Acme (id 42) apparaît deux fois : une fois dans le groupe carrier (position 1) et une fois dans rental (position 5). C’est attendu. meta.totalHits reste le nombre de brands distincts côté Meili (ici 3), pas la cardinalité de data[].

  • Réponse 200 OK (sans groupBy) :
{
"data": [
{
"type": "brands",
"id": "42",
"attributes": {
"name": "Acme",
"code": "acme",
"logoUrl": "",
"types": [
{ "type": "carrier", "position": 1 },
{ "type": "rental", "position": 5 }
],
"images": {
"icon": "https://app.example/assets/images/brands/acme/icon.webp",
"logo": "https://app.example/assets/images/brands/acme/logo.webp"
}
}
}
],
"links": { "self": "https://api.example/api/brands" },
"meta": { "totalHits": 1, "query": "" }
}
  • Garde-fou catalogue : si meta.totalHits > BRANDS_MAX_RESULTS, la réponse ajoute meta.truncated = true + meta.maxResults = <cap> — signal explicite que le cap serveur a été atteint et qu’il faut bumper BRANDS_MAX_RESULTS côté ops.

  • Pagination : aucune (links ne contient que self). C’est volontaire — le catalogue est petit, le client peut tout charger en mémoire et l’utiliser comme référentiel.

  • Brands sans types[] en mode groupBy : ignorés (ils ne peuvent pas être assignés à un groupe). Pour les voir, appeler /api/brands sans groupBy.

  • Réponses d’erreur :

    • 422 Invalid groupBygroupBy fourni avec une valeur autre que type.
    • 503 Search backend unavailable — Meilisearch injoignable. Le détail Meili est propagé dans errors[0].detail.

Pré-requis index Meilisearch (one-shot ops) :

  • les champs texte recherchables (name, etc.) dans searchableAttributes
  • aucun pré-requis sur sortableAttributes — tout le tri est PHP-side après explosion par type