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.webp→images.iconlogo.webp→images.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
codeest re-validé contre^[a-z0-9_-]+$(aprèsstrtolower) avant tout accès disque — un code malformé (../../etc, chemin absolu, dotfile) ne peut jamais sortir du dossierbrands/et donnenull/null.
GET /api/brands
Section titled “GET /api/brands”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 siqfourni, sinon ordre naturel de l’index). - Avec
?groupBy=type: chaque brand est explosé en N ressources, une par entrée de sontypes[]. Tri global(type ASC, position ASC)— appliqué côté serveur, ce qui garantit que les éléments d’un même type sont contigus dansdata[].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 viaattributes.brandId.
-
Auth : aucune.
-
Action : ListBrandsAction.
-
Query (tous optionnels) :
q: recherche full-text (matche lessearchableAttributesde l’index, typiquementname).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), sanssort(le tri par type/position se fait PHP-side après explosion ;types.positioncô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(id42) apparaît deux fois : une fois dans le groupecarrier(position 1) et une fois dansrental(position 5). C’est attendu.meta.totalHitsreste le nombre de brands distincts côté Meili (ici 3), pas la cardinalité dedata[].
- Réponse
200 OK(sansgroupBy) :
{ "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 ajoutemeta.truncated = true+meta.maxResults = <cap>— signal explicite que le cap serveur a été atteint et qu’il faut bumperBRANDS_MAX_RESULTScôté ops. -
Pagination : aucune (
linksne contient queself). 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 modegroupBy: ignorés (ils ne peuvent pas être assignés à un groupe). Pour les voir, appeler/api/brandssansgroupBy. -
Réponses d’erreur :
422 Invalid groupBy—groupByfourni avec une valeur autre quetype.503 Search backend unavailable— Meilisearch injoignable. Le détail Meili est propagé danserrors[0].detail.
Pré-requis index Meilisearch (one-shot ops) :
- les champs texte recherchables (
name, etc.) danssearchableAttributes- aucun pré-requis sur
sortableAttributes— tout le tri est PHP-side après explosion par type