Pays
Catalogue ISO 3166 borné (~250 documents) hébergé dans l’index Meilisearch MEILISEARCH_COUNTRIES_INDEX (défaut countries). Lecture seule depuis Meili — aucun domaine Country côté MySQL, l’index est alimenté par un processus externe.
Identité côté API : le id JSON:API d’une ressource countries est le code ISO 3166-2 en minuscules (fr, us, ad, …). Les documents Meili stockent le code en majuscules dans le champ id (FR, US) — Hydrogen normalise à la sérialisation. Les URLs sont case-insensitive end-to-end (/api/countries/fr ≡ /api/countries/FR).
Descriptions Markdown : la ressource détail (GET /api/countries/{code}) embarque un attribut description dont la valeur est le contenu brut Markdown du fichier resources/lang/<locale>/countries/<code>/description.md. Le layout est un dossier par pays (<code>/) contenant description.md — cela laisse la place pour ajouter d’autres ressources éditoriales par pays plus tard (faq.md, highlights.md, dossier images/, …) sans casser les URLs de description. La locale est résolue par LocaleResolverMiddleware (header Accept-Language ou param). Si le fichier n’existe pas pour la locale courante, fallback sur la locale SupportedLocales::DEFAULT ; si aucune des deux n’existe, description = null (le pays sert quand même, juste sans blurb). Le Markdown n’est pas rendu côté serveur — chaque surface (web, mobile, BO preview) applique son propre sanitizer.
Garde anti-path-traversal : le code passe par une regex
^[a-z]{2,3}$avant toute lecture disque. Toute tentative d’injection (../,\0, etc.) court-circuite la résolution →description = null.
GET /api/countries
Section titled “GET /api/countries”Retourne tout le catalogue en un seul appel — pas de pagination, pas de limit/offset exposés au client.
-
Auth : aucune.
-
Action : ListCountriesAction.
-
Pipeline : un seul appel Meili avec
limit = COUNTRIES_MAX_RESULTS(défaut 1000),q = ""(ordre naturel de l’index). -
Pas de
descriptiondans le listing : charger 250 fichiers Markdown sur un endpoint de catalogue est gaspilleur. Le blurb n’est disponible que sur le détail. -
Réponse
200 OK:
{ "data": [ { "type": "countries", "id": "fr", "attributes": { "name": "France", "slug": "france", "codes": { "iso_3166_2": "FR", "iso_3166_3": "FRA", "wikipedia": null }, "region": "Western Europe", "continent": "Europe", "continent_id": "EU", "status": "officially-assigned", "stats": { "surface": "549393.44", "stats": null }, "timezones": { "utc": ["+01:00", "+02:00"] }, "official_name": { "fr-FR": "République française", "en-US": "French Republic" }, "latitude": 46, "longitude": 2 } } ], "links": { "self": "https://api.example/api/countries" }, "meta": { "totalHits": 249, "count": 249 }}- Garde-fou catalogue : si
meta.totalHits > COUNTRIES_MAX_RESULTS, la réponse ajoutemeta.truncated = true+meta.maxResults = <cap>. - Réponses d’erreur :
503 Search backend unavailable— Meilisearch injoignable.
GET /api/countries/search
Section titled “GET /api/countries/search”Même schéma que /api/establishments/search : trois modes.
- Full-text :
?q=<term>. - Geo-rayon :
?lat=&lng=&distance=(mètres) — les trois ensemble, sinon422. - Combiné :
q+lat+lng+distance.
-
Auth : aucune.
-
Action : SearchCountriesAction.
-
Query :
q(string, optionnel) — recherche full-text.lat(float [-90, 90]),lng(float [-180, 180]),distance(int positif, mètres) — tous trois requis pour le mode géo, sinon422 Incomplete geo parameters.limit(int, défaut20, plafond50).offset(int ≥ 0, défaut0).
-
Plafond distance :
COUNTRY_NEARBY_MAX_DISTANCE_METERS(défaut 5 000 000 m = 5 000 km). Les coordonnées pays sont des centroïdes à l’échelle continentale, un cap serré n’aurait pas de sens. Dépassement →422 Distance too large. -
Tri géo : en mode géo, tri par
_geoPoint(lat, lng):asc(distance croissante depuis le centre). En mode full-text pur, tri par pertinence Meili. -
Réponse
200 OK:
{ "data": [ { "type": "countries", "id": "fr", "attributes": { "name": "France", "...": "..." } } ], "links": { "self": "https://api.example/api/countries/search?q=france", "first": "https://api.example/api/countries/search?q=france&limit=20", "prev": null, "next": null, "last": null }, "meta": { "totalHits": 1, "limit": 20, "offset": 0, "query": "france", "center": { "lat": 48.85, "lng": 2.35 }, "distance": 1000000 }}Les attributs
centeretdistancene sont présents qu’en mode géo.
- Réponses d’erreur :
422 Incomplete geo parameters— un deslat/lng/distancefourni mais pas les autres.422 Invalid latitude/Invalid longitude/Invalid distance— valeurs hors bornes ou non numériques.422 Distance too large—distance > COUNTRY_NEARBY_MAX_DISTANCE_METERS.503 Search backend unavailable.
GET /api/countries/{code}
Section titled “GET /api/countries/{code}”Détail d’un pays par son code ISO 3166-2 (2 ou 3 caractères, case-insensitive). Filtre Meili exact sur id = "<UPPER>" — pas de recherche full-text (le scorer pourrait surfacer le mauvais continent sur des codes de bord).
-
Auth : aucune.
-
Action : GetCountryAction.
-
Path :
{code}— regex[a-zA-Z]{2,3}. Casse normalisée à la résolution (majuscules pour Meili, minuscules pour leidJSON:API). Format invalide →404.
-
Différence clé avec le listing : ajoute un attribut
descriptionchargé depuisresources/lang/<locale>/countries/<code>.md(voir intro de section). -
Réponse
200 OK(localefr-FR) :
{ "data": { "type": "countries", "id": "fr", "attributes": { "name": "France", "slug": "france", "codes": { "iso_3166_2": "FR", "iso_3166_3": "FRA", "wikipedia": null }, "region": "Western Europe", "continent": "Europe", "continent_id": "EU", "status": "officially-assigned", "stats": { "surface": "549393.44", "stats": null }, "timezones": { "utc": ["+01:00", "+02:00"] }, "official_name": { "fr-FR": "République française", "en-US": "French Republic" }, "latitude": 46, "longitude": 2, "description": "# France\n\nLa **France**, officiellement la *République française*, …" } }}- Réponses d’erreur :
404 Country not found— code invalide (regex non matchée) OU code valide mais aucun document Meili correspondant.503 Search backend unavailable.
Pré-requis index Meilisearch (one-shot ops) :
iddansfilterableAttributes(pour le lookup exact du détail)_geodansfilterableAttributesETsortableAttributes(pour?lat=&lng=&distance=)- les champs texte recherchables (
name,slug, …) danssearchableAttributes(*couvre tout sur le déploiement courant)