---
title: "Établissements"
description: "Triplet /api/establishments (list paginé) / /api/establishments/search (full-text + géo) / /api/establishments/{id} (détail) adossé à l'index Meilisearch M…"
---

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/`).

## `GET /api/establishments`

Listing paginé du catalogue, sans filtre full-text ni géo (pour ça, voir `/api/establishments/search`).

- **Auth** : aucune.
- **Action** : [ListEstablishmentsAction](../src/Http/Action/Api/Establishment/ListEstablishmentsAction.php).
- **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`** :

```json
{
  "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`.

## `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](../src/Http/Action/Api/Establishment/SearchEstablishmentsAction.php).
- **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`, `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é 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`** :

```json
{
  "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](#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 large` — `distance` > `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é).

## `GET /api/establishments/{id}`

Détail d'un établissement par son id hex.

- **Auth** : aucune.
- **Action** : [GetEstablishmentAction](../src/Http/Action/Api/Establishment/GetEstablishmentAction.php).
- **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)

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 = 00003c7104994cc688663a51b89c9d40` ⇒ `establishment/00/00/3c/00003c7104994cc688663a51b89c9d40/`.

Le loader [EstablishmentStaticAssetsLoader](../src/Domain/Establishment/EstablishmentStaticAssetsLoader.php) 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`** :

```json
{
  "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`.

---
