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 :
- recopie tous les champs du document dans
attributes, sauf les clés Meili-internes (_geo,_geoDistance,_formatted,_matchesPosition,_rankingScore,_rankingScoreDetails) et leidbrut ; - aplatit
_geo: { lat, lng }enattributes.latitude/attributes.longitude; - expose
_geoDistance(mètres) enattributes.distanceMetersquand le tri géo est actif (search en mode géo uniquement) ; - normalise
country_id/region_id/subregion_iden 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 viaCountrySummaryResolvercontre l’indexcountries.region— résolu viaRegionSummaryResolvercontre l’indexregions.subregion— résolu viaSubregionSummaryResolvercontre l’indexsubregions.
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).
GET /api/cities
Section titled “GET /api/cities”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éfaut20, plafond100).offset(int ≥ 0, défaut0).country(ISO 3166-1 alpha-2, casse insensible, normalisée upper côté serveur) — filtrecountry_id.region(ISO 3166-2, 2..7 chars alphanum + tiret, casse insensible) — filtreregion_id.subregion(ISO 3166-2, même contrainte) — filtresubregion_id.
-
Tri : aucun
?sort=n’est exposé. L’indexcitiesn’a que_geoensortableAttributes(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.
GET /api/cities/search
Section titled “GET /api/cities/search”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.searchableAttributesest sur*⇒ matche notammentslug,names,codes.wikipedia, etc.lat,lng,distance— tous les trois ou aucun. Avec eux : filtre_geoRadius(lat, lng, distance)+ tri_geoPoint(lat, lng):asc.distanceborné parCITY_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.distanceMetersrempli quand le tri géo est actif. -
Réponse
200 OK: structure identique au listing, plusmeta.center/meta.distancequand géo, etattributes.distanceMeterspar hit. -
Réponses d’erreur :
422 Incomplete geo parameters— sous-ensemble delat/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 :
_geodansfilterableAttributesETsortableAttributescountry_id,region_id,subregion_iddansfilterableAttributes- champs texte recherchables dans
searchableAttributes(l’index actuel a*⇒ ok)
GET /api/cities/{id}
Section titled “GET /api/cities/{id}”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’attributidest filterable) — pas de full-text, le scorer pourrait surfacer un mauvais doc sur des préfixes d’UUID. -
Réponse
200 OK: ressource uniquecitiesavec la même shapeattributesque les listings (formatter + 3 blocscountry/region/subregionpartagés). -
Réponses d’erreur :
404 City not found— id mal formé OU absent de l’index.503 Search backend unavailable.