Skip to content

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.

Enrichissement éditorial du détail : en plus de description, GET /api/countries/{code} (et sa surcouche admin) embarque deux attributs supplémentaires :

  • cover — URL absolue de l’image de couverture du pays, résolue sur le mount statique partagé à country/<code>/cover.webp. Si le pays n’a pas de couverture propre, un fallback partagé country/cover-default.webp est renvoyé (jamais null — le client a toujours une image à afficher). C’est un asset binaire non-localisé (une photo de pays est la même quelle que soit la langue). Chemins pilotés par COUNTRY_STATIC_PATH / COUNTRY_STATIC_PUBLIC_URL (défaut = mount avatars AVATAR_STORAGE_PATH / AVATAR_PUBLIC_URL). Même garde regex ^[a-z]{2,3}$ avant tout stat.

  • content — bloc de textes traduisibles chargé depuis resources/lang/<locale>/countries/<code>/content.php (sibling de description.md, même résolution de locale + fallback + garde path-traversal). null si aucun fichier n’existe pour la locale ni le fallback. Structure :

    • tagline (string) — phrase d’accroche.
    • arguments (list) — 3-4 arguments succincts donnant envie de visiter.
    • highlights (list) — les choses importantes à voir (bullet points).
    • bestTimeToVisit (object) — summary (string, texte lisible météo/ambiance) + seasons (list) où chaque saison porte key (jeton stable non-traduit spring|summer|autumn|winter pour l’ordre/les icônes côté client), label, months, weather, ambiance (localisés) et recommended (bool).

    Chaque clé de content est optionnelle et tolérante aux fautes : une entrée malformée dans le fichier est silencieusement ignorée (le champ concerné devient absent) plutôt que de faire échouer tout le détail.

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 description dans 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 ajoute meta.truncated = true + meta.maxResults = <cap>.
  • Réponses d’erreur :
    • 503 Search backend unavailable — Meilisearch injoignable.

Même schéma que /api/establishments/search : trois modes.

  1. Full-text : ?q=<term>.
  2. Geo-rayon : ?lat=&lng=&distance= (mètres) — les trois ensemble, sinon 422.
  3. 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, sinon 422 Incomplete geo parameters.
    • limit (int, défaut 20, plafond 50).
    • offset (int ≥ 0, défaut 0).
  • 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 center et distance ne sont présents qu’en mode géo.

  • Réponses d’erreur :
    • 422 Incomplete geo parameters — un des lat/lng/distance fourni mais pas les autres.
    • 422 Invalid latitude / Invalid longitude / Invalid distance — valeurs hors bornes ou non numériques.
    • 422 Distance too largedistance > COUNTRY_NEARBY_MAX_DISTANCE_METERS.
    • 503 Search backend unavailable.

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 le id JSON:API). Format invalide → 404.
  • Différence clé avec le listing : ajoute les attributs description, cover et content (voir intro de section) — absents du listing pour ne pas charger 250 fichiers par appel de catalogue.

  • Réponse 200 OK (locale fr-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*, …",
"cover": "http://hexatrip-static.dev.com/country/fr/cover.webp",
"content": {
"tagline": "De la lumière de la Méditerranée aux sommets des Alpes.",
"arguments": [
"Une diversité de paysages unique : littoraux, montagnes, vignobles et villages perchés.",
"Une richesse culturelle inégalée — musées de rang mondial, patrimoine et gastronomie.",
"Des transports faciles : TGV, réseau routier dense et aéroports internationaux."
],
"highlights": [
"Paris et le musée du Louvre",
"Le Mont-Saint-Michel",
"Les châteaux de la Loire"
],
"bestTimeToVisit": {
"summary": "La France se visite toute l’année. Le printemps et l’automne offrent un climat doux…",
"seasons": [
{ "key": "spring", "label": "Printemps", "months": "Mars – Mai", "weather": "Températures douces…", "ambiance": "Terrasses qui rouvrent…", "recommended": true },
{ "key": "winter", "label": "Hiver", "months": "Décembre – Février", "weather": "Froid, neige en montagne…", "ambiance": "Ski dans les Alpes…", "recommended": false }
]
}
}
}
}
}

cover est toujours présent (fallback country/cover-default.webp si le pays n’a pas de couverture). content vaut null si aucun fichier content.php n’existe pour la locale ni le fallback.

  • 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) :

  • id dans filterableAttributes (pour le lookup exact du détail)
  • _geo dans filterableAttributes ET sortableAttributes (pour ?lat=&lng=&distance=)
  • les champs texte recherchables (name, slug, …) dans searchableAttributes (* couvre tout sur le déploiement courant)