Skip to content

Villes

Triplet /api/cities (list paginé) / /api/cities/search (full-text + géo) / /api/cities/{id} (détail) adossé à l’index Meilisearch MEILISEARCH_CITIES_INDEX (défaut cities). Catalogue ~10 000 documents (montée en charge progressive via le pipeline d’alimentation externe).

Comme pour les établissements et les offres, Hydrogen n’a pas de domaine City côté MySQL : l’index Meilisearch est la source de vérité, alimenté par un processus externe. Pas de re-hydratation SQL.

Identité côté API : contrairement à /api/establishments/{id} (32-hex) et /api/offers/{id} (32-hex), le id JSON:API d’une ressource cities est un UUID dashé lowercase (ex: 67104949-52b7-11f1-96d5-00155dda08de). La regex de path ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ est stricte sur la casse — uppercase ⇒ 404 (URL space canonique).

Shape attributes commune (formatter partagé) : les 3 endpoints utilisent CityHitFormatter, 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 ;
  2. aplatit _geo: { lat, lng } en attributes.latitude / attributes.longitude ;
  3. expose _geoDistance (mètres) en attributes.distanceMeters quand le tri géo est actif (search en mode géo uniquement) ;
  4. normalise country_id / region_id / subregion_id en lowercase (cohérence avec /api/countries/{code}, /api/regions/{code}, /api/subregions/{code}).

Champs métier exposés tels quels : slug, codes{osm, wikidata, wikipedia}, names, official_names, alt_names, stats{stats, surface}. Le pipeline source stocke certains de ces champs à null selon la couverture data (names, alt_names, official_names notamment) — Hydrogen les passe à l’identique.

Embed hiérarchique : chaque ressource cities est enrichie de 3 blocs inline :

  • country — résolu via CountrySummaryResolver contre l’index countries.
  • region — résolu via RegionSummaryResolver contre l’index regions.
  • subregion — résolu via SubregionSummaryResolver contre l’index subregions.

Les 3 résolveurs font du batch loading (1 round-trip Meili par niveau quel que soit le nombre de villes sur la page — pas de N+1). Si un code ISO ne résout pas dans son index parent, le bloc vaut null (l’index subregions ne couvre actuellement que ~99 codes ⇒ la majorité des villes auront subregion: null ; c’est une question de couverture data, pas un bug).

Listing paginé du catalogue, avec filtres hiérarchiques cumulables (AND). Pas de full-text ni de géo ici — voir /api/cities/search.

  • Auth : aucune.

  • Action : ListCitiesAction.

  • Query :

    • limit (int, défaut 20, plafond 100).
    • offset (int ≥ 0, défaut 0).
    • country (ISO 3166-1 alpha-2, casse insensible, normalisée upper côté serveur) — filtre country_id.
    • region (ISO 3166-2, 2..7 chars alphanum + tiret, casse insensible) — filtre region_id.
    • subregion (ISO 3166-2, même contrainte) — filtre subregion_id.
  • Tri : aucun ?sort= n’est exposé. L’index cities n’a que _geo en sortableAttributes (réservé au search). Ordre = ordre naturel Meili (= ordre d’insertion).

  • Réponse 200 OK :

{
"data": [
{
"type": "cities",
"id": "67104949-52b7-11f1-96d5-00155dda08de",
"attributes": {
"slug": "paris",
"codes": { "osm": null, "wikidata": "Q90", "wikipedia": "fr:Paris" },
"names": null,
"official_names": null,
"alt_names": null,
"stats": { "stats": "2133111", "surface": "105.3" },
"country_id": "fr",
"region_id": "fr-cvl",
"subregion_id": "fr-75c",
"latitude": 48.8588897,
"longitude": 2.32004102172,
"country": { "id": "fr", "name": "France", "slug": "france", "continent": "Europe", "continentId": "eu" },
"region": { "id": "fr-cvl", "name": "Centre-Val de Loire", "slug": "centre-val-de-loire", "countryId": "fr" },
"subregion": { "id": "fr-75c", "name": "Paris", "slug": "paris", "regionId": "fr-idf", "countryId": "fr" }
}
}
],
"links": {
"self": "https://api.example/api/cities?country=FR&limit=20",
"first": "https://api.example/api/cities?country=FR&limit=20",
"prev": null,
"next": "https://api.example/api/cities?country=FR&limit=20&offset=20",
"last": "https://api.example/api/cities?country=FR&limit=20&offset=440"
},
"meta": {
"totalHits": 458,
"limit": 20,
"offset": 0,
"count": 20,
"country": "FR"
}
}
  • Réponses d’erreur :
    • 422 Invalid country code / Invalid region code / Invalid subregion code.
    • 503 Search backend unavailable.

Recherche de villes par nom/texte (?q=), par proximité GPS (?lat=&lng=&distance=) ou les deux. Sémantique d’erreur identique à /api/establishments/search et /api/offers/search (geo params mutuellement requis, etc.). Les 3 filtres hiérarchiques (country, region, subregion) restent disponibles ici, combinables avec full-text et géo.

  • Auth : aucune.

  • Action : SearchCitiesAction.

  • Query :

    • q (optionnel) — recherche full-text. searchableAttributes est sur * ⇒ matche notamment slug, names, codes.wikipedia, etc.
    • lat, lng, distancetous les trois ou aucun. Avec eux : filtre _geoRadius(lat, lng, distance) + tri _geoPoint(lat, lng):asc. distance borné par CITY_NEARBY_MAX_DISTANCE_METERS (défaut 50 km).
    • country, region, subregion — mêmes contraintes qu’au listing, cumulables avec full-text et géo (AND).
    • limit (1..50, défaut 20).
    • offset (≥ 0, défaut 0).
  • Pipeline : même formatter partagé + mêmes 3 resolvers d’embed. attributes.distanceMeters rempli quand le tri géo est actif.

  • Réponse 200 OK : structure identique au listing, plus meta.center / meta.distance quand géo, et attributes.distanceMeters par hit.

  • Réponses d’erreur :

    • 422 Incomplete geo parameters — sous-ensemble de lat/lng/distance.
    • 422 Invalid latitude / Invalid longitude / Invalid distance / Distance too large.
    • 422 Invalid country code / Invalid region code / Invalid subregion code.
    • 503 Search backend unavailable.

Pré-requis index Meilisearch :

  • _geo dans filterableAttributes ET sortableAttributes
  • country_id, region_id, subregion_id dans filterableAttributes
  • champs texte recherchables dans searchableAttributes (l’index actuel a * ⇒ ok)

Détail d’une ville par son UUID dashé.

  • Auth : aucune.

  • Action : GetCityAction.

  • Path : {id} — regex stricte [0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}. Le pattern de route Slim porte déjà la regex : tout id mal formé n’atteint pas l’action et renvoie un 404 router.

  • Résolution : filtre Meili exact id = "<uuid>" (l’attribut id est filterable) — pas de full-text, le scorer pourrait surfacer un mauvais doc sur des préfixes d’UUID.

  • Réponse 200 OK : ressource unique cities avec la même shape attributes que les listings (formatter + 3 blocs country/region/subregion partagés).

  • Réponses d’erreur :

    • 404 City not found — id mal formé OU absent de l’index.
    • 503 Search backend unavailable.