diff --git a/README.md b/README.md index 84c5973..df82daa 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,7 @@ L'idée est ici de répondre à des questions précises en traitant côté serve > Ex: Quelles assiettes de SUP sont présentes autour de la mairie de Vincennes ? -Les tools WFS orientés "objet" (`adminexpress`, `cadastre`, `urbanisme`, `assiette_sup`) exposent un `feature_ref { typename, feature_id }` quand l'objet source est réutilisable tel quel dans un appel ultérieur à `gpf_wfs_get_feature_by_id` ou `gpf_wfs_get_features` (ex : `spatial_operator="intersects_feature"`). +Les tools WFS orientés "objet" (`adminexpress`, `cadastre`, `urbanisme`, `assiette_sup`) exposent un `feature_ref { typename, feature_id }` quand l'objet source est réutilisable tel quel dans un appel ultérieur à `gpf_wfs_get_feature_by_id` ou `gpf_wfs_get_features` (ex : `spatial_filter={ type: "intersects_feature", feature_ref: ... }`). ### Explorer les données vecteurs @@ -236,15 +236,15 @@ Le tool accepte un contrat structuré : - `select` pour choisir les propriétés à renvoyer - `where` pour filtrer les objets - `order_by` pour trier les résultats -- `spatial_operator` et ses paramètres dédiés pour le spatial +- `spatial_filter` pour le spatial - `result_type="request"` pour récupérer la requête compilée en `POST`, ainsi qu'une `get_url` dérivée quand elle reste raisonnablement portable en GET Exemples : - `where=[{ property: "code_insee", operator: "eq", value: "25000" }]` -- `spatial_operator="bbox"` avec `bbox_west`, `bbox_south`, `bbox_east`, `bbox_north` -- `spatial_operator="dwithin_point"` avec `dwithin_lon`, `dwithin_lat`, `dwithin_distance_m` -- `spatial_operator="intersects_feature"` avec `intersects_feature_typename` et `intersects_feature_id` issus d'une `feature_ref` +- `spatial_filter={ type: "bbox", bbox: { west, south, east, north } }` +- `spatial_filter={ type: "dwithin_point", point: { lon, lat }, distance_m }` +- `spatial_filter={ type: "intersects_feature", feature_ref: { typename, feature_id } }` issu d'une `feature_ref` > - Quelles sont les 5 communes les plus peuplées du Doubs (25)? > - Combien y a-t-il de bâtiments à moins de 5 km de la tour Eiffel? diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 3fe0a55..84c32de 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -66,7 +66,7 @@ Title: Unités administratives - Renvoie, pour un point donné par sa `longitude` et sa `latitude`, la liste des unités administratives (arrondissement, arrondissement_municipal, canton, collectivite_territoriale, commune, commune_associee_ou_deleguee, departement, epci, region) qui le couvrent, sous forme d'objets typés contenant leurs propriétés administratives. - Les résultats incluent un `feature_ref` WFS réutilisable. Les propriétés incluent notamment le code INSEE. -- Le `feature_ref` de chaque unité administrative est directement réutilisable dans `gpf_wfs_get_features` avec `spatial_operator="intersects_feature"` pour interroger d'autres données sur cette emprise. +- Le `feature_ref` de chaque unité administrative est directement réutilisable dans `gpf_wfs_get_features` avec `spatial_filter={ type: "intersects_feature", feature_ref: ... }` pour interroger d'autres données sur cette emprise. - Pour récupérer exactement l'objet correspondant au `feature_ref`, utiliser `gpf_wfs_get_feature_by_id`. - (source : Géoplateforme (WFS, ADMINEXPRESS-COG.LATEST)). @@ -142,7 +142,7 @@ Title: Unités administratives }, "feature_ref": { "type": "object", - "description": "Référence WFS réutilisable, notamment avec `gpf_wfs_get_features` et `spatial_operator = \"intersects_feature\"`.", + "description": "Référence WFS réutilisable, notamment avec `gpf_wfs_get_features` et `spatial_filter = { type: \"intersects_feature\", feature_ref: ... }`.", "properties": { "typename": { "type": "string", @@ -357,7 +357,7 @@ Title: Servitudes d’utilité publique }, "feature_ref": { "type": "object", - "description": "Référence WFS réutilisable, notamment avec `gpf_wfs_get_features` et `spatial_operator = \"intersects_feature\"`.", + "description": "Référence WFS réutilisable, notamment avec `gpf_wfs_get_features` et `spatial_filter = { type: \"intersects_feature\", feature_ref: ... }`.", "properties": { "typename": { "type": "string", @@ -404,7 +404,7 @@ Title: Informations cadastrales - Renvoie, pour un point donné par sa `longitude` et sa `latitude`, la liste des objets cadastraux (arrondissement, commune, feuille, parcelle, subdivision_fiscale, localisant) les plus proches, avec leurs informations associées. - Les résultats sont retournés au plus une fois par type lorsqu'ils sont disponibles et incluent un `feature_ref` WFS réutilisable. -- Le `feature_ref` est directement réutilisable dans `gpf_wfs_get_features` avec `spatial_operator="intersects_feature"`. +- Le `feature_ref` est directement réutilisable dans `gpf_wfs_get_features` avec `spatial_filter={ type: "intersects_feature", feature_ref: ... }`. - La distance de recherche est fixée à 10 mètres. Si aucun objet n'est trouvé dans les 10 mètres, le résultat est vide. - Pour récupérer exactement l'objet correspondant au `feature_ref`, utiliser `gpf_wfs_get_feature_by_id`. - (source : Géoplateforme (WFS, CADASTRALPARCELS.PARCELLAIRE_EXPRESS)). @@ -481,7 +481,7 @@ Title: Informations cadastrales }, "feature_ref": { "type": "object", - "description": "Référence WFS réutilisable, notamment avec `gpf_wfs_get_features` et `spatial_operator = \"intersects_feature\"`.", + "description": "Référence WFS réutilisable, notamment avec `gpf_wfs_get_features` et `spatial_filter = { type: \"intersects_feature\", feature_ref: ... }`.", "properties": { "typename": { "type": "string", @@ -848,13 +848,13 @@ Title: Lecture d’objets WFS ### Description du tool - Interroge un type WFS et renvoie des résultats structurés sans demander au modèle d'écrire du CQL ou du WFS. -- Utiliser `select` pour choisir les propriétés, `where` pour filtrer, `order_by` pour trier et `spatial_operator` avec ses paramètres dédiés pour le spatial. Avec `result_type="request"`, la géométrie est automatiquement ajoutée aux propriétés sélectionnées pour garantir une requête cartographiable. +- Utiliser `select` pour choisir les propriétés, `where` pour filtrer, `order_by` pour trier et `spatial_filter` pour le spatial. Avec `result_type="request"`, la géométrie est automatiquement ajoutée aux propriétés sélectionnées pour garantir une requête cartographiable. - Exemple attributaire : `where=[{ property: "code_insee", operator: "eq", value: "75056" }]`. -- Exemple bbox : `spatial_operator="bbox"` avec `bbox_west`, `bbox_south`, `bbox_east`, `bbox_north` en `lon/lat`. -- Exemple point dans géométrie : `spatial_operator="intersects_point"` avec `intersects_lon` et `intersects_lat`. -- Exemple distance : `spatial_operator="dwithin_point"` avec `dwithin_lon`, `dwithin_lat`, `dwithin_distance_m`. -- Exemple réutilisation : `spatial_operator="intersects_feature"` avec `intersects_feature_typename` et `intersects_feature_id` issus d'une `feature_ref`. -- ⚠️ Quand `typename` et `intersects_feature_typename` sont identiques, utiliser `gpf_wfs_get_feature_by_id` pour récupérer exactement l'objet ciblé. +- Exemple bbox : `spatial_filter={ type: "bbox", bbox: { west: 2.29, south: 48.85, east: 2.3, north: 48.86 } }`. +- Exemple point dans géométrie : `spatial_filter={ type: "intersects_point", point: { lon: 2.29424, lat: 48.858264 } }`. +- Exemple distance : `spatial_filter={ type: "dwithin_point", point: { lon: 2.29424, lat: 48.858264 }, distance_m: 50 }`. +- Exemple réutilisation : `spatial_filter={ type: "intersects_feature", feature_ref: { typename: "ADMINEXPRESS-COG.LATEST:commune", feature_id: "commune.29458" } }`. +- ⚠️ Quand `typename` et `spatial_filter.feature_ref.typename` sont identiques, utiliser `gpf_wfs_get_feature_by_id` pour récupérer exactement l'objet ciblé. - **OBLIGATOIRE : toujours appeler `gpf_wfs_describe_type` avant ce tool, sauf si `gpf_wfs_describe_type` a déjà été appelé pour ce même typename dans la conversation en cours.** - Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiques à chaque typename et diffèrent systématiquement des conventions habituelles (ex : pas de nom_officiel, navigabilite sans accent, etc.). Toute tentative sans appel préalable à `gpf_wfs_describe_type` **provoquera une erreur.** @@ -862,22 +862,11 @@ Title: Lecture d’objets WFS | Field | Type | Required | Description | | --- | --- | --- | --- | -| `bbox_east` | number | no | Longitude est en WGS84 `lon/lat`, utilisée avec `spatial_operator = "bbox"`. | -| `bbox_north` | number | no | Latitude nord en WGS84 `lon/lat`, utilisée avec `spatial_operator = "bbox"`. | -| `bbox_south` | number | no | Latitude sud en WGS84 `lon/lat`, utilisée avec `spatial_operator = "bbox"`. | -| `bbox_west` | number | no | Longitude ouest en WGS84 `lon/lat`, utilisée avec `spatial_operator = "bbox"`. | -| `dwithin_distance_m` | number | no | Distance en mètres, utilisée avec `spatial_operator = "dwithin_point"`. | -| `dwithin_lat` | number | no | Latitude du point en WGS84 `lon/lat`, utilisée avec `spatial_operator = "dwithin_point"`. | -| `dwithin_lon` | number | no | Longitude du point en WGS84 `lon/lat`, utilisée avec `spatial_operator = "dwithin_point"`. | -| `intersects_feature_id` | string | no | Identifiant du feature de référence, utilisé avec `spatial_operator = "intersects_feature"`. | -| `intersects_feature_typename` | string | no | Type WFS du feature de référence, utilisé avec `spatial_operator = "intersects_feature"`. | -| `intersects_lat` | number | no | Latitude du point en WGS84 `lon/lat`, utilisée avec `spatial_operator = "intersects_point"`. | -| `intersects_lon` | number | no | Longitude du point en WGS84 `lon/lat`, utilisée avec `spatial_operator = "intersects_point"`. | | `limit` | integer | no | Nombre maximum d'objets à renvoyer. Valeur par défaut : 100. Maximum : 5000. Default: 100. | | `order_by` | array | no | Liste ordonnée des critères de tri. | | `result_type` | string | no | `results` renvoie une FeatureCollection avec les propriétés attributaires uniquement — **les géométries ne sont pas incluses**, ce mode ne peut donc pas être utilisé directement pour cartographier. `hits` renvoie uniquement le nombre total d'objets correspondant à la requête. `request` renvoie l'URL WFS compilée (`get_url`) à destination de `create_map` via `geojson_url`, ou pour déboguer la requête générée. **La géométrie est automatiquement ajoutée aux propriétés du `select`** pour garantir l'affichage cartographique. Values: results, hits, request. Default: results. | | `select` | array | no | Liste des propriétés non géométriques à renvoyer pour chaque objet. Utiliser `gpf_wfs_describe_type` pour connaître les noms exacts disponibles. Exemple : `["code_insee", "nom_officiel"]`. | -| `spatial_operator` | string | no | Type optionnel de filtre spatial. Values: bbox, intersects_point, dwithin_point, intersects_feature. | +| `spatial_filter` | anyOf | no | Filtre spatial optionnel. Variantes supportees : `{ type: "bbox", bbox: { west, south, east, north } }`, `{ type: "intersects_point", point: { lon, lat } }`, `{ type: "dwithin_point", point: { lon, lat }, distance_m }`, `{ type: "intersects_feature", feature_ref: { typename, feature_id } }`. | | `typename` | string | yes | Nom exact du type WFS à interroger, par exemple `BDTOPO_V3:batiment`. Utiliser `gpf_wfs_search_types` pour trouver un `typename` valide. | | `where` | array | no | Clauses de filtre attributaire, combinées avec `AND`. | @@ -995,78 +984,160 @@ Title: Lecture d’objets WFS "minItems": 1, "description": "Clauses de filtre attributaire, combinées avec `AND`." }, - "spatial_operator": { - "type": "string", - "enum": [ - "bbox", - "intersects_point", - "dwithin_point", - "intersects_feature" + "spatial_filter": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "bbox" + }, + "bbox": { + "type": "object", + "properties": { + "west": { + "type": "number", + "minimum": -180, + "maximum": 180, + "description": "Longitude ouest en WGS84 `lon/lat`." + }, + "south": { + "type": "number", + "minimum": -90, + "maximum": 90, + "description": "Latitude sud en WGS84 `lon/lat`." + }, + "east": { + "type": "number", + "minimum": -180, + "maximum": 180, + "description": "Longitude est en WGS84 `lon/lat`." + }, + "north": { + "type": "number", + "minimum": -90, + "maximum": 90, + "description": "Latitude nord en WGS84 `lon/lat`." + } + }, + "required": [ + "west", + "south", + "east", + "north" + ], + "additionalProperties": false, + "description": "Boite englobante en WGS84 `lon/lat`." + } + }, + "required": [ + "type", + "bbox" + ], + "additionalProperties": false, + "description": "Filtre spatial par boite englobante." + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "intersects_point" + }, + "point": { + "type": "object", + "properties": { + "lon": { + "type": "number", + "minimum": -180, + "maximum": 180, + "description": "Longitude du point en WGS84 `lon/lat`." + }, + "lat": { + "type": "number", + "minimum": -90, + "maximum": 90, + "description": "Latitude du point en WGS84 `lon/lat`." + } + }, + "required": [ + "lon", + "lat" + ], + "additionalProperties": false, + "description": "Point en WGS84 `lon/lat`." + } + }, + "required": [ + "type", + "point" + ], + "additionalProperties": false, + "description": "Filtre spatial par intersection avec un point." + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "dwithin_point" + }, + "point": { + "$ref": "#/properties/spatial_filter/anyOf/1/properties/point" + }, + "distance_m": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Distance en metres." + } + }, + "required": [ + "type", + "point", + "distance_m" + ], + "additionalProperties": false, + "description": "Filtre spatial par distance autour d'un point." + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "intersects_feature" + }, + "feature_ref": { + "type": "object", + "properties": { + "typename": { + "type": "string", + "minLength": 1, + "description": "Type WFS du feature de référence." + }, + "feature_id": { + "type": "string", + "minLength": 1, + "description": "Identifiant du feature de référence." + } + }, + "required": [ + "typename", + "feature_id" + ], + "additionalProperties": false, + "description": "Référence légère vers un feature WFS réutilisable." + } + }, + "required": [ + "type", + "feature_ref" + ], + "additionalProperties": false, + "description": "Filtre spatial par intersection avec un feature de reference." + } ], - "description": "Type optionnel de filtre spatial." - }, - "bbox_west": { - "type": "number", - "minimum": -180, - "maximum": 180, - "description": "Longitude ouest en WGS84 `lon/lat`, utilisée avec `spatial_operator = \"bbox\"`." - }, - "bbox_south": { - "type": "number", - "minimum": -90, - "maximum": 90, - "description": "Latitude sud en WGS84 `lon/lat`, utilisée avec `spatial_operator = \"bbox\"`." - }, - "bbox_east": { - "type": "number", - "minimum": -180, - "maximum": 180, - "description": "Longitude est en WGS84 `lon/lat`, utilisée avec `spatial_operator = \"bbox\"`." - }, - "bbox_north": { - "type": "number", - "minimum": -90, - "maximum": 90, - "description": "Latitude nord en WGS84 `lon/lat`, utilisée avec `spatial_operator = \"bbox\"`." - }, - "intersects_lon": { - "type": "number", - "minimum": -180, - "maximum": 180, - "description": "Longitude du point en WGS84 `lon/lat`, utilisée avec `spatial_operator = \"intersects_point\"`." - }, - "intersects_lat": { - "type": "number", - "minimum": -90, - "maximum": 90, - "description": "Latitude du point en WGS84 `lon/lat`, utilisée avec `spatial_operator = \"intersects_point\"`." - }, - "dwithin_lon": { - "type": "number", - "minimum": -180, - "maximum": 180, - "description": "Longitude du point en WGS84 `lon/lat`, utilisée avec `spatial_operator = \"dwithin_point\"`." - }, - "dwithin_lat": { - "type": "number", - "minimum": -90, - "maximum": 90, - "description": "Latitude du point en WGS84 `lon/lat`, utilisée avec `spatial_operator = \"dwithin_point\"`." - }, - "dwithin_distance_m": { - "type": "number", - "exclusiveMinimum": 0, - "description": "Distance en mètres, utilisée avec `spatial_operator = \"dwithin_point\"`." - }, - "intersects_feature_typename": { - "type": "string", - "minLength": 1, - "description": "Type WFS du feature de référence, utilisé avec `spatial_operator = \"intersects_feature\"`." - }, - "intersects_feature_id": { - "type": "string", - "minLength": 1, - "description": "Identifiant du feature de référence, utilisé avec `spatial_operator = \"intersects_feature\"`." + "description": "Filtre spatial optionnel. Variantes supportees : `{ type: \"bbox\", bbox: { west, south, east, north } }`, `{ type: \"intersects_point\", point: { lon, lat } }`, `{ type: \"dwithin_point\", point: { lon, lat }, distance_m }`, `{ type: \"intersects_feature\", feature_ref: { typename, feature_id } }`." } }, "required": [ @@ -1193,7 +1264,7 @@ Title: Informations d’urbanisme - Renvoie, pour un point donné par sa `longitude` et sa `latitude`, la liste des objets d'urbanisme pertinents du Géoportail de l'Urbanisme (document, zones, prescriptions, informations, etc.), avec leurs propriétés associées. (source : Géoplateforme - (WFS Géoportail de l'Urbanisme)). - Les résultats peuvent notamment inclure le document d'urbanisme applicable ainsi que des éléments réglementaires associés à proximité du point. -- Quand un objet correspond à une couche WFS réutilisable, il expose aussi un `feature_ref` compatible avec `gpf_wfs_get_features` et `spatial_operator="intersects_feature"`. +- Quand un objet correspond à une couche WFS réutilisable, il expose aussi un `feature_ref` compatible avec `gpf_wfs_get_features` et `spatial_filter={ type: "intersects_feature", feature_ref: ... }`. - Le zonage PLU (zone U, AU, A, N...) est inclus dans les zones retournées et constitue souvent l'information principale recherchée. - Pour récupérer exactement l'objet correspondant au `feature_ref`, utiliser `gpf_wfs_get_feature_by_id`. - Modèles d'URL Géoportail de l'Urbanisme : @@ -1273,7 +1344,7 @@ Title: Informations d’urbanisme }, "feature_ref": { "type": "object", - "description": "Référence WFS réutilisable, notamment avec `gpf_wfs_get_features` et `spatial_operator = \"intersects_feature\"`.", + "description": "Référence WFS réutilisable, notamment avec `gpf_wfs_get_features` et `spatial_filter = { type: \"intersects_feature\", feature_ref: ... }`.", "properties": { "typename": { "type": "string", diff --git a/src/gpf/adminexpress.ts b/src/gpf/adminexpress.ts index 03487fb..30c3712 100644 --- a/src/gpf/adminexpress.ts +++ b/src/gpf/adminexpress.ts @@ -48,7 +48,10 @@ const ADMINEXPRESS_TYPENAMES = ADMINEXPRESS_TYPES.map((type) => `ADMINEXPRESS-CO export async function getAdminUnits(lon: number, lat: number): Promise { logger.info(`[adminexpress] getAdminUnits(${lon},${lat})...`); - const spatialFilter: SpatialFilter = { operator: "intersects_point", lon, lat }; + const spatialFilter: SpatialFilter = { + type: "intersects_point", + point: { lon, lat }, + }; // Resolve and compile one spatial filter per typename to avoid relying on // cross-layer geometry property homogeneity. diff --git a/src/gpf/parcellaire-express.ts b/src/gpf/parcellaire-express.ts index 3e924ca..082f646 100644 --- a/src/gpf/parcellaire-express.ts +++ b/src/gpf/parcellaire-express.ts @@ -73,9 +73,8 @@ export async function getParcellaireExpress(lon: number, lat: number): Promise

{ const spatialFilter = getSpatialFilter(input); - if (!spatialFilter || spatialFilter.operator !== "intersects_feature") { + if (!spatialFilter || spatialFilter.type !== "intersects_feature") { return undefined; } - const referenceFeatureType = await getFeatureType(spatialFilter.typename); + const referenceFeatureType = await getFeatureType(spatialFilter.feature_ref.typename); const referenceGeometryProperty = getGeometryProperty(referenceFeatureType); const featureCollection = await fetchFeatureById({ - typename: spatialFilter.typename, - feature_id: spatialFilter.feature_id, + typename: spatialFilter.feature_ref.typename, + feature_id: spatialFilter.feature_ref.feature_id, propertyName: referenceGeometryProperty.name, }); const referenceFeature = requireSingleFeatureById(featureCollection, { - typename: spatialFilter.typename, - feature_id: spatialFilter.feature_id, + typename: spatialFilter.feature_ref.typename, + feature_id: spatialFilter.feature_ref.feature_id, }); if (!isGeometryLike(referenceFeature?.geometry)) { throw new Error( - `Le feature de référence '${spatialFilter.feature_id}' n'a pas de géométrie exploitable.`, + `Le feature de référence '${spatialFilter.feature_ref.feature_id}' n'a pas de géométrie exploitable.`, ); } diff --git a/src/helpers/wfs_engine/queryPreparation.ts b/src/helpers/wfs_engine/queryPreparation.ts index b4c2615..6c74079 100644 --- a/src/helpers/wfs_engine/queryPreparation.ts +++ b/src/helpers/wfs_engine/queryPreparation.ts @@ -134,7 +134,7 @@ function compileWhereClause(featureType: Collection, geometryProperty: Collectio featureType, geometryProperty, clause.property, - "La propriété '{property}' est géométrique. Utiliser `spatial_operator` et ses paramètres dédiés." + "La propriété '{property}' est géométrique. Utiliser `spatial_filter`." ); const normalized = normalizeWhereClause(property, clause); @@ -194,7 +194,7 @@ export function compileQueryParts( // Keep the spatial predicate first: the GeoPlateforme GeoServer is sensitive // to filter ordering and may reject equivalent filters when attributes come first. if (spatialFilter) { - switch (spatialFilter.operator) { + switch (spatialFilter.type) { case "bbox": fragments.push(compileBboxSpatialFilter(geometryProperty, spatialFilter)); break; diff --git a/src/helpers/wfs_engine/schema.ts b/src/helpers/wfs_engine/schema.ts index 2821e60..1303c2c 100644 --- a/src/helpers/wfs_engine/schema.ts +++ b/src/helpers/wfs_engine/schema.ts @@ -18,7 +18,7 @@ export const DEFAULT_LIMIT = 100; export const MAX_LIMIT = 5000; export const REQUEST_GET_URL_MAX_LENGTH = 6000; export const WHERE_OPERATORS = ["eq", "ne", "lt", "lte", "gt", "gte", "in", "is_null"] as const; -export const SPATIAL_OPERATORS = ["bbox", "intersects_point", "dwithin_point", "intersects_feature"] as const; +export const SPATIAL_FILTER_TYPES = ["bbox", "intersects_point", "dwithin_point", "intersects_feature"] as const; export const ORDER_DIRECTIONS = ["asc", "desc"] as const; // --- Shared Clauses --- @@ -35,13 +35,67 @@ const orderBySchema = z.object({ direction: z.enum(ORDER_DIRECTIONS).default("asc").describe("Direction de tri : `asc` ou `desc`."), }).strict().describe("Critère de tri structuré. Exemple : `{ property: \"population\", direction: \"desc\" }`."); +const spatialPointSchema = z.object({ + lon: lonSchema.describe("Longitude du point en WGS84 `lon/lat`."), + lat: latSchema.describe("Latitude du point en WGS84 `lon/lat`."), +}).strict().describe("Point en WGS84 `lon/lat`."); + +const spatialBboxSchema = z.object({ + west: lonSchema.describe("Longitude ouest en WGS84 `lon/lat`."), + south: latSchema.describe("Latitude sud en WGS84 `lon/lat`."), + east: lonSchema.describe("Longitude est en WGS84 `lon/lat`."), + north: latSchema.describe("Latitude nord en WGS84 `lon/lat`."), +}).strict().describe("Boite englobante en WGS84 `lon/lat`."); + +const spatialFeatureRefSchema = z.object({ + typename: z.string().trim().min(1).describe("Type WFS du feature de référence."), + feature_id: z.string().trim().min(1).describe("Identifiant du feature de référence."), +}).strict().describe("Référence légère vers un feature WFS réutilisable."); + +export const spatialFilterSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("bbox"), + bbox: spatialBboxSchema, + }).strict().describe("Filtre spatial par boite englobante."), + z.object({ + type: z.literal("intersects_point"), + point: spatialPointSchema, + }).strict().describe("Filtre spatial par intersection avec un point."), + z.object({ + type: z.literal("dwithin_point"), + point: spatialPointSchema, + distance_m: z.number().finite().positive().describe("Distance en metres."), + }).strict().describe("Filtre spatial par distance autour d'un point."), + z.object({ + type: z.literal("intersects_feature"), + feature_ref: spatialFeatureRefSchema, + }).strict().describe("Filtre spatial par intersection avec un feature de reference."), +]); + // --- Shared Types --- +export type SpatialPoint = { + lon: number; + lat: number; +}; + +export type SpatialBbox = { + west: number; + south: number; + east: number; + north: number; +}; + +export type SpatialFeatureRef = { + typename: string; + feature_id: string; +}; + export type SpatialFilter = - | { operator: "bbox"; west: number; south: number; east: number; north: number } - | { operator: "intersects_point"; lon: number; lat: number } - | { operator: "dwithin_point"; lon: number; lat: number; distance_m: number } - | { operator: "intersects_feature"; typename: string; feature_id: string }; + | { type: "bbox"; bbox: SpatialBbox } + | { type: "intersects_point"; point: SpatialPoint } + | { type: "dwithin_point"; point: SpatialPoint; distance_m: number } + | { type: "intersects_feature"; feature_ref: SpatialFeatureRef }; // --- Shared Compact Outputs --- @@ -91,26 +145,16 @@ export const gpfWfsGetFeaturesInputSchema = z.object({ .min(1) .optional() .describe("Clauses de filtre attributaire, combinées avec `AND`."), - spatial_operator: z - .enum(SPATIAL_OPERATORS) + spatial_filter: spatialFilterSchema .optional() - .describe("Type optionnel de filtre spatial."), - bbox_west: lonSchema.describe("Longitude ouest en WGS84 `lon/lat`, utilisée avec `spatial_operator = \"bbox\"`.").optional(), - bbox_south: latSchema.describe("Latitude sud en WGS84 `lon/lat`, utilisée avec `spatial_operator = \"bbox\"`.").optional(), - bbox_east: lonSchema.describe("Longitude est en WGS84 `lon/lat`, utilisée avec `spatial_operator = \"bbox\"`.").optional(), - bbox_north: latSchema.describe("Latitude nord en WGS84 `lon/lat`, utilisée avec `spatial_operator = \"bbox\"`.").optional(), - intersects_lon: lonSchema.describe("Longitude du point en WGS84 `lon/lat`, utilisée avec `spatial_operator = \"intersects_point\"`.").optional(), - intersects_lat: latSchema.describe("Latitude du point en WGS84 `lon/lat`, utilisée avec `spatial_operator = \"intersects_point\"`.").optional(), - dwithin_lon: lonSchema.describe("Longitude du point en WGS84 `lon/lat`, utilisée avec `spatial_operator = \"dwithin_point\"`.").optional(), - dwithin_lat: latSchema.describe("Latitude du point en WGS84 `lon/lat`, utilisée avec `spatial_operator = \"dwithin_point\"`.").optional(), - dwithin_distance_m: z.number().finite().positive().describe("Distance en mètres, utilisée avec `spatial_operator = \"dwithin_point\"`.").optional(), - intersects_feature_typename: z.string().trim().min(1).optional().describe("Type WFS du feature de référence, utilisé avec `spatial_operator = \"intersects_feature\"`."), - intersects_feature_id: z.string().trim().min(1).optional().describe("Identifiant du feature de référence, utilisé avec `spatial_operator = \"intersects_feature\"`."), + .describe("Filtre spatial optionnel. Variantes supportees : `{ type: \"bbox\", bbox: { west, south, east, north } }`, `{ type: \"intersects_point\", point: { lon, lat } }`, `{ type: \"dwithin_point\", point: { lon, lat }, distance_m }`, `{ type: \"intersects_feature\", feature_ref: { typename, feature_id } }`."), }).strict(); // --- `gpf_wfs_get_features` Types --- -export type GpfWfsGetFeaturesInput = z.infer; +export type GpfWfsGetFeaturesInput = Omit, "spatial_filter"> & { + spatial_filter?: SpatialFilter; +}; export type WhereClause = NonNullable[number]; export type OrderByClause = NonNullable[number]; diff --git a/src/helpers/wfs_engine/spatialCql.ts b/src/helpers/wfs_engine/spatialCql.ts index 6b86ddc..88551b3 100644 --- a/src/helpers/wfs_engine/spatialCql.ts +++ b/src/helpers/wfs_engine/spatialCql.ts @@ -18,14 +18,14 @@ import type { SpatialFilter } from "./schema.js"; * @param spatialFilter Normalized bbox filter. * @returns A CQL bbox predicate. */ -export function compileBboxSpatialFilter(geometryProperty: CollectionProperty, spatialFilter: Extract) { - if (spatialFilter.west >= spatialFilter.east) { - throw new Error("Le bbox est invalide : `bbox_west` doit être strictement inférieur à `bbox_east`."); +export function compileBboxSpatialFilter(geometryProperty: CollectionProperty, spatialFilter: Extract) { + if (spatialFilter.bbox.west >= spatialFilter.bbox.east) { + throw new Error("Le bbox est invalide : `spatial_filter.bbox.west` doit être strictement inférieur à `spatial_filter.bbox.east`."); } - if (spatialFilter.south >= spatialFilter.north) { - throw new Error("Le bbox est invalide : `bbox_south` doit être strictement inférieur à `bbox_north`."); + if (spatialFilter.bbox.south >= spatialFilter.bbox.north) { + throw new Error("Le bbox est invalide : `spatial_filter.bbox.south` doit être strictement inférieur à `spatial_filter.bbox.north`."); } - return `BBOX(${geometryProperty.name},${spatialFilter.west},${spatialFilter.south},${spatialFilter.east},${spatialFilter.north},'EPSG:4326')`; + return `BBOX(${geometryProperty.name},${spatialFilter.bbox.west},${spatialFilter.bbox.south},${spatialFilter.bbox.east},${spatialFilter.bbox.north},'EPSG:4326')`; } /** @@ -35,8 +35,8 @@ export function compileBboxSpatialFilter(geometryProperty: CollectionProperty, s * @param spatialFilter Normalized point intersection filter. * @returns A CQL intersects predicate. */ -export function compileIntersectsPointSpatialFilter(geometryProperty: CollectionProperty, spatialFilter: Extract) { - return `INTERSECTS(${geometryProperty.name},SRID=4326;POINT(${spatialFilter.lon} ${spatialFilter.lat}))`; +export function compileIntersectsPointSpatialFilter(geometryProperty: CollectionProperty, spatialFilter: Extract) { + return `INTERSECTS(${geometryProperty.name},SRID=4326;POINT(${spatialFilter.point.lon} ${spatialFilter.point.lat}))`; } /** @@ -46,8 +46,8 @@ export function compileIntersectsPointSpatialFilter(geometryProperty: Collection * @param spatialFilter Normalized distance filter. * @returns A CQL dwithin predicate. */ -export function compileDwithinSpatialFilter(geometryProperty: CollectionProperty, spatialFilter: Extract) { - return `DWITHIN(${geometryProperty.name},SRID=4326;POINT(${spatialFilter.lon} ${spatialFilter.lat}),${spatialFilter.distance_m},meters)`; +export function compileDwithinSpatialFilter(geometryProperty: CollectionProperty, spatialFilter: Extract) { + return `DWITHIN(${geometryProperty.name},SRID=4326;POINT(${spatialFilter.point.lon} ${spatialFilter.point.lat}),${spatialFilter.distance_m},meters)`; } /** diff --git a/src/helpers/wfs_engine/spatialFilter.ts b/src/helpers/wfs_engine/spatialFilter.ts index a499b42..68dcbed 100644 --- a/src/helpers/wfs_engine/spatialFilter.ts +++ b/src/helpers/wfs_engine/spatialFilter.ts @@ -1,8 +1,8 @@ /** - * Spatial input normalization helpers for the structured WFS query compiler. + * Spatial input accessor for the structured WFS query compiler. * - * This module turns raw tool input parameters into normalized spatial filter - * objects that can then be compiled into CQL fragments. + * The public tool schema already validates `spatial_filter` as a strict + * discriminated union, so the engine can consume it directly. */ import type { @@ -10,142 +10,12 @@ import type { SpatialFilter, } from "./schema.js"; -// --- Parameter Groups --- - -const BBOX_PARAM_NAMES = ["bbox_west", "bbox_south", "bbox_east", "bbox_north"] as const; -const INTERSECTS_POINT_PARAM_NAMES = ["intersects_lon", "intersects_lat"] as const; -const DWITHIN_PARAM_NAMES = ["dwithin_lon", "dwithin_lat", "dwithin_distance_m"] as const; -const INTERSECTS_FEATURE_PARAM_NAMES = ["intersects_feature_typename", "intersects_feature_id"] as const; - -// --- Parameter Detection --- - -/** - * Checks whether any property in a named group is defined on the raw input object. - * - * @param input Normalized tool input. - * @param keys Input keys to inspect. - * @returns `true` when at least one key from the group is present. - */ -function hasAny(input: GpfWfsGetFeaturesInput, keys: readonly string[]) { - return keys.some((name) => input[name as keyof GpfWfsGetFeaturesInput] !== undefined); -} - -// --- Per-Mode Readers --- - -/** - * Reads and validates the `bbox` spatial filter parameters. - * - * @param input Normalized tool input. - * @returns A normalized `bbox` spatial filter. - */ -function readBboxFilter(input: GpfWfsGetFeaturesInput): SpatialFilter { - if (input.bbox_west === undefined || input.bbox_south === undefined || input.bbox_east === undefined || input.bbox_north === undefined) { - throw new Error("Le filtre spatial `bbox` exige `bbox_west`, `bbox_south`, `bbox_east` et `bbox_north`."); - } - - return { - operator: "bbox", - west: input.bbox_west, - south: input.bbox_south, - east: input.bbox_east, - north: input.bbox_north, - }; -} - -/** - * Reads and validates the `intersects_point` spatial filter parameters. - * - * @param input Normalized tool input. - * @returns A normalized `intersects_point` spatial filter. - */ -function readIntersectsPointFilter(input: GpfWfsGetFeaturesInput): SpatialFilter { - if (input.intersects_lon === undefined || input.intersects_lat === undefined) { - throw new Error("Le filtre spatial `intersects_point` exige `intersects_lon` et `intersects_lat`."); - } - - return { - operator: "intersects_point", - lon: input.intersects_lon, - lat: input.intersects_lat, - }; -} - -/** - * Reads and validates the `dwithin_point` spatial filter parameters. - * - * @param input Normalized tool input. - * @returns A normalized `dwithin_point` spatial filter. - */ -function readDwithinPointFilter(input: GpfWfsGetFeaturesInput): SpatialFilter { - if (input.dwithin_lon === undefined || input.dwithin_lat === undefined || input.dwithin_distance_m === undefined) { - throw new Error("Le filtre spatial `dwithin_point` exige `dwithin_lon`, `dwithin_lat` et `dwithin_distance_m`."); - } - - return { - operator: "dwithin_point", - lon: input.dwithin_lon, - lat: input.dwithin_lat, - distance_m: input.dwithin_distance_m, - }; -} - -/** - * Reads and validates the `intersects_feature` spatial filter parameters. - * - * @param input Normalized tool input. - * @returns A normalized `intersects_feature` spatial filter. - */ -function readIntersectsFeatureFilter(input: GpfWfsGetFeaturesInput): SpatialFilter { - if (!input.intersects_feature_typename || !input.intersects_feature_id) { - throw new Error("Le filtre spatial `intersects_feature` exige `intersects_feature_typename` et `intersects_feature_id`."); - } - - return { - operator: "intersects_feature", - typename: input.intersects_feature_typename, - feature_id: input.intersects_feature_id, - }; -} - -// --- Public Normalization --- - /** - * Normalizes the raw spatial input into a discriminated spatial filter object. + * Returns the validated spatial filter object from the tool input. * * @param input Normalized tool input. - * @returns A normalized spatial filter, or `undefined` when no spatial filter is requested. + * @returns The spatial filter, or `undefined` when no spatial filter is requested. */ export function getSpatialFilter(input: GpfWfsGetFeaturesInput): SpatialFilter | undefined { - const hasBboxParams = hasAny(input, BBOX_PARAM_NAMES); - const hasIntersectsPointParams = hasAny(input, INTERSECTS_POINT_PARAM_NAMES); - const hasDwithinParams = hasAny(input, DWITHIN_PARAM_NAMES); - const hasIntersectsFeatureParams = hasAny(input, INTERSECTS_FEATURE_PARAM_NAMES); - - switch (input.spatial_operator) { - case undefined: - if (hasBboxParams || hasIntersectsPointParams || hasDwithinParams || hasIntersectsFeatureParams) { - throw new Error("Les paramètres spatiaux exigent `spatial_operator`."); - } - return undefined; - case "bbox": - if (hasIntersectsPointParams || hasDwithinParams || hasIntersectsFeatureParams) { - throw new Error("Le filtre spatial `bbox` n'accepte pas les paramètres d'un autre mode spatial."); - } - return readBboxFilter(input); - case "intersects_point": - if (hasBboxParams || hasDwithinParams || hasIntersectsFeatureParams) { - throw new Error("Le filtre spatial `intersects_point` n'accepte pas les paramètres d'un autre mode spatial."); - } - return readIntersectsPointFilter(input); - case "dwithin_point": - if (hasBboxParams || hasIntersectsPointParams || hasIntersectsFeatureParams) { - throw new Error("Le filtre spatial `dwithin_point` n'accepte pas les paramètres d'un autre mode spatial."); - } - return readDwithinPointFilter(input); - case "intersects_feature": - if (hasBboxParams || hasIntersectsPointParams || hasDwithinParams) { - throw new Error("Le filtre spatial `intersects_feature` n'accepte pas les paramètres d'un autre mode spatial."); - } - return readIntersectsFeatureFilter(input); - } + return input.spatial_filter; } diff --git a/src/tools/AdminexpressTool.ts b/src/tools/AdminexpressTool.ts index b9f9f0f..69ea4fc 100644 --- a/src/tools/AdminexpressTool.ts +++ b/src/tools/AdminexpressTool.ts @@ -25,7 +25,7 @@ const adminexpressResultSchema = z type: z.string().describe(`Le type d'unité administrative (${ADMINEXPRESS_TYPES.join(", ")}).`), id: z.string().describe("L'identifiant de l'unité administrative."), bbox: z.array(z.number()).describe("La boîte englobante de l'unité administrative.").optional(), - feature_ref: featureRefSchema.describe("Référence WFS réutilisable, notamment avec `gpf_wfs_get_features` et `spatial_operator = \"intersects_feature\"`."), + feature_ref: featureRefSchema.describe("Référence WFS réutilisable, notamment avec `gpf_wfs_get_features` et `spatial_filter = { type: \"intersects_feature\", feature_ref: ... }`."), }) .catchall(z.unknown()); @@ -42,7 +42,7 @@ class AdminexpressTool extends BaseTool { description = [ `Renvoie, pour un point donné par sa \`longitude\` et sa \`latitude\`, la liste des unités administratives (${ADMINEXPRESS_TYPES.join(", ")}) qui le couvrent, sous forme d'objets typés contenant leurs propriétés administratives.`, "Les résultats incluent un `feature_ref` WFS réutilisable. Les propriétés incluent notamment le code INSEE.", - "Le `feature_ref` de chaque unité administrative est directement réutilisable dans `gpf_wfs_get_features` avec `spatial_operator=\"intersects_feature\"` pour interroger d'autres données sur cette emprise.", + "Le `feature_ref` de chaque unité administrative est directement réutilisable dans `gpf_wfs_get_features` avec `spatial_filter={ type: \"intersects_feature\", feature_ref: ... }` pour interroger d'autres données sur cette emprise.", "Pour récupérer exactement l'objet correspondant au `feature_ref`, utiliser `gpf_wfs_get_feature_by_id`.", `(source : ${ADMINEXPRESS_SOURCE}).` ].join("\n"); diff --git a/src/tools/AssietteSupTool.ts b/src/tools/AssietteSupTool.ts index ca96d63..e36beb5 100644 --- a/src/tools/AssietteSupTool.ts +++ b/src/tools/AssietteSupTool.ts @@ -25,7 +25,7 @@ const assietteSupResultSchema = z type: z.string().describe("Le type d'assiette de servitude d'utilité publique renvoyé."), id: z.string().describe("L'identifiant de l'assiette."), bbox: z.array(z.number()).describe("La boîte englobante de l'assiette.").optional(), - feature_ref: featureRefSchema.describe("Référence WFS réutilisable, notamment avec `gpf_wfs_get_features` et `spatial_operator = \"intersects_feature\"`.").optional(), + feature_ref: featureRefSchema.describe("Référence WFS réutilisable, notamment avec `gpf_wfs_get_features` et `spatial_filter = { type: \"intersects_feature\", feature_ref: ... }`.").optional(), distance: z.number().describe("La distance en mètres entre le point demandé et l'assiette retenue."), }) .catchall(z.unknown()); diff --git a/src/tools/CadastreTool.ts b/src/tools/CadastreTool.ts index 5196af1..04547c7 100644 --- a/src/tools/CadastreTool.ts +++ b/src/tools/CadastreTool.ts @@ -25,7 +25,7 @@ const cadastreResultSchema = z type: z.string().describe(`Le type d'objet cadastral (${PARCELLAIRE_EXPRESS_TYPES.join(", ")}).`), id: z.string().describe("L'identifiant de l'objet cadastral."), bbox: z.array(z.number()).describe("La boîte englobante de l'objet cadastral.").optional(), - feature_ref: featureRefSchema.describe("Référence WFS réutilisable, notamment avec `gpf_wfs_get_features` et `spatial_operator = \"intersects_feature\"`."), + feature_ref: featureRefSchema.describe("Référence WFS réutilisable, notamment avec `gpf_wfs_get_features` et `spatial_filter = { type: \"intersects_feature\", feature_ref: ... }`."), distance: z.number().describe("La distance en mètres entre le point demandé et l'objet cadastral retenu."), source: z.string().describe("La source des données cadastrales."), }) @@ -44,7 +44,7 @@ class CadastreTool extends BaseTool { description = [ `Renvoie, pour un point donné par sa \`longitude\` et sa \`latitude\`, la liste des objets cadastraux (${PARCELLAIRE_EXPRESS_TYPES.join(", ")}) les plus proches, avec leurs informations associées.`, "Les résultats sont retournés au plus une fois par type lorsqu'ils sont disponibles et incluent un `feature_ref` WFS réutilisable.", - "Le `feature_ref` est directement réutilisable dans `gpf_wfs_get_features` avec `spatial_operator=\"intersects_feature\"`.", + "Le `feature_ref` est directement réutilisable dans `gpf_wfs_get_features` avec `spatial_filter={ type: \"intersects_feature\", feature_ref: ... }`.", "La distance de recherche est fixée à 10 mètres. Si aucun objet n'est trouvé dans les 10 mètres, le résultat est vide.", "Pour récupérer exactement l'objet correspondant au `feature_ref`, utiliser `gpf_wfs_get_feature_by_id`.", `(source : ${PARCELLAIRE_EXPRESS_SOURCE}).` diff --git a/src/tools/GpfWfsGetFeaturesTool.ts b/src/tools/GpfWfsGetFeaturesTool.ts index bd7d091..b64b767 100644 --- a/src/tools/GpfWfsGetFeaturesTool.ts +++ b/src/tools/GpfWfsGetFeaturesTool.ts @@ -29,13 +29,13 @@ class GpfWfsGetFeaturesTool extends BaseTool { annotations = READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS; description = [ "Interroge un type WFS et renvoie des résultats structurés sans demander au modèle d'écrire du CQL ou du WFS.", - "Utiliser `select` pour choisir les propriétés, `where` pour filtrer, `order_by` pour trier et `spatial_operator` avec ses paramètres dédiés pour le spatial. Avec `result_type=\"request\"`, la géométrie est automatiquement ajoutée aux propriétés sélectionnées pour garantir une requête cartographiable.", + "Utiliser `select` pour choisir les propriétés, `where` pour filtrer, `order_by` pour trier et `spatial_filter` pour le spatial. Avec `result_type=\"request\"`, la géométrie est automatiquement ajoutée aux propriétés sélectionnées pour garantir une requête cartographiable.", "Exemple attributaire : `where=[{ property: \"code_insee\", operator: \"eq\", value: \"75056\" }]`.", - "Exemple bbox : `spatial_operator=\"bbox\"` avec `bbox_west`, `bbox_south`, `bbox_east`, `bbox_north` en `lon/lat`.", - "Exemple point dans géométrie : `spatial_operator=\"intersects_point\"` avec `intersects_lon` et `intersects_lat`.", - "Exemple distance : `spatial_operator=\"dwithin_point\"` avec `dwithin_lon`, `dwithin_lat`, `dwithin_distance_m`.", - "Exemple réutilisation : `spatial_operator=\"intersects_feature\"` avec `intersects_feature_typename` et `intersects_feature_id` issus d'une `feature_ref`.", - "⚠️ Quand `typename` et `intersects_feature_typename` sont identiques, utiliser `gpf_wfs_get_feature_by_id` pour récupérer exactement l'objet ciblé.", + "Exemple bbox : `spatial_filter={ type: \"bbox\", bbox: { west: 2.29, south: 48.85, east: 2.3, north: 48.86 } }`.", + "Exemple point dans géométrie : `spatial_filter={ type: \"intersects_point\", point: { lon: 2.29424, lat: 48.858264 } }`.", + "Exemple distance : `spatial_filter={ type: \"dwithin_point\", point: { lon: 2.29424, lat: 48.858264 }, distance_m: 50 }`.", + "Exemple réutilisation : `spatial_filter={ type: \"intersects_feature\", feature_ref: { typename: \"ADMINEXPRESS-COG.LATEST:commune\", feature_id: \"commune.29458\" } }`.", + "⚠️ Quand `typename` et `spatial_filter.feature_ref.typename` sont identiques, utiliser `gpf_wfs_get_feature_by_id` pour récupérer exactement l'objet ciblé.", "**OBLIGATOIRE : toujours appeler `gpf_wfs_describe_type` avant ce tool, sauf si `gpf_wfs_describe_type` a déjà été appelé pour ce même typename dans la conversation en cours.**", "Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiques à chaque typename et diffèrent systématiquement des conventions habituelles (ex : pas de nom_officiel, navigabilite sans accent, etc.). Toute tentative sans appel préalable à `gpf_wfs_describe_type` **provoquera une erreur.**", ].join("\n"); diff --git a/src/tools/UrbanismeTool.ts b/src/tools/UrbanismeTool.ts index dd12c78..d8a2f54 100644 --- a/src/tools/UrbanismeTool.ts +++ b/src/tools/UrbanismeTool.ts @@ -25,7 +25,7 @@ const urbanismeResultSchema = z type: z.string().describe("Le type d'objet d'urbanisme renvoyé."), id: z.string().describe("L'identifiant de l'objet d'urbanisme."), bbox: z.array(z.number()).describe("La boîte englobante de l'objet d'urbanisme.").optional(), - feature_ref: featureRefSchema.describe("Référence WFS réutilisable, notamment avec `gpf_wfs_get_features` et `spatial_operator = \"intersects_feature\"`.").optional(), + feature_ref: featureRefSchema.describe("Référence WFS réutilisable, notamment avec `gpf_wfs_get_features` et `spatial_filter = { type: \"intersects_feature\", feature_ref: ... }`.").optional(), distance: z.number().describe("La distance en mètres entre le point demandé et l'objet d'urbanisme retenu."), }) .catchall(z.unknown()); @@ -37,7 +37,7 @@ const urbanismeOutputSchema = z.object({ const URBANISME_TOOL_DESCRIPTION = [ `Renvoie, pour un point donné par sa \`longitude\` et sa \`latitude\`, la liste des objets d'urbanisme pertinents du Géoportail de l'Urbanisme (document, zones, prescriptions, informations, etc.), avec leurs propriétés associées. (source : ${URBANISME_SOURCE}).`, "Les résultats peuvent notamment inclure le document d'urbanisme applicable ainsi que des éléments réglementaires associés à proximité du point.", - "Quand un objet correspond à une couche WFS réutilisable, il expose aussi un `feature_ref` compatible avec `gpf_wfs_get_features` et `spatial_operator=\"intersects_feature\"`.", + "Quand un objet correspond à une couche WFS réutilisable, il expose aussi un `feature_ref` compatible avec `gpf_wfs_get_features` et `spatial_filter={ type: \"intersects_feature\", feature_ref: ... }`.", "Le zonage PLU (zone U, AU, A, N...) est inclus dans les zones retournées et constitue souvent l'information principale recherchée.", "Pour récupérer exactement l'objet correspondant au `feature_ref`, utiliser `gpf_wfs_get_feature_by_id`.", "Modèles d'URL Géoportail de l'Urbanisme :", diff --git a/test/helpers/wfs_engine/queryPreparation.test.ts b/test/helpers/wfs_engine/queryPreparation.test.ts index e761e81..ee822fc 100644 --- a/test/helpers/wfs_engine/queryPreparation.test.ts +++ b/test/helpers/wfs_engine/queryPreparation.test.ts @@ -43,11 +43,15 @@ describe("gpfWfsGetFeatures/queryPreparation", () => { it("should compile bbox in lon lat order", () => { const compiled = compileQueryParts({ ...baseInput, - spatial_operator: "bbox", - bbox_west: 2.4, - bbox_south: 48.7, - bbox_east: 2.5, - bbox_north: 48.8, + spatial_filter: { + type: "bbox", + bbox: { + west: 2.4, + south: 48.7, + east: 2.5, + north: 48.8, + }, + }, }, featureType); expect(compiled.cqlFilter).toEqual("BBOX(geometrie,2.4,48.7,2.5,48.8,'EPSG:4326')"); @@ -56,17 +60,25 @@ describe("gpfWfsGetFeatures/queryPreparation", () => { it("should compile point spatial filters", () => { const intersects = compileQueryParts({ ...baseInput, - spatial_operator: "intersects_point", - intersects_lon: 2.3522, - intersects_lat: 48.8566, + spatial_filter: { + type: "intersects_point", + point: { + lon: 2.3522, + lat: 48.8566, + }, + }, }, featureType); const dwithin = compileQueryParts({ ...baseInput, - spatial_operator: "dwithin_point", - dwithin_lon: 2.3522, - dwithin_lat: 48.8566, - dwithin_distance_m: 250, + spatial_filter: { + type: "dwithin_point", + point: { + lon: 2.3522, + lat: 48.8566, + }, + distance_m: 250, + }, }, featureType); expect(intersects.cqlFilter).toEqual("INTERSECTS(geometrie,SRID=4326;POINT(2.3522 48.8566))"); @@ -76,9 +88,13 @@ describe("gpfWfsGetFeatures/queryPreparation", () => { it("should compile intersects_feature with resolved geometry", () => { const compiled = compileQueryParts({ ...baseInput, - spatial_operator: "intersects_feature", - intersects_feature_typename: "ADMINEXPRESS-COG.LATEST:commune", - intersects_feature_id: "commune.1", + spatial_filter: { + type: "intersects_feature", + feature_ref: { + typename: "ADMINEXPRESS-COG.LATEST:commune", + feature_id: "commune.1", + }, + }, }, featureType, { geometry_ewkt: "SRID=4326;MULTIPOLYGON(((2 48,2.2 48,2.2 48.2,2 48,2 48)))", }); @@ -103,11 +119,10 @@ describe("gpfWfsGetFeatures/queryPreparation", () => { expect(compiled.propertyName).toEqual("code_insee,population,geometrie"); }); - it("should reject stray spatial params without operator", () => { - expect(() => compileQueryParts({ - ...baseInput, - bbox_west: 2.3, - }, featureType)).toThrow("paramètres spatiaux exigent `spatial_operator`"); + it("should leave cqlFilter undefined when no where and no spatial_filter are provided", () => { + const compiled = compileQueryParts(baseInput, featureType); + + expect(compiled.cqlFilter).toBeUndefined(); }); it("should build sortBy from structured order_by", () => { diff --git a/test/helpers/wfs_engine/spatialCql.test.ts b/test/helpers/wfs_engine/spatialCql.test.ts index 5360714..f762ec1 100644 --- a/test/helpers/wfs_engine/spatialCql.test.ts +++ b/test/helpers/wfs_engine/spatialCql.test.ts @@ -20,7 +20,10 @@ const geometryProperty: CollectionProperty = { describe("compileBboxSpatialFilter", () => { it("should compile a valid bbox filter to a CQL BBOX predicate", () => { - const filter = extractSpatialFilter({ operator: "bbox", west: 2.1, south: 48.7, east: 2.5, north: 48.9 }); + const filter = extractSpatialFilter({ + type: "bbox", + bbox: { west: 2.1, south: 48.7, east: 2.5, north: 48.9 }, + }); const result = compileBboxSpatialFilter(geometryProperty, filter); @@ -28,39 +31,54 @@ describe("compileBboxSpatialFilter", () => { }); it("should reject west >= east", () => { - const filter = extractSpatialFilter({ operator: "bbox", west: 3.0, south: 48.0, east: 2.0, north: 49.0 }); + const filter = extractSpatialFilter({ + type: "bbox", + bbox: { west: 3.0, south: 48.0, east: 2.0, north: 49.0 }, + }); expect(() => compileBboxSpatialFilter(geometryProperty, filter)).toThrow( - "Le bbox est invalide : `bbox_west` doit être strictement inférieur à `bbox_east`." + "Le bbox est invalide : `spatial_filter.bbox.west` doit être strictement inférieur à `spatial_filter.bbox.east`." ); }); it("should reject equal west and east", () => { - const filter = extractSpatialFilter({ operator: "bbox", west: 2.5, south: 48.0, east: 2.5, north: 49.0 }); + const filter = extractSpatialFilter({ + type: "bbox", + bbox: { west: 2.5, south: 48.0, east: 2.5, north: 49.0 }, + }); expect(() => compileBboxSpatialFilter(geometryProperty, filter)).toThrow( - "Le bbox est invalide : `bbox_west` doit être strictement inférieur à `bbox_east`." + "Le bbox est invalide : `spatial_filter.bbox.west` doit être strictement inférieur à `spatial_filter.bbox.east`." ); }); it("should reject south >= north", () => { - const filter = extractSpatialFilter({ operator: "bbox", west: 2.0, south: 49.0, east: 3.0, north: 48.0 }); + const filter = extractSpatialFilter({ + type: "bbox", + bbox: { west: 2.0, south: 49.0, east: 3.0, north: 48.0 }, + }); expect(() => compileBboxSpatialFilter(geometryProperty, filter)).toThrow( - "Le bbox est invalide : `bbox_south` doit être strictement inférieur à `bbox_north`." + "Le bbox est invalide : `spatial_filter.bbox.south` doit être strictement inférieur à `spatial_filter.bbox.north`." ); }); it("should reject equal south and north", () => { - const filter = extractSpatialFilter({ operator: "bbox", west: 2.0, south: 48.5, east: 3.0, north: 48.5 }); + const filter = extractSpatialFilter({ + type: "bbox", + bbox: { west: 2.0, south: 48.5, east: 3.0, north: 48.5 }, + }); expect(() => compileBboxSpatialFilter(geometryProperty, filter)).toThrow( - "Le bbox est invalide : `bbox_south` doit être strictement inférieur à `bbox_north`." + "Le bbox est invalide : `spatial_filter.bbox.south` doit être strictement inférieur à `spatial_filter.bbox.north`." ); }); it("should handle negative coordinates", () => { - const filter = extractSpatialFilter({ operator: "bbox", west: -5.0, south: -10.0, east: -1.0, north: -2.0 }); + const filter = extractSpatialFilter({ + type: "bbox", + bbox: { west: -5.0, south: -10.0, east: -1.0, north: -2.0 }, + }); const result = compileBboxSpatialFilter(geometryProperty, filter); @@ -68,7 +86,10 @@ describe("compileBboxSpatialFilter", () => { }); it("should handle coordinates crossing the equator", () => { - const filter = extractSpatialFilter({ operator: "bbox", west: 10.0, south: -5.0, east: 20.0, north: 5.0 }); + const filter = extractSpatialFilter({ + type: "bbox", + bbox: { west: 10.0, south: -5.0, east: 20.0, north: 5.0 }, + }); const result = compileBboxSpatialFilter(geometryProperty, filter); @@ -80,7 +101,10 @@ describe("compileBboxSpatialFilter", () => { describe("compileIntersectsPointSpatialFilter", () => { it("should compile an intersects_point filter to a CQL INTERSECTS predicate", () => { - const filter = extractSpatialFilter({ operator: "intersects_point", lon: 2.3522, lat: 48.8566 }); + const filter = extractSpatialFilter({ + type: "intersects_point", + point: { lon: 2.3522, lat: 48.8566 }, + }); const result = compileIntersectsPointSpatialFilter(geometryProperty, filter); @@ -88,7 +112,10 @@ describe("compileIntersectsPointSpatialFilter", () => { }); it("should handle negative coordinates", () => { - const filter = extractSpatialFilter({ operator: "intersects_point", lon: -73.9857, lat: 40.7484 }); + const filter = extractSpatialFilter({ + type: "intersects_point", + point: { lon: -73.9857, lat: 40.7484 }, + }); const result = compileIntersectsPointSpatialFilter(geometryProperty, filter); @@ -100,7 +127,11 @@ describe("compileIntersectsPointSpatialFilter", () => { describe("compileDwithinSpatialFilter", () => { it("should compile a dwithin_point filter to a CQL DWITHIN predicate", () => { - const filter = extractSpatialFilter({ operator: "dwithin_point", lon: 2.3522, lat: 48.8566, distance_m: 500 }); + const filter = extractSpatialFilter({ + type: "dwithin_point", + point: { lon: 2.3522, lat: 48.8566 }, + distance_m: 500, + }); const result = compileDwithinSpatialFilter(geometryProperty, filter); @@ -108,7 +139,11 @@ describe("compileDwithinSpatialFilter", () => { }); it("should handle a large distance", () => { - const filter = extractSpatialFilter({ operator: "dwithin_point", lon: 0, lat: 0, distance_m: 50000 }); + const filter = extractSpatialFilter({ + type: "dwithin_point", + point: { lon: 0, lat: 0 }, + distance_m: 50000, + }); const result = compileDwithinSpatialFilter(geometryProperty, filter); @@ -116,7 +151,11 @@ describe("compileDwithinSpatialFilter", () => { }); it("should handle a small fractional distance", () => { - const filter = extractSpatialFilter({ operator: "dwithin_point", lon: 5.0, lat: 43.0, distance_m: 0.5 }); + const filter = extractSpatialFilter({ + type: "dwithin_point", + point: { lon: 5.0, lat: 43.0 }, + distance_m: 0.5, + }); const result = compileDwithinSpatialFilter(geometryProperty, filter); @@ -146,19 +185,13 @@ describe("compileIntersectsFeatureSpatialFilter", () => { // --- Helpers --- -const VALID_OPERATORS: readonly string[] = ["bbox", "intersects_point", "dwithin_point", "intersects_feature"]; - -/** - * Type-safe helper to build a specific spatial filter variant with a runtime guard. - * - * Validates the operator at runtime so a mistyped fixture fails fast instead of - * silently passing due to a bare cast. - */ -function extractSpatialFilter( - input: SpatialFilter & { operator: T }, -): Extract { - if (!VALID_OPERATORS.includes(input.operator)) { - throw new Error(`Test fixture error: unexpected operator '${String(input.operator)}'`); +const VALID_TYPES: readonly string[] = ["bbox", "intersects_point", "dwithin_point", "intersects_feature"]; + +function extractSpatialFilter( + input: SpatialFilter & { type: T }, +): Extract { + if (!VALID_TYPES.includes(input.type)) { + throw new Error(`Test fixture error: unexpected type '${String(input.type)}'`); } - return input as Extract; + return input as Extract; } diff --git a/test/helpers/wfs_engine/spatialFilter.test.ts b/test/helpers/wfs_engine/spatialFilter.test.ts index dbeb3e2..dbaa027 100644 --- a/test/helpers/wfs_engine/spatialFilter.test.ts +++ b/test/helpers/wfs_engine/spatialFilter.test.ts @@ -1,5 +1,8 @@ import { getSpatialFilter } from "../../../src/helpers/wfs_engine/spatialFilter"; -import type { GpfWfsGetFeaturesInput } from "../../../src/helpers/wfs_engine/schema"; +import { + gpfWfsGetFeaturesInputSchema, + type GpfWfsGetFeaturesInput, +} from "../../../src/helpers/wfs_engine/schema"; // --- Shared fixtures --- @@ -9,365 +12,257 @@ const baseInput: GpfWfsGetFeaturesInput = { result_type: "results", }; -// --- getSpatialFilter: no operator --- +function parseInput(input: Record): GpfWfsGetFeaturesInput { + return gpfWfsGetFeaturesInputSchema.parse(input); +} -describe("getSpatialFilter — no operator", () => { - it("should return undefined when no spatial_operator and no spatial params", () => { - expect(getSpatialFilter(baseInput)).toBeUndefined(); - }); - - it("should throw when stray bbox params are present without spatial_operator", () => { - expect(() => - getSpatialFilter({ ...baseInput, bbox_west: 2.0 }), - ).toThrow("paramètres spatiaux exigent `spatial_operator`"); - }); - - it("should throw when stray intersects_point params are present without spatial_operator", () => { - expect(() => - getSpatialFilter({ ...baseInput, intersects_lon: 2.3 }), - ).toThrow("paramètres spatiaux exigent `spatial_operator`"); - }); - - it("should throw when stray dwithin params are present without spatial_operator", () => { - expect(() => - getSpatialFilter({ ...baseInput, dwithin_lon: 2.3 }), - ).toThrow("paramètres spatiaux exigent `spatial_operator`"); - }); +// --- getSpatialFilter --- - it("should throw when stray intersects_feature params are present without spatial_operator", () => { - expect(() => - getSpatialFilter({ ...baseInput, intersects_feature_typename: "TEST:type" }), - ).toThrow("paramètres spatiaux exigent `spatial_operator`"); +describe("getSpatialFilter", () => { + it("should return undefined when spatial_filter is absent", () => { + expect(getSpatialFilter(baseInput)).toBeUndefined(); }); -}); - -// --- getSpatialFilter: bbox --- -describe("getSpatialFilter — bbox", () => { - it("should return a bbox filter when all bbox params are provided", () => { - const result = getSpatialFilter({ + it("should return a bbox filter", () => { + const input = parseInput({ ...baseInput, - spatial_operator: "bbox", - bbox_west: 2.1, - bbox_south: 48.7, - bbox_east: 2.5, - bbox_north: 48.9, + spatial_filter: { + type: "bbox", + bbox: { + west: 2.1, + south: 48.7, + east: 2.5, + north: 48.9, + }, + }, }); - expect(result).toEqual({ - operator: "bbox", - west: 2.1, - south: 48.7, - east: 2.5, - north: 48.9, + expect(getSpatialFilter(input)).toEqual({ + type: "bbox", + bbox: { + west: 2.1, + south: 48.7, + east: 2.5, + north: 48.9, + }, }); }); - it("should throw when bbox params are incomplete (missing north)", () => { - expect(() => - getSpatialFilter({ - ...baseInput, - spatial_operator: "bbox", - bbox_west: 2.1, - bbox_south: 48.7, - bbox_east: 2.5, - }), - ).toThrow("Le filtre spatial `bbox` exige `bbox_west`, `bbox_south`, `bbox_east` et `bbox_north`"); - }); + it("should return an intersects_point filter", () => { + const input = parseInput({ + ...baseInput, + spatial_filter: { + type: "intersects_point", + point: { + lon: 2.3522, + lat: 48.8566, + }, + }, + }); - it("should throw when bbox operator is used with intersects_point params", () => { - expect(() => - getSpatialFilter({ - ...baseInput, - spatial_operator: "bbox", - bbox_west: 2.1, - bbox_south: 48.7, - bbox_east: 2.5, - bbox_north: 48.9, - intersects_lon: 2.3, - }), - ).toThrow("Le filtre spatial `bbox` n'accepte pas les paramètres d'un autre mode spatial"); + expect(getSpatialFilter(input)).toEqual({ + type: "intersects_point", + point: { + lon: 2.3522, + lat: 48.8566, + }, + }); }); - it("should throw when bbox operator is used with dwithin params", () => { - expect(() => - getSpatialFilter({ - ...baseInput, - spatial_operator: "bbox", - bbox_west: 2.1, - bbox_south: 48.7, - bbox_east: 2.5, - bbox_north: 48.9, - dwithin_distance_m: 100, - }), - ).toThrow("Le filtre spatial `bbox` n'accepte pas les paramètres d'un autre mode spatial"); - }); + it("should return a dwithin_point filter", () => { + const input = parseInput({ + ...baseInput, + spatial_filter: { + type: "dwithin_point", + point: { + lon: 2.3522, + lat: 48.8566, + }, + distance_m: 500, + }, + }); - it("should throw when bbox operator is used with intersects_feature params", () => { - expect(() => - getSpatialFilter({ - ...baseInput, - spatial_operator: "bbox", - bbox_west: 2.1, - bbox_south: 48.7, - bbox_east: 2.5, - bbox_north: 48.9, - intersects_feature_typename: "TEST:type", - }), - ).toThrow("Le filtre spatial `bbox` n'accepte pas les paramètres d'un autre mode spatial"); + expect(getSpatialFilter(input)).toEqual({ + type: "dwithin_point", + point: { + lon: 2.3522, + lat: 48.8566, + }, + distance_m: 500, + }); }); -}); -// --- getSpatialFilter: intersects_point --- - -describe("getSpatialFilter — intersects_point", () => { - it("should return an intersects_point filter when lon/lat are provided", () => { - const result = getSpatialFilter({ + it("should return an intersects_feature filter", () => { + const input = parseInput({ ...baseInput, - spatial_operator: "intersects_point", - intersects_lon: 2.3522, - intersects_lat: 48.8566, + spatial_filter: { + type: "intersects_feature", + feature_ref: { + typename: "ADMINEXPRESS-COG.LATEST:commune", + feature_id: "commune.8952", + }, + }, }); - expect(result).toEqual({ - operator: "intersects_point", - lon: 2.3522, - lat: 48.8566, + expect(getSpatialFilter(input)).toEqual({ + type: "intersects_feature", + feature_ref: { + typename: "ADMINEXPRESS-COG.LATEST:commune", + feature_id: "commune.8952", + }, }); }); +}); - it("should throw when intersects_lon is missing", () => { - expect(() => - getSpatialFilter({ - ...baseInput, - spatial_operator: "intersects_point", - intersects_lat: 48.8566, - }), - ).toThrow("Le filtre spatial `intersects_point` exige `intersects_lon` et `intersects_lat`"); - }); +// --- schema validation --- - it("should throw when intersects_lat is missing", () => { - expect(() => - getSpatialFilter({ - ...baseInput, - spatial_operator: "intersects_point", - intersects_lon: 2.3522, - }), - ).toThrow("Le filtre spatial `intersects_point` exige `intersects_lon` et `intersects_lat`"); - }); +describe("gpfWfsGetFeaturesInputSchema spatial_filter", () => { + it("should reject incomplete bbox objects", () => { + const result = gpfWfsGetFeaturesInputSchema.safeParse({ + ...baseInput, + spatial_filter: { + type: "bbox", + bbox: { + west: 2.1, + south: 48.7, + east: 2.5, + }, + }, + }); - it("should throw when intersects_point operator is used with bbox params", () => { - expect(() => - getSpatialFilter({ - ...baseInput, - spatial_operator: "intersects_point", - intersects_lon: 2.3522, - intersects_lat: 48.8566, - bbox_west: 2.0, - }), - ).toThrow("Le filtre spatial `intersects_point` n'accepte pas les paramètres d'un autre mode spatial"); + expect(result.success).toBe(false); }); - it("should throw when intersects_point operator is used with dwithin params", () => { - expect(() => - getSpatialFilter({ - ...baseInput, - spatial_operator: "intersects_point", - intersects_lon: 2.3522, - intersects_lat: 48.8566, - dwithin_distance_m: 100, - }), - ).toThrow("Le filtre spatial `intersects_point` n'accepte pas les paramètres d'un autre mode spatial"); - }); + it("should reject incomplete intersects_point objects", () => { + const result = gpfWfsGetFeaturesInputSchema.safeParse({ + ...baseInput, + spatial_filter: { + type: "intersects_point", + point: { + lat: 48.8566, + }, + }, + }); - it("should throw when intersects_point operator is used with intersects_feature params", () => { - expect(() => - getSpatialFilter({ - ...baseInput, - spatial_operator: "intersects_point", - intersects_lon: 2.3522, - intersects_lat: 48.8566, - intersects_feature_id: "feature.1", - }), - ).toThrow("Le filtre spatial `intersects_point` n'accepte pas les paramètres d'un autre mode spatial"); + expect(result.success).toBe(false); }); -}); - -// --- getSpatialFilter: dwithin_point --- -describe("getSpatialFilter — dwithin_point", () => { - it("should return a dwithin_point filter when lon/lat/distance are provided", () => { - const result = getSpatialFilter({ + it("should reject incomplete dwithin_point objects", () => { + const result = gpfWfsGetFeaturesInputSchema.safeParse({ ...baseInput, - spatial_operator: "dwithin_point", - dwithin_lon: 2.3522, - dwithin_lat: 48.8566, - dwithin_distance_m: 500, - }); - - expect(result).toEqual({ - operator: "dwithin_point", - lon: 2.3522, - lat: 48.8566, - distance_m: 500, + spatial_filter: { + type: "dwithin_point", + point: { + lon: 2.3522, + lat: 48.8566, + }, + }, }); - }); - it("should throw when dwithin_distance_m is missing", () => { - expect(() => - getSpatialFilter({ - ...baseInput, - spatial_operator: "dwithin_point", - dwithin_lon: 2.3522, - dwithin_lat: 48.8566, - }), - ).toThrow("Le filtre spatial `dwithin_point` exige `dwithin_lon`, `dwithin_lat` et `dwithin_distance_m`"); + expect(result.success).toBe(false); }); - it("should throw when dwithin_lon is missing", () => { - expect(() => - getSpatialFilter({ - ...baseInput, - spatial_operator: "dwithin_point", - dwithin_lat: 48.8566, - dwithin_distance_m: 500, - }), - ).toThrow("Le filtre spatial `dwithin_point` exige `dwithin_lon`, `dwithin_lat` et `dwithin_distance_m`"); - }); + it("should reject incomplete intersects_feature objects", () => { + const result = gpfWfsGetFeaturesInputSchema.safeParse({ + ...baseInput, + spatial_filter: { + type: "intersects_feature", + feature_ref: { + typename: "ADMINEXPRESS-COG.LATEST:commune", + }, + }, + }); - it("should throw when dwithin_point operator is used with bbox params", () => { - expect(() => - getSpatialFilter({ - ...baseInput, - spatial_operator: "dwithin_point", - dwithin_lon: 2.3522, - dwithin_lat: 48.8566, - dwithin_distance_m: 500, - bbox_north: 49.0, - }), - ).toThrow("Le filtre spatial `dwithin_point` n'accepte pas les paramètres d'un autre mode spatial"); + expect(result.success).toBe(false); }); - it("should throw when dwithin_point operator is used with intersects_point params", () => { - expect(() => - getSpatialFilter({ - ...baseInput, - spatial_operator: "dwithin_point", - dwithin_lon: 2.3522, - dwithin_lat: 48.8566, - dwithin_distance_m: 500, - intersects_lat: 48.0, - }), - ).toThrow("Le filtre spatial `dwithin_point` n'accepte pas les paramètres d'un autre mode spatial"); - }); + it("should reject extra keys at the spatial_filter level", () => { + const result = gpfWfsGetFeaturesInputSchema.safeParse({ + ...baseInput, + spatial_filter: { + type: "bbox", + bbox: { + west: 2.1, + south: 48.7, + east: 2.5, + north: 48.9, + }, + distance_m: 100, + }, + }); - it("should throw when dwithin_point operator is used with intersects_feature params", () => { - expect(() => - getSpatialFilter({ - ...baseInput, - spatial_operator: "dwithin_point", - dwithin_lon: 2.3522, - dwithin_lat: 48.8566, - dwithin_distance_m: 500, - intersects_feature_typename: "TEST:type", - }), - ).toThrow("Le filtre spatial `dwithin_point` n'accepte pas les paramètres d'un autre mode spatial"); + expect(result.success).toBe(false); }); -}); - -// --- getSpatialFilter: intersects_feature --- -describe("getSpatialFilter — intersects_feature", () => { - it("should return an intersects_feature filter when typename and feature_id are provided", () => { - const result = getSpatialFilter({ + it("should reject extra keys inside bbox", () => { + const result = gpfWfsGetFeaturesInputSchema.safeParse({ ...baseInput, - spatial_operator: "intersects_feature", - intersects_feature_typename: "ADMINEXPRESS-COG.LATEST:commune", - intersects_feature_id: "commune.8952", + spatial_filter: { + type: "bbox", + bbox: { + west: 2.1, + south: 48.7, + east: 2.5, + north: 48.9, + lon: 2.3, + }, + }, }); - expect(result).toEqual({ - operator: "intersects_feature", - typename: "ADMINEXPRESS-COG.LATEST:commune", - feature_id: "commune.8952", - }); + expect(result.success).toBe(false); }); - it("should throw when intersects_feature_typename is missing", () => { - expect(() => - getSpatialFilter({ - ...baseInput, - spatial_operator: "intersects_feature", - intersects_feature_id: "commune.8952", - }), - ).toThrow("Le filtre spatial `intersects_feature` exige `intersects_feature_typename` et `intersects_feature_id`"); - }); - - it("should throw when intersects_feature_typename is an empty string", () => { - expect(() => - getSpatialFilter({ - ...baseInput, - spatial_operator: "intersects_feature", - intersects_feature_typename: "", - intersects_feature_id: "commune.8952", - }), - ).toThrow("Le filtre spatial `intersects_feature` exige `intersects_feature_typename` et `intersects_feature_id`"); - }); + it("should reject extra keys inside point", () => { + const result = gpfWfsGetFeaturesInputSchema.safeParse({ + ...baseInput, + spatial_filter: { + type: "intersects_point", + point: { + lon: 2.3522, + lat: 48.8566, + west: 2.1, + }, + }, + }); - it("should throw when intersects_feature_id is missing", () => { - expect(() => - getSpatialFilter({ - ...baseInput, - spatial_operator: "intersects_feature", - intersects_feature_typename: "ADMINEXPRESS-COG.LATEST:commune", - }), - ).toThrow("Le filtre spatial `intersects_feature` exige `intersects_feature_typename` et `intersects_feature_id`"); + expect(result.success).toBe(false); }); - it("should throw when intersects_feature_id is an empty string", () => { - expect(() => - getSpatialFilter({ - ...baseInput, - spatial_operator: "intersects_feature", - intersects_feature_typename: "ADMINEXPRESS-COG.LATEST:commune", - intersects_feature_id: "", - }), - ).toThrow("Le filtre spatial `intersects_feature` exige `intersects_feature_typename` et `intersects_feature_id`"); - }); + it("should reject extra keys inside feature_ref", () => { + const result = gpfWfsGetFeaturesInputSchema.safeParse({ + ...baseInput, + spatial_filter: { + type: "intersects_feature", + feature_ref: { + typename: "ADMINEXPRESS-COG.LATEST:commune", + feature_id: "commune.8952", + lon: 2.3, + }, + }, + }); - it("should throw when intersects_feature operator is used with bbox params", () => { - expect(() => - getSpatialFilter({ - ...baseInput, - spatial_operator: "intersects_feature", - intersects_feature_typename: "TEST:type", - intersects_feature_id: "feature.1", - bbox_south: 48.0, - }), - ).toThrow("Le filtre spatial `intersects_feature` n'accepte pas les paramètres d'un autre mode spatial"); + expect(result.success).toBe(false); }); - it("should throw when intersects_feature operator is used with intersects_point params", () => { - expect(() => - getSpatialFilter({ - ...baseInput, - spatial_operator: "intersects_feature", - intersects_feature_typename: "TEST:type", - intersects_feature_id: "feature.1", - intersects_lon: 2.3, - }), - ).toThrow("Le filtre spatial `intersects_feature` n'accepte pas les paramètres d'un autre mode spatial"); - }); + it("should reject mixed bbox and dwithin payloads in the same object", () => { + const result = gpfWfsGetFeaturesInputSchema.safeParse({ + ...baseInput, + spatial_filter: { + type: "bbox", + bbox: { + west: 2.1, + south: 48.7, + east: 2.5, + north: 48.9, + }, + point: { + lon: 2.3522, + lat: 48.8566, + }, + distance_m: 100, + }, + }); - it("should throw when intersects_feature operator is used with dwithin params", () => { - expect(() => - getSpatialFilter({ - ...baseInput, - spatial_operator: "intersects_feature", - intersects_feature_typename: "TEST:type", - intersects_feature_id: "feature.1", - dwithin_lon: 2.3, - }), - ).toThrow("Le filtre spatial `intersects_feature` n'accepte pas les paramètres d'un autre mode spatial"); + expect(result.success).toBe(false); }); }); diff --git a/test/tools/wfs/getFeatures.test.ts b/test/tools/wfs/getFeatures.test.ts index ea824b2..dff7c1e 100644 --- a/test/tools/wfs/getFeatures.test.ts +++ b/test/tools/wfs/getFeatures.test.ts @@ -154,6 +154,9 @@ describe("Test GpfWfsGetFeaturesTool", () => { expect(tool.toolDefinition.inputSchema.properties?.where).toMatchObject({ type: "array", }); + expect(tool.toolDefinition.inputSchema.properties?.spatial_filter).toMatchObject({ + anyOf: expect.any(Array), + }); expect(tool.toolDefinition.outputSchema).toBeUndefined(); }); @@ -303,6 +306,40 @@ describe("Test GpfWfsGetFeaturesTool", () => { }); }); + it("should reject removed flat spatial parameters as unknown inputs", async () => { + const tool = new GpfWfsGetFeaturesTool(); + const response = await tool.toolCall({ + params: { + name: "gpf_wfs_get_features", + arguments: { + typename: "ADMINEXPRESS-COG.LATEST:commune", + spatial_operator: "bbox", + bbox_west: 2.1, + bbox_south: 48.7, + bbox_east: 2.5, + bbox_north: 48.9, + }, + }, + }); + + expect(response.isError).toBe(true); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + expect(textContent.text).toContain("Paramètres invalides"); + expect(response.structuredContent).toMatchObject({ + type: "urn:geocontext:problem:invalid-tool-params", + errors: expect.arrayContaining([ + expect.objectContaining({ code: "unknown_parameter", name: "spatial_operator" }), + expect.objectContaining({ code: "unknown_parameter", name: "bbox_west" }), + expect.objectContaining({ code: "unknown_parameter", name: "bbox_south" }), + expect.objectContaining({ code: "unknown_parameter", name: "bbox_east" }), + expect.objectContaining({ code: "unknown_parameter", name: "bbox_north" }), + ]), + }); + }); + it("should build a POST request with query params and encoded body", async () => { const tool = new GpfWfsGetFeaturesTool(); mockFeatureTypes({ [polygonFeatureType.id]: polygonFeatureType }); @@ -554,9 +591,13 @@ describe("Test GpfWfsGetFeaturesTool", () => { name: "gpf_wfs_get_features", arguments: { typename: "ADMINEXPRESS-COG.LATEST:commune", - spatial_operator: "intersects_feature", - intersects_feature_typename: "CADASTRALPARCELS.PARCELLAIRE_EXPRESS:localisant", - intersects_feature_id: "localisant.1", + spatial_filter: { + type: "intersects_feature", + feature_ref: { + typename: "CADASTRALPARCELS.PARCELLAIRE_EXPRESS:localisant", + feature_id: "localisant.1", + }, + }, result_type: "request", }, }, @@ -589,9 +630,13 @@ describe("Test GpfWfsGetFeaturesTool", () => { name: "gpf_wfs_get_features", arguments: { typename: "ADMINEXPRESS-COG.LATEST:commune", - spatial_operator: "intersects_feature", - intersects_feature_typename: "CADASTRALPARCELS.PARCELLAIRE_EXPRESS:localisant", - intersects_feature_id: "localisant.404", + spatial_filter: { + type: "intersects_feature", + feature_ref: { + typename: "CADASTRALPARCELS.PARCELLAIRE_EXPRESS:localisant", + feature_id: "localisant.404", + }, + }, }, }, }); @@ -617,9 +662,13 @@ describe("Test GpfWfsGetFeaturesTool", () => { name: "gpf_wfs_get_features", arguments: { typename: "ADMINEXPRESS-COG.LATEST:commune", - spatial_operator: "intersects_feature", - intersects_feature_typename: "ADMINEXPRESS-COG.LATEST:commune", - intersects_feature_id: "commune.1", + spatial_filter: { + type: "intersects_feature", + feature_ref: { + typename: "ADMINEXPRESS-COG.LATEST:commune", + feature_id: "commune.1", + }, + }, }, }, });