Établissements
Triplet /api/establishments (list paginé) / /api/establishments/search (full-text + géo) / /api/establishments/{id} (détail) adossé à l’index Meilisearch MEILISEARCH_ESTABLISHMENTS_INDEX (défaut establishments_dev ; prod : establishments). Catalogue ~350 000 documents ⇒ pagination obligatoire sur les listings.
Contrairement aux médias et aux utilisateurs, Hydrogen n’a pas de domaine Establishment côté MySQL : l’index Meilisearch est la source de vérité, alimenté par un processus externe. Pas de re-hydratation SQL, pas de cache applicatif — chaque hit Meili devient ressource JSON:API directement.
Identité côté API : le id JSON:API d’une ressource establishments est le hash hex 32 caractères lowercase stocké dans le champ id du document Meili (ex: 00003c7104994cc688663a51b89c9d40). Les documents source sont déjà en minuscules, la regex de path ^[a-f0-9]{32}$ est stricte sur la casse pour garder un espace d’URL canonique (pas de variante upper).
Shape attributes commune (formatter partagé) : les 3 endpoints utilisent EstablishmentHitFormatter, qui :
- recopie tous les champs du document dans
attributes, sauf les clés Meili-internes (_geo,_geoDistance,_formatted,_matchesPosition,_rankingScore,_rankingScoreDetails) et leidbrut (passe en JSON:API id) ; - aplatit
_geo: { lat, lng }enattributes.latitude/attributes.longitude(les coords source sont stockées comme strings —"42.644108000000"— le(float)cast les normalise) ; - expose
_geoDistance(mètres) enattributes.distanceMetersquand le tri géo est actif (search en mode géo uniquement).
Champs métier exposés tels quels : name, open_location_code, class[], contact{email[], website[], phone[]}, city{id[], name[]}, region{id[], name[]}, country{id[], name[]}, continent{id[], name[]}. Les sous-objets géographiques (city/region/country/continent) sont passés tels quels depuis l’index : leur format (tableau d’ids/noms) est défini par le pipeline d’alimentation, pas par Hydrogen.
created_atetupdated_atsont stockés dans l’index (le formatter les utilise pour le tri whitelistsort=created_at/sort=updated_at) mais sont privés : ils ne sortent pas dansattributes— bookkeeping ETL, hors contrat public.
Pas de description Markdown ici — contrairement aux pays/régions/sous-régions, les établissements n’ont pas de blurb éditorial. Si on en ajoute un jour, ce sera via un domaine BO dédié (pas via
resources/lang/).
GET /api/establishments
Section titled “GET /api/establishments”Listing paginé du catalogue, sans filtre full-text ni géo (pour ça, voir /api/establishments/search).
-
Auth : aucune.
-
Action : ListEstablishmentsAction.
-
Query :
limit(int, défaut20, plafond100).offset(int ≥ 0, défaut0).sort(string, optionnel) — whitelist :created_at/-created_at(= défaut, desc),created_at_asc,updated_at/-updated_at,updated_at_asc,name(asc),-name(desc). Valeur hors whitelist →422 Invalid sort(évite d’exposer un attribut absent desortableAttributesce qui 503erait Meili).
-
Tri par défaut :
created_at:desc— les nouveautés en haut. -
Réponse
200 OK:
{ "data": [ { "type": "establishments", "id": "00003c7104994cc688663a51b89c9d40", "attributes": { "name": "Appartement de vacances à Grad Dubrovnik", "open_location_code": "8FJWJ3VR+J4", "class": [], "contact": { "email": [], "website": [], "phone": [] }, "city": { "id": [], "name": [] }, "region": { "id": [], "name": [] }, "country": { "id": [], "name": [] }, "continent": { "id": [], "name": [] }, "latitude":42.644108, "longitude": 18.090339 } } ], "links": { "self": "https://api.example/api/establishments?limit=20", "first": "https://api.example/api/establishments?limit=20", "prev": null, "next": "https://api.example/api/establishments?limit=20&offset=20", "last": "https://api.example/api/establishments?limit=20&offset=354500" }, "meta": { "totalHits": 354505, "limit": 20, "offset": 0, "count": 20, "sort": "created_at:desc" }}- Réponses d’erreur :
422 Invalid sort— valeur desorthors whitelist.503 Search backend unavailable.
GET /api/establishments/search
Section titled “GET /api/establishments/search”Recherche d’établissements par nom (?q=…), par proximité GPS (?lat=…&lng=…&distance=…), ou les deux combinés.
-
Auth : aucune (endpoint public).
-
Action : SearchEstablishmentsAction.
-
Query :
q(optionnel) — recherche full-text. Sansq, Meilisearch retourne tous les documents (filtrés par géo si présent), ordre par défaut.lat,lng,distance— tous les trois ou aucun. Fournir l’un sans les autres ⇒422 Incomplete geo parameters. Avec eux, le filtre_geoRadius(lat, lng, distance)s’applique ET le tri bascule sur_geoPoint(lat, lng):asc(du plus proche au plus éloigné).lat: float dans[-90, 90].lng: float dans[-180, 180].distance: entier positif (mètres), borné parESTABLISHMENT_NEARBY_MAX_DISTANCE_METERS(défaut 50 km). Au-delà →422 Distance too large.
limit(1..50, défaut 20).offset(≥0, défaut 0).
-
Pipeline : pas de re-hydratation MySQL — mêmes étapes de mise en forme que le listing (formatter partagé), avec en plus
attributes.distanceMetersrempli quand le tri géo est actif. -
Réponse
200 OK:
{ "data": [ { "type": "establishments", "id": "00003c7104994cc688663a51b89c9d40", "attributes": { "name": "Café des Arts", "open_location_code": "8FW4V75V+8Q", "class": ["cafe"], "contact": { "email": [], "website": [], "phone": [] }, "city": { "id": [], "name": [] }, "region": { "id": [], "name": [] }, "country": { "id": [], "name": [] }, "continent": { "id": [], "name": [] }, "latitude":48.8566, "longitude": 2.3522, "distanceMeters": 412.7 } } ], "links": { "self": "https://api.example/api/establishments/search?q=café&lat=48.8566&lng=2.3522&distance=2000&limit=20", "first": "https://api.example/api/establishments/search?q=café&lat=48.8566&lng=2.3522&distance=2000&limit=20", "prev": null, "next": "https://api.example/api/establishments/search?q=café&lat=48.8566&lng=2.3522&distance=2000&limit=20&offset=20", "last": "https://api.example/api/establishments/search?q=café&lat=48.8566&lng=2.3522&distance=2000&limit=20&offset=80" }, "meta": { "totalHits": 87, "limit": 20, "offset": 0, "query": "café", "center": { "lat": 48.8566, "lng": 2.3522 }, "distance": 2000 }}-
meta.totalHits: estimé par Meilisearch (sémantique offset, cf. conventions générales). -
meta.centeretmeta.distancene sont présents que si le mode géo est actif. -
attributes.distanceMetersn’est présent que sur les hits issus d’un tri_geoPoint:asc(mode géo). -
Pagination : offset-based, navigation via
links.{self,first,prev,next,last}(links.lastcalculé grâce àtotalHits). -
Réponses d’erreur :
422 Incomplete geo parameters— un seul delat/lng/distanceest fourni (ou deux sur trois).422 Invalid latitude/Invalid longitude/Invalid distance— valeurs hors plage ou non numériques.422 Distance too large—distance>ESTABLISHMENT_NEARBY_MAX_DISTANCE_METERS.503 Search backend unavailable— Meilisearch injoignable, index manquant, ou pré-requis index non satisfaits (_geopas dansfilterableAttributes/sortableAttributes). Le détail Meili est propagé danserrors[0].detail.
Pré-requis index Meilisearch (ops one-shot, sans ça le mode géo 503e) :
_geodoit être dansfilterableAttributesETsortableAttributes- Les champs textuels à exposer dans la recherche (
name,address, etc.) doivent être listés danssearchableAttributesSans ça, l’endpoint répond
503côté géo (la recherche purement full-text reste fonctionnelle tant qu’unsearchableAttributesest configuré).
GET /api/establishments/{id}
Section titled “GET /api/establishments/{id}”Détail d’un établissement par son id hex.
- Auth : aucune.
- Action : GetEstablishmentAction.
- Path :
{id}— regex stricte[a-f0-9]{32}. Pas de normalisation de casse :00003C71…(upper) ⇒404. Le pattern de routage Slim porte déjà la regex, donc tout id mal formé n’atteint même pas l’action.
- Résolution : filtre Meili exact
id = "<hex>"sur l’index (l’attributidest filterable) — pas de recherche full-text, le scorer pourrait surfacer un mauvais doc sur des préfixes d’id.
Enrichissement statique (détail uniquement)
Section titled “Enrichissement statique (détail uniquement)”En plus des champs venant de l’index, le détail attache deux attributs lus depuis le mount partagé hexatrip-static (cf. envs ESTABLISHMENT_STATIC_PATH / ESTABLISHMENT_STATIC_PUBLIC_URL, défaut = mêmes valeurs qu’AVATAR_*) :
images— liste ordonnée alphabétiquement des URL absolues du dossierimages/du bucket de l’établissement (carousel client). Extensions retenues :webp,jpg,jpeg,png,gif,avif. Préfixer les fichiers par01_,02_, … donne un contrôle d’ordre sans état BD.- Fallback : bucket absent / dossier
images/absent / contenu vide après filtre ⇒imagescontient une seule entrée, l’URL absolue du fichier partagé<ESTABLISHMENT_STATIC_PUBLIC_URL>/establishment/default-establishment.webp(fichier ops à la racine deestablishment/, calqué sur le patternuser/default-avatar.webp). Le client peut donc toujours afficher au moins une slide sans cas particulier “pas d’image”.
- Fallback : bucket absent / dossier
description— corps Markdown brut (non rendu serveur-side) lu depuis<locale>_description.mddans le bucket. Résolution :<requestLocale>_description.md(si la locale est supportée — cf.SupportedLocales),- sinon
<FALLBACK>_description.md(fr-FRpar défaut), - sinon
null.
Bucket on-disk, ventilé sur 3 niveaux comme les avatars (3 paires de 2 chars hex du début de l’id) :
<ESTABLISHMENT_STATIC_PATH>/establishment/ default-establishment.webp ← fallback partagé (carousel sans image) AA/BB/CC/<hex32>/ images/ 01_facade.webp 02_lobby.webp … fr-FR_description.md en-US_description.mdExemple pour id = 00003c7104994cc688663a51b89c9d40 ⇒ establishment/00/00/3c/00003c7104994cc688663a51b89c9d40/.
Le loader EstablishmentStaticAssetsLoader re-valide l’id contre ^[a-f0-9]{32}$ et la locale via SupportedLocales::supports() avant tout stat() (path-traversal guard). Un bucket absent n’est jamais une erreur : la ressource sort avec images: [] + description: null.
⚠️ Cet enrichissement n’est pas appliqué sur
/api/establishmentsni/api/establishments/search— sinon chaque page paginée déclencherait jusqu’à 100scandir()consécutifs. Si un listing a besoin d’une miniature, ce sera un champ dédié dans l’index Meili (pas une lecture disque par hit).
- Réponse
200 OK:
{ "data": { "type": "establishments", "id": "00003c7104994cc688663a51b89c9d40", "attributes": { "name": "Appartement de vacances à Grad Dubrovnik", "open_location_code": "8FJWJ3VR+J4", "class": [], "contact": { "email": [], "website": [], "phone": [] }, "city": { "id": [], "name": [] }, "region": { "id": [], "name": [] }, "country": { "id": [], "name": [] }, "continent": { "id": [], "name": [] }, "latitude": 42.644108, "longitude": 18.090339, "images": [ "http://hexatrip-static.dev.com/establishment/00/00/3c/00003c7104994cc688663a51b89c9d40/images/01_facade.webp", "http://hexatrip-static.dev.com/establishment/00/00/3c/00003c7104994cc688663a51b89c9d40/images/02_lobby.webp" ], "description": "## À propos\n\nUn appartement de charme au cœur de la vieille ville…" } }}- Réponses d’erreur :
404 Establishment not found— id mal formé (n’atteint pas l’action, le router 404e via la regex) OU id valide mais absent de l’index.503 Search backend unavailable.