Skip to content

Points d'intérêt

Triplet /api/pois (list paginé + facettes) / /api/pois/search (full-text + géo + facettes) / /api/pois/{id} (détail) adossé à l’index Meilisearch MEILISEARCH_POIS_INDEX (défaut pois). Catalogue ~210 000 documents dérivés d’OpenStreetMap ⇒ pagination obligatoire sur les listings.

Comme les établissements, Hydrogen n’a pas de domaine Poi côté MySQL : l’index Meilisearch est la source de vérité, alimenté par un ETL externe. Pas de re-hydratation SQL, pas de cache applicatif — chaque hit Meili devient ressource JSON:API directement.

Identité côté API : le id JSON:API d’une ressource pois est le hash hex 32 caractères en minuscules (UUID sans tirets). Les documents source stockent le id en majuscules (AED604B47ADD11F196D500155DDA08DE) ; on normalise vers le lowercase pour l’URL/JSON:API et l’endpoint de détail remet en majuscules avant d’interroger l’index — l’espace d’URL reste insensible à la casse de bout en bout (GET /api/pois/aed6… = GET /api/pois/AED6…).

Shape attributes commune (formatter partagé) : les 3 endpoints utilisent PoiHitFormatter, qui :

  1. recopie tous les champs du document dans attributes, sauf les clés Meili-internes (_geo, _geoDistance, _formatted, _matchesPosition, _rankingScore, _rankingScoreDetails) et le id brut (passe en JSON:API id) ;
  2. aplatit _geo: { lat, lng } en attributes.latitude / attributes.longitude ;
  3. expose _geoDistance (mètres) en attributes.distanceMeters quand le tri géo est actif (search en mode géo uniquement).

Champs métier exposés tels quels depuis l’index : osm_id, osm_type, name, alt_names[], names{}, category, subcategories[], address, postcode, la hiérarchie country_id/region_id/subregion_id/city_id + leurs libellés dénormalisés country/region/subregion/city, importance, has_geo, attributes{} (tags OSM bruts), et les champs optionnels opening_hours, phone, website, wikidata, wikipedia. Leur format est défini par le pipeline d’alimentation, pas par Hydrogen.

Pas de description Markdown ni d’assets statiques ici — contrairement aux pays/régions/sous-régions (blurb éditorial) ou aux établissements (carousel images + description), les POIs sont dérivés d’OpenStreetMap et ne portent aucun asset on-disk : le document Meili est la ressource entière.

Les deux endpoints de listing partagent le même jeu de filtres, cumulables (composés en AND côté Meili) et validés à l’identique via PoiFilters. Chaque valeur est normalisée à la casse stockée dans l’index avant d’être injectée dans le filtre :

QueryChamp MeiliFormat attenduExemple
categorycategoryslug minuscule ([a-z0-9_-]{1,50})transport
countrycountry_idISO 3166-1 alpha-2fr
regionregion_idISO 3166-2fr-ara
subregionsubregion_idISO 3166-2fr-38
citycity_idhex 32676584c2…
hasGeohas_geotrue / false (1 / 0)true

Toute valeur mal formée renvoie un 422 ciblé (Invalid category / Invalid country code / Invalid region code / Invalid subregion code / Invalid city id / Invalid hasGeo) avec le pointer correspondant. Les filtres appliqués sont ré-échoés (normalisés) dans meta.

Listing paginé du catalogue, sans filtre full-text ni géo (pour ça, voir /api/pois/search), avec les facettes ci-dessus.

  • Auth : aucune.

  • Action : ListPoisAction.

  • Query :

    • limit (int, défaut 20, plafond 100).
    • offset (int ≥ 0, défaut 0).
    • sort (string, optionnel) — whitelist : importance / -importance (= défaut, desc), importance_asc, name (asc), -name (desc). Valeur hors whitelist → 422 Invalid sort.
    • facettes : category, country, region, subregion, city, hasGeo (cf. section précédente).
  • Tri par défaut : importance:desc — les POIs les plus notables en haut.

  • Réponse 200 OK :

{
"data": [
{
"type": "pois",
"id": "aed604b47add11f196d500155dda08de",
"attributes": {
"osm_id": 136221,
"osm_type": "node",
"name": "Chavant",
"alt_names": [],
"names": { "default": "Chavant" },
"category": "transport",
"subcategories": ["tramway_station"],
"address": null,
"postcode": null,
"country_id": "FR",
"region_id": "FR-ARA",
"subregion_id": "FR-38",
"city_id": "676584C252B711F196D500155DDA08DE",
"country": "France",
"region": "Auvergne-Rhône-Alpes",
"subregion": "Isère",
"city": "Grenoble",
"importance": 0.0,
"has_geo": true,
"attributes": { "access": { "tram": "yes", "railway": "tram_stop" } },
"latitude": 45.1845327,
"longitude": 5.7317282
}
}
],
"links": {
"self": "https://api.example/api/pois?category=transport&limit=20",
"first": "https://api.example/api/pois?category=transport&limit=20",
"prev": null,
"next": "https://api.example/api/pois?category=transport&limit=20&offset=20",
"last": "https://api.example/api/pois?category=transport&limit=20&offset=980"
},
"meta": {
"totalHits": 12043,
"limit": 20,
"offset": 0,
"count": 20,
"sort": "importance:desc",
"category": "transport"
}
}
  • Réponses d’erreur :
    • 422 Invalid sort — valeur de sort hors whitelist.
    • 422 Invalid … — valeur de facette mal formée (cf. section Facettes).
    • 503 Search backend unavailable.

Recherche de POIs par nom (?q=…), par proximité GPS (?lat=…&lng=…&distance=…), par facettes, ou toute combinaison des trois.

  • Auth : aucune (endpoint public).

  • Action : SearchPoisAction.

  • Query :

    • q (optionnel) — recherche full-text (searchable : name, alt_names, names, city, category, subcategories, region, country, address, postcode). Sans q, Meilisearch retourne tous les documents (filtrés par facette/géo si présent).
    • lat, lng, distancetous les trois ou aucun. Fournir l’un sans les autres ⇒ 422 Incomplete geo parameters. Avec eux, le filtre _geoRadius(lat, lng, distance) s’applique ET le tri bascule sur _geoPoint(lat, lng):asc (du plus proche au plus éloigné).
      • lat : float dans [-90, 90].
      • lng : float dans [-180, 180].
      • distance : entier positif (mètres), borné par POI_NEARBY_MAX_DISTANCE_METERS (défaut 50 km). Au-delà → 422 Distance too large.
    • facettes : category, country, region, subregion, city, hasGeo (cumulables avec q et/ou le géo).
    • limit (1..50, défaut 20).
    • offset (≥0, défaut 0).
  • Réponse 200 OK :

{
"data": [
{
"type": "pois",
"id": "aed604b47add11f196d500155dda08de",
"attributes": {
"name": "Chavant",
"category": "transport",
"subcategories": ["tramway_station"],
"country_id": "FR",
"city": "Grenoble",
"importance": 0.0,
"has_geo": true,
"latitude": 45.1845327,
"longitude": 5.7317282,
"distanceMeters": 214.8
}
}
],
"links": {
"self": "https://api.example/api/pois/search?q=chavant&lat=45.18&lng=5.73&distance=2000&limit=20",
"first": "https://api.example/api/pois/search?q=chavant&lat=45.18&lng=5.73&distance=2000&limit=20",
"prev": null,
"next": null,
"last": null
},
"meta": {
"totalHits": 3,
"limit": 20,
"offset": 0,
"query": "chavant",
"category": "transport",
"center": { "lat": 45.18, "lng": 5.73 },
"distance": 2000
}
}
  • meta.totalHits : estimé par Meilisearch (sémantique offset).

  • meta.center et meta.distance ne sont présents que si le mode géo est actif.

  • attributes.distanceMeters n’est présent que sur les hits issus d’un tri _geoPoint:asc (mode géo).

  • Les clés de facettes (category, country, …) ne sont présentes dans meta que si le filtre correspondant a été fourni.

  • Pagination : offset-based, navigation via links.{self,first,prev,next,last}.

  • Réponses d’erreur :

    • 422 Incomplete geo parameters — un seul de lat/lng/distance est fourni (ou deux sur trois).
    • 422 Invalid latitude / Invalid longitude / Invalid distance — valeurs hors plage ou non numériques.
    • 422 Distance too largedistance > POI_NEARBY_MAX_DISTANCE_METERS.
    • 422 Invalid … — valeur de facette mal formée.
    • 503 Search backend unavailable — Meilisearch injoignable, index manquant, ou pré-requis index non satisfaits. Le détail Meili est propagé dans errors[0].detail.

Pré-requis index Meilisearch (ops one-shot) :

  • _geo dans filterableAttributes ET sortableAttributes (mode géo)
  • category, subcategories, country_id, region_id, subregion_id, city_id, has_geo dans filterableAttributes (facettes)
  • importance, name dans sortableAttributes (tri du listing)
  • champs textuels (name, alt_names, …) dans searchableAttributes

Détail d’un POI par son id hex.

  • Auth : aucune.

  • Action : GetPoiAction.

  • Path :

    • {id} — regex [a-fA-F0-9]{32} (insensible à la casse). L’action met en majuscules pour interroger l’index (ids stockés upper) et renvoie le JSON:API id en minuscules.
  • Résolution : filtre Meili exact id = "<HEX_UPPER>" sur l’index (l’attribut id est filterable) — pas de recherche full-text.

  • Réponse 200 OK :

{
"data": {
"type": "pois",
"id": "aed604b47add11f196d500155dda08de",
"attributes": {
"osm_id": 136221,
"osm_type": "node",
"name": "Chavant",
"category": "transport",
"subcategories": ["tramway_station"],
"country_id": "FR",
"region_id": "FR-ARA",
"subregion_id": "FR-38",
"city_id": "676584C252B711F196D500155DDA08DE",
"country": "France",
"region": "Auvergne-Rhône-Alpes",
"subregion": "Isère",
"city": "Grenoble",
"importance": 0.0,
"has_geo": true,
"attributes": { "access": { "tram": "yes", "railway": "tram_stop" } },
"latitude": 45.1845327,
"longitude": 5.7317282
}
}
}
  • Réponses d’erreur :
    • 404 POI not found — id mal formé (n’atteint pas l’action, le router 404e via la regex) OU id valide mais absent de l’index.
    • 503 Search backend unavailable.