---
title: "Régions"
description: "Catalogue ISO 3166-2 non borné (~5 300 documents) hébergé dans l'index Meilisearch MEILISEARCH_REGIONS_INDEX (défaut regions). Lecture seule depuis Meili —…"
---

Catalogue ISO 3166-2 **non borné** (~5 300 documents) hébergé dans l'index Meilisearch `MEILISEARCH_REGIONS_INDEX` (défaut `regions`). Lecture seule depuis Meili — aucun domaine `Region` côté MySQL, l'index est alimenté par un processus externe. Contrairement aux pays, le volume impose une **pagination obligatoire** (les 5 300 entrées ne tiennent pas dans un seul appel raisonnable).

**Identité côté API** : le `id` JSON:API d'une ressource `regions` est le **code ISO 3166-2 en minuscules** (`fr-idf`, `ad-02`, `gb-eng`, …). Les documents Meili stockent le code en majuscules dans le champ `id` (`FR-IDF`, `AD-02`) — Hydrogen normalise à la sérialisation. Les URLs sont **case-insensitive** end-to-end (`/api/regions/fr-idf` ≡ `/api/regions/FR-IDF`).

**Descriptions Markdown** : la ressource détail (`GET /api/regions/{code}`) embarque un attribut `description` dont la valeur est le **contenu brut Markdown** du fichier `resources/lang/<locale>/regions/<code>/description.md`. Le layout est **un dossier par région** (`<code>/`) contenant `description.md` — mêmes propriétés et même fallback que pour les pays. 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` (la région sert quand même, juste sans blurb). Le Markdown n'est **pas rendu côté serveur**.

**Bloc `country` inline** : chaque ressource `regions` (listing, search, détail) embarque un attribut `country` qui est un **sous-ensemble léger** du document pays parent — assez d'info pour afficher "Île-de-France · France · Europe" sans nécessiter un appel à `/api/countries/<code>`. Champs exposés : `id` (ISO alpha-2 minuscule, ex: `fr`), `name`, `slug`, `continent`, `continentId`. Pour la description longue, la liste des fuseaux ou les `official_name` multi-locales, appeler `/api/countries/<code>`. Le bloc résout en **un seul** appel Meili sur l'index `countries` par requête (batch-loader request-scoped), peu importe le nombre de hits. Si le `country_id` d'une région est manquant ou ne résout pas (code orphelin), `country = null`.

> **Garde anti-path-traversal** : le code passe par une regex `^[a-z0-9-]{2,7}$` avant toute lecture disque. Toute tentative d'injection court-circuite la résolution → `description = null`.

## `GET /api/regions`

Listing paginé du catalogue, avec filtre optionnel par pays.

- **Auth** : aucune.
- **Action** : [ListRegionsAction](../src/Http/Action/Api/Region/ListRegionsAction.php).
- **Query** :
  - `limit` (int, défaut `20`, plafond `100`).
  - `offset` (int ≥ 0, défaut `0`).
  - `country` (string, optionnel) — code ISO 3166-1 alpha-2 (2 lettres, case-insensitive). Filtre Meili exact sur `country_id`. Format invalide → `422 Invalid country code`.
- **Pas de `description` dans le listing** : charger des milliers de 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": "regions",
      "id":   "fr-idf",
      "attributes": {
        "name": "Île-de-France",
        "slug": "ile-de-france",
        "type": "Région",
        "codes": { "osm": "8649", "wikidata": "Q13917", "wikipedia": "fr:Île-de-France" },
        "names": { "fr-FR": "Île-de-France", "en-US": "Île-de-France" },
        "native_name": "Île-de-France",
        "alt_names": null,
        "official_names": null,
        "country_id": "FR",
        "stats": { "stats": null, "surface": "12011.4" },
        "latitude": 48.85,
        "longitude": 2.35,
        "country": {
          "id":          "fr",
          "name":        "France",
          "slug":        "france",
          "continent":   "Europe",
          "continentId": "EU"
        }
      }
    }
  ],
  "links": {
    "self":  "https://api.example/api/regions?country=fr&limit=20",
    "first": "https://api.example/api/regions?country=fr&limit=20",
    "prev":  null,
    "next":  "https://api.example/api/regions?country=fr&limit=20&offset=20",
    "last":  "https://api.example/api/regions?country=fr&limit=20&offset=0"
  },
  "meta": {
    "totalHits": 18,
    "limit":     20,
    "offset":    0,
    "count":     18,
    "country":   "FR"
  }
}
```

- **Réponses d'erreur** :
  - `422 Invalid country code` — `country` ne matche pas la regex `[a-zA-Z]{2}`.
  - `503 Search backend unavailable`.

## `GET /api/regions/search`

Trois modes combinables :

1. **Full-text** : `?q=<term>`.
2. **Geo-rayon** : `?lat=&lng=&distance=` (mètres) — les trois ensemble, sinon `422`.
3. **Filtre pays** : `?country=FR` — combinable avec n'importe quel autre mode.

- **Auth** : aucune.
- **Action** : [SearchRegionsAction](../src/Http/Action/Api/Region/SearchRegionsAction.php).
- **Query** :
  - `q` (string, optionnel) — recherche full-text.
  - `lat` (float [-90, 90]), `lng` (float [-180, 180]), `distance` (int positif, mètres) — tous trois requis ensemble pour le mode géo, sinon `422 Incomplete geo parameters`.
  - `country` (string, optionnel) — ISO 3166-1 alpha-2.
  - `limit` (int, défaut `20`, plafond `50`).
  - `offset` (int ≥ 0, défaut `0`).
- **Plafond distance** : `REGION_NEARBY_MAX_DISTANCE_METERS` (défaut **2 000 000 m = 2 000 km**). Dépassement → `422 Distance too large`.
- **Tri géo** : en mode géo, tri par `_geoPoint(lat, lng):asc` (distance croissante). En mode full-text pur, tri par pertinence Meili. **Pré-requis ops** : `_geo` dans `sortableAttributes` de l'index `regions` (NON activé par défaut sur le déploiement, voir bloc Pré-requis en fin de section).

- **Réponse `200 OK`** :

```json
{
  "data": [
    {
      "type": "regions",
      "id":   "fr-idf",
      "attributes": {
        "name":       "Île-de-France",
        "country_id": "FR",
        "country":    { "id": "fr", "name": "France", "slug": "france", "continent": "Europe", "continentId": "EU" },
        "...":        "..."
      }
    }
  ],
  "links": {
    "self":  "https://api.example/api/regions/search?q=ile",
    "first": "https://api.example/api/regions/search?q=ile&limit=20",
    "prev":  null,
    "next":  null,
    "last":  null
  },
  "meta": {
    "totalHits": 4,
    "limit":     20,
    "offset":    0,
    "query":     "ile",
    "country":   "FR",
    "center":    { "lat": 48.85, "lng": 2.35 },
    "distance":  500000
  }
}
```

  > `country`, `center` et `distance` ne sont présents que quand le mode correspondant est actif.

- **Réponses d'erreur** :
  - `422 Invalid country code` — `country` mal formé.
  - `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 > REGION_NEARBY_MAX_DISTANCE_METERS`.
  - `503 Search backend unavailable`.

## `GET /api/regions/{code}`

Détail d'une région par son code ISO 3166-2 (2 à 7 caractères, case-insensitive). Filtre Meili **exact** sur `id = "<UPPER>"` — pas de recherche full-text (le scorer pourrait surfacer la mauvaise subdivision sur des codes de bord).

- **Auth** : aucune.
- **Action** : [GetRegionAction](../src/Http/Action/Api/Region/GetRegionAction.php).
- **Path** :
  - `{code}` — regex `[a-zA-Z0-9-]{2,7}` (accepte `XX`, `XX-YYY`, `XX-99`, …). 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 un attribut `description` chargé depuis `resources/lang/<locale>/regions/<code>/description.md` (voir intro de section).

- **Réponse `200 OK`** (locale `fr-FR`) :

```json
{
  "data": {
    "type": "regions",
    "id":   "fr-idf",
    "attributes": {
      "name":           "Île-de-France",
      "slug":           "ile-de-france",
      "type":           "Région",
      "codes":          { "osm": "8649", "wikidata": "Q13917", "wikipedia": "fr:Île-de-France" },
      "names":          { "fr-FR": "Île-de-France", "en-US": "Île-de-France" },
      "native_name":    "Île-de-France",
      "alt_names":      null,
      "official_names": null,
      "country_id":     "FR",
      "stats":          { "stats": null, "surface": "12011.4" },
      "latitude":       48.85,
      "longitude":      2.35,
      "country":        { "id": "fr", "name": "France", "slug": "france", "continent": "Europe", "continentId": "EU" },
      "description":    "# Île-de-France\n\nL'**Île-de-France** est la région la plus peuplée…"
    }
  }
}
```

- **Réponses d'erreur** :
  - `404 Region 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) — **déjà actif**.
> - `country_id` dans `filterableAttributes` (pour le filtre `?country=`) — **déjà actif**.
> - `_geo` dans `filterableAttributes` (pour `?lat=&lng=&distance=`) — **déjà actif**.
> - `_geo` dans `sortableAttributes` (pour trier par distance dans le mode géo) — **NON activé par défaut** ; sans lui, le filtre `_geoRadius(...)` fonctionne mais le tri par distance retombe sur l'ordre de pertinence Meili.
> - les champs texte recherchables dans `searchableAttributes` (`*` couvre tout sur le déploiement courant).

---
