Skip to content

É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 :

  1. recopie tous les champs du document dans attributes, sauf les clés Meili-internes (_geo, _geoDistance, _formatted, _matchesPosition, _rankingScore, _rankingScoreDetails) et le id brut (passe en JSON:API id) ;
  2. aplatit _geo: { lat, lng } en attributes.latitude / attributes.longitude (les coords source sont stockées comme strings — "42.644108000000" — le (float) cast les normalise) ;
  3. expose _geoDistance (mètres) en attributes.distanceMeters quand 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_at et updated_at sont stockés dans l’index (le formatter les utilise pour le tri whitelist sort=created_at / sort=updated_at) mais sont privés : ils ne sortent pas dans attributes — 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/).

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éfaut 20, plafond 100).
    • offset (int ≥ 0, défaut 0).
    • 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 de sortableAttributes ce 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 de sort hors whitelist.
    • 503 Search backend unavailable.

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. Sans q, Meilisearch retourne tous les documents (filtrés par géo si présent), ordre par défaut.
    • lat, lng, distancetous 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é par ESTABLISHMENT_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.distanceMeters rempli 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.center et meta.distance ne sont présents que si le mode géo est actif.

  • attributes.distanceMeters n’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.last calculé grâce à totalHits).

  • Réponses d’erreur :

    • 422 Incomplete geo parameters — un seul de lat/lng/distance est fourni (ou deux sur trois).
    • 422 Invalid latitude / Invalid longitude / Invalid distance — valeurs hors plage ou non numériques.
    • 422 Distance too largedistance > ESTABLISHMENT_NEARBY_MAX_DISTANCE_METERS.
    • 503 Search backend unavailable — Meilisearch injoignable, index manquant, ou pré-requis index non satisfaits (_geo pas dans filterableAttributes/sortableAttributes). Le détail Meili est propagé dans errors[0].detail.

Pré-requis index Meilisearch (ops one-shot, sans ça le mode géo 503e) :

  • _geo doit être dans filterableAttributes ET sortableAttributes
  • Les champs textuels à exposer dans la recherche (name, address, etc.) doivent être listés dans searchableAttributes

Sans ça, l’endpoint répond 503 côté géo (la recherche purement full-text reste fonctionnelle tant qu’un searchableAttributes est configuré).

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’attribut id est 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 dossier images/ du bucket de l’établissement (carousel client). Extensions retenues : webp, jpg, jpeg, png, gif, avif. Préfixer les fichiers par 01_, 02_, … donne un contrôle d’ordre sans état BD.
    • Fallback : bucket absent / dossier images/ absent / contenu vide après filtre ⇒ images contient une seule entrée, l’URL absolue du fichier partagé <ESTABLISHMENT_STATIC_PUBLIC_URL>/establishment/default-establishment.webp (fichier ops à la racine de establishment/, calqué sur le pattern user/default-avatar.webp). Le client peut donc toujours afficher au moins une slide sans cas particulier “pas d’image”.
  • description — corps Markdown brut (non rendu serveur-side) lu depuis <locale>_description.md dans le bucket. Résolution :
    1. <requestLocale>_description.md (si la locale est supportée — cf. SupportedLocales),
    2. sinon <FALLBACK>_description.md (fr-FR par défaut),
    3. 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.md

Exemple pour id = 00003c7104994cc688663a51b89c9d40establishment/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/establishments ni /api/establishments/search — sinon chaque page paginée déclencherait jusqu’à 100 scandir() 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.