---
title: "Pays"
description: "Catalogue ISO 3166 borné (~250 documents) hébergé dans l'index Meilisearch MEILISEARCH_COUNTRIES_INDEX (défaut countries). Lecture seule depuis Meili — auc…"
---

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<string>) — 3-4 arguments succincts donnant envie de visiter.
  - `highlights` (list<string>) — 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.

## `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](../src/Http/Action/Api/Country/ListCountriesAction.php).
- **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`** :

```json
{
  "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.

## `GET /api/countries/search`

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](../src/Http/Action/Api/Country/SearchCountriesAction.php).
- **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`** :

```json
{
  "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 large` — `distance > COUNTRY_NEARBY_MAX_DISTANCE_METERS`.
  - `503 Search backend unavailable`.

## `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](../src/Http/Action/Api/Country/GetCountryAction.php).
- **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`) :

```json
{
  "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)

---
