diff --git a/README.md b/README.md index 6b8ea6d..07beae4 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ Si `GPF_WFS_MINISEARCH_OPTIONS` est absent ou vide, les options par défaut rest Remarque : -- Les outils `gpf_wfs_list_types`, `gpf_wfs_search_types` et `gpf_wfs_describe_type` s'appuient sur un catalogue de schémas embarqué fourni par `@ignfab/gpf-schema-store`. +- Les outils `gpf_wfs_search_types` et `gpf_wfs_describe_type` s'appuient sur un catalogue de schémas embarqué fourni par `@ignfab/gpf-schema-store`. - L'outil `gpf_wfs_get_features` interroge toujours le service WFS de la Géoplateforme en direct. - Le catalogue embarqué améliore la description des featureTypes mais il peut être légèrement décalé par rapport à l'état courant du WFS. @@ -177,12 +177,13 @@ L'idée est ici de répondre à des précises en traitant côté serveur les app * [assiette_sup(lon,lat)](src/tools/AssietteSupTool.ts) permet de **récupérer les Servitude d'Utilité Publiques (SUP)** +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_features`, notamment avec `spatial_operator="intersects_feature"`. + ### Explorer les données vecteurs #### Explorer les tables -* [gpf_wfs_list_types()](src/tools/GpfWfsListTypesTool.ts) pour **lister de façon exhaustive les types WFS connus du catalogue de schémas embarqué**. Cet outil est surtout utile pour un inventaire complet ou une exploration globale du catalogue ; pour trouver rapidement un type pertinent, préférer `gpf_wfs_search_types`. -* [gpf_wfs_search_types(keywords,max_results=10)](src/tools/GpfWfsSearchTypesTool.ts) pour **rechercher un type WFS pertinent à partir de mots-clés et obtenir un `typename` valide**. La recherche est textuelle, configurable via `GPF_WFS_MINISEARCH_OPTIONS`, et renvoie aussi un score de pertinence quand il est disponible. +* [gpf_wfs_search_types(keywords,max_results=10)](src/tools/GpfWfsSearchTypesTool.ts) pour **rechercher un type WFS pertinent à partir de mots-clés et obtenir un `typename` valide**. La recherche est textuelle et configurable via `GPF_WFS_MINISEARCH_OPTIONS`. > - Quels sont les millésimes ADMINEXPRESS disponibles sur la Géoplateforme? > - Quelle est la table de la BDTOPO correspondant aux bâtiments? @@ -197,7 +198,22 @@ L'idée est ici de répondre à des précises en traitant côté serveur les app #### Explorer les données des tables -* [gpf_wfs_get_features(typename,...)](src/tools/GpfWfsGetFeaturesTool.ts) pour **récupérer les données d'une table** depuis le service WFS de la Géoplateforme ([GetFeature](https://data.geopf.fr/wfs/ows?service=WFS&version=2.0.0&request=GetFeature&typename=ADMINEXPRESS-COG.LATEST:commune&outputFormat=application/json&count=1)) +* [gpf_wfs_get_features(typename,...)](src/tools/GpfWfsGetFeaturesTool.ts) pour **récupérer les données d'une table** depuis le service WFS de la Géoplateforme sans écrire de CQL à la main. + +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 en `lon/lat` +- `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` > - 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? @@ -231,7 +247,6 @@ N'hésitez pas : mcp add tool gpf_wmts_layers ``` -* [@camptocamp/ogc-client](https://camptocamp.github.io/ogc-client/#/) pour la **lecture des réponses XML des services WFS, WMTS,...** * [@ignfab/gpf-schema-store](https://www.npmjs.com/package/@ignfab/gpf-schema-store) pour le **catalogue de schémas embarqué** utilisé par les outils d'exploration WFS. * [MiniSearch](https://github.com/lucaong/minisearch) pour la **recherche par mot clé** utilisée dans `@ignfab/gpf-schema-store`. * [jsts](https://bjornharrtell.github.io/jsts/) pour les **traitements géométriques** (ex : tri des réponses par distance au point recherché). diff --git a/package-lock.json b/package-lock.json index e6524c2..c5a4575 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "0.9.5", "license": "MIT", "dependencies": { - "@camptocamp/ogc-client": "^1.3.0", "@ignfab/gpf-schema-store": "^0.1.3", "@rgrove/parse-xml": "^4.2.0", "https-proxy-agent": "^7.0.6", @@ -2133,9 +2132,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.16", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.16.tgz", - "integrity": "sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA==", + "version": "2.10.17", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.17.tgz", + "integrity": "sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2309,9 +2308,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001786", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001786.tgz", - "integrity": "sha512-4oxTZEvqmLLrERwxO76yfKM7acZo310U+v4kqexI2TL1DkkUEMT8UijrxxcnVdxR3qkVf5awGRX+4Z6aPHVKrA==", + "version": "1.0.30001787", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", + "integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==", "dev": true, "funding": [ { @@ -2590,9 +2589,9 @@ "license": "MIT" }, "node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", "license": "MIT", "peer": true, "engines": { @@ -2835,9 +2834,9 @@ "peer": true }, "node_modules/electron-to-chromium": { - "version": "1.5.332", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.332.tgz", - "integrity": "sha512-7OOtytmh/rINMLwaFTbcMVvYXO3AUm029X0LcyfYk0B557RlPkdpTpnH9+htMlfu5dKwOmT0+Zs2Aw+lnn6TeQ==", + "version": "1.5.334", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.334.tgz", + "integrity": "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==", "dev": true, "license": "ISC" }, @@ -5824,9 +5823,9 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -6084,13 +6083,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" diff --git a/package.json b/package.json index 0d602c9..cb89396 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,7 @@ "homepage": "https://github.com/ignfab/geocontext#readme", "scripts": { "clean:dist": "rm -rf dist", - "copy:resources": "mkdir -p dist/resources/content && cp -R src/resources/content/. dist/resources/content/", - "build": "npm run clean:dist && tsc && npx mcp-build && npm run copy:resources", + "build": "npm run clean:dist && tsc && npx mcp-build", "watch": "tsc --watch", "start": "node dist/index.js", "test": "node --no-warnings --experimental-vm-modules ./node_modules/jest/bin/jest.js", @@ -38,7 +37,6 @@ "fresh": "npm run reset && npm cache verify && npm install && npm run build && npm test" }, "dependencies": { - "@camptocamp/ogc-client": "^1.3.0", "@ignfab/gpf-schema-store": "^0.1.3", "@rgrove/parse-xml": "^4.2.0", "https-proxy-agent": "^7.0.6", diff --git a/src/gpf/adminexpress.js b/src/gpf/adminexpress.js index d50c3b9..6a367c2 100644 --- a/src/gpf/adminexpress.js +++ b/src/gpf/adminexpress.js @@ -1,6 +1,4 @@ -import _ from 'lodash'; - -import { fetchJSON } from '../helpers/http.js'; +import { fetchWfsFeatures, mapWfsFeature } from '../helpers/wfs.js'; import logger from '../logger.js'; /** @@ -27,33 +25,13 @@ export const ADMINEXPRESS_TYPES = [ * @param {(url: string) => Promise} [fetcher] * @returns {object[]} */ -export async function getAdminUnits(lon, lat, fetcher = fetchJSON) { +export async function getAdminUnits(lon, lat, fetcher) { logger.info(`[adminexpress] getAdminUnits(${lon},${lat})...`); - // note that EPSG:4326 means lat,lon order for GeoServer -> flipped coordinates... - const cql_filter = `INTERSECTS(geometrie,Point(${lat} ${lon}))`; - - // TODO : avoid useless geometry retrieval at WFS level - const url = 'https://data.geopf.fr/wfs?' + new URLSearchParams({ - service: 'WFS', - request: 'GetFeature', - typeName: ADMINEXPRESS_TYPES.map((type) => { return `ADMINEXPRESS-COG.LATEST:${type}` }).join(','), - outputFormat: 'application/json', - cql_filter: cql_filter - }).toString(); + // Using EWKT format with SRID=4326 prefix for standard lon,lat order + const cql_filter = `INTERSECTS(geometrie,SRID=4326;POINT(${lon} ${lat}))`; + const typeNames = ADMINEXPRESS_TYPES.map((type) => `ADMINEXPRESS-COG.LATEST:${type}`); - const featureCollection = await fetcher(url); - if (!Array.isArray(featureCollection?.features)) { - throw new Error("Le service ADMINEXPRESS n'a pas retourné de collection d'objets exploitable"); - } - return featureCollection.features.map((feature) => { - // parse type from id (ex: "commune.3837") - const type = feature.id.split('.')[0]; - // ignore geometry and extend properties - return Object.assign({ - type: type, - id: feature.id, - bbox: feature.bbox - }, feature.properties); - }); + const features = await fetchWfsFeatures(typeNames, cql_filter, 'ADMINEXPRESS', fetcher); + return features.map((feature) => mapWfsFeature(feature, typeNames)); } diff --git a/src/gpf/parcellaire-express.js b/src/gpf/parcellaire-express.js index 0d4aecb..cb4a274 100644 --- a/src/gpf/parcellaire-express.js +++ b/src/gpf/parcellaire-express.js @@ -1,7 +1,7 @@ import _ from 'lodash'; import distance from '../helpers/distance.js'; -import { fetchJSON } from '../helpers/http.js'; +import { fetchWfsFeatures, mapWfsFeature, toGeoJsonPoint } from '../helpers/wfs.js'; import logger from '../logger.js'; // CADASTRALPARCELS.PARCELLAIRE_EXPRESS: @@ -49,44 +49,20 @@ function filterByDistance(items){ * @param {(url: string) => Promise} [fetcher] * @returns */ -export async function getParcellaireExpress(lon, lat, fetcher = fetchJSON) { +export async function getParcellaireExpress(lon, lat, fetcher) { logger.info(`getParcellaireExpress(${lon},${lat}) ...`); - // note that EPSG:4326 means lat,lon order for GeoServer -> flipped coordinates... - const cql_filter = `DWITHIN(geom,Point(${lat} ${lon}),10,meters)`; + // Using EWKT format with SRID=4326 prefix for standard lon,lat order + const cql_filter = `DWITHIN(geom,SRID=4326;POINT(${lon} ${lat}),10,meters)`; + const typeNames = PARCELLAIRE_EXPRESS_TYPES.map((type) => `CADASTRALPARCELS.PARCELLAIRE_EXPRESS:${type}`); - const sourceGeom = { - "type": "Point", - "coordinates": [lon,lat] - }; + const sourceGeom = toGeoJsonPoint(lon, lat); - // TODO : avoid useless geometry retrieval at WFS level - const url = 'https://data.geopf.fr/wfs?' + new URLSearchParams({ - service: 'WFS', - request: 'GetFeature', - typeName: PARCELLAIRE_EXPRESS_TYPES.map((type) => { return `CADASTRALPARCELS.PARCELLAIRE_EXPRESS:${type}` }).join(','), - outputFormat: 'application/json', - cql_filter: cql_filter - }).toString(); - - const featureCollection = await fetcher(url); - if (!Array.isArray(featureCollection?.features)) { - throw new Error("Le service PARCELLAIRE_EXPRESS n'a pas retourné de collection d'objets exploitable"); - } - return filterByDistance(featureCollection.features.map((feature) => { - // parse type from id (ex: "commune.3837") - const type = feature.id.split('.')[0]; - // ignore geometry and extend properties - return Object.assign({ - type: type, - id: feature.id, - bbox: feature.bbox, - distance: distance( - sourceGeom, - feature.geometry - ), - source: PARCELLAIRE_EXPRESS_SOURCE, - }, feature.properties); - })); + const features = await fetchWfsFeatures(typeNames, cql_filter, 'PARCELLAIRE_EXPRESS', fetcher); + return filterByDistance(features.map((feature) => ({ + ...mapWfsFeature(feature, typeNames), + distance: distance(sourceGeom, feature.geometry), + source: PARCELLAIRE_EXPRESS_SOURCE, + }))); } diff --git a/src/gpf/urbanisme.js b/src/gpf/urbanisme.js index c0c1721..978bf37 100644 --- a/src/gpf/urbanisme.js +++ b/src/gpf/urbanisme.js @@ -1,5 +1,5 @@ import distance from "../helpers/distance.js"; -import { fetchJSON } from "../helpers/http.js"; +import { fetchWfsFeatures, mapWfsFeature, toGeoJsonPoint } from "../helpers/wfs.js"; import logger from "../logger.js"; // https://data.geopf.fr/wfs/ows?service=WFS&version=2.0.0&request=GetCapabilities @@ -17,7 +17,6 @@ export const URBANISME_TYPES = [ ]; export const URBANISME_SOURCE = "Géoplateforme - (WFS Géoportail de l'Urbanisme)"; -const URBANISME_INVALID_COLLECTION_ERROR = "Le service Urbanisme n'a pas retourné de collection d'objets exploitable"; const URBANISME_EXCLUDED_PROPERTIES = new Set([ 'gpu_status', @@ -47,43 +46,20 @@ function sanitizeUrbanismeItem(item) { * @param {(url: string) => Promise} [fetcher] * @returns */ -export async function getUrbanisme(lon, lat, fetcher = fetchJSON) { +export async function getUrbanisme(lon, lat, fetcher) { logger.info(`getUrbanisme(${lon},${lat})...`); - // note that EPSG:4326 means lat,lon order for GeoServer -> flipped coordinates... - const cql_filter = `DWITHIN(the_geom,Point(${lat} ${lon}),30,meters)`; + // Using EWKT format with SRID=4326 prefix for standard lon,lat order + const cql_filter = `DWITHIN(the_geom,SRID=4326;POINT(${lon} ${lat}),30,meters)`; - const sourceGeom = { - "type": "Point", - "coordinates": [lon,lat] - }; + const sourceGeom = toGeoJsonPoint(lon, lat); - // TODO : avoid useless geometry retrieval at WFS level - const url = 'https://data.geopf.fr/wfs?' + new URLSearchParams({ - service: 'WFS', - request: 'GetFeature', - typeName: URBANISME_TYPES.join(','), - outputFormat: 'application/json', - cql_filter: cql_filter - }).toString(); - - const featureCollection = await fetcher(url); - if (!Array.isArray(featureCollection?.features)) { - throw new Error(URBANISME_INVALID_COLLECTION_ERROR); - } - return featureCollection.features.map((feature) => { - // parse type from id (ex: "commune.3837") - const type = feature.id.split('.')[0]; - // ignore geometry and extend properties - const item = Object.assign({ - type: type, - id: feature.id, - bbox: feature.bbox, - distance: (distance( - sourceGeom, - feature.geometry - ) * 1000.0) - }, feature.properties); + const features = await fetchWfsFeatures(URBANISME_TYPES, cql_filter, 'Urbanisme', fetcher); + return features.map((feature) => { + const item = { + ...mapWfsFeature(feature, URBANISME_TYPES), + distance: distance(sourceGeom, feature.geometry) * 1000.0, + }; return sanitizeUrbanismeItem(item); }); } @@ -102,42 +78,17 @@ const ASSIETTES_SUP_TYPES = [ * @param {(url: string) => Promise} [fetcher] * @returns */ -export async function getAssiettesServitudes(lon, lat, fetcher = fetchJSON) { +export async function getAssiettesServitudes(lon, lat, fetcher) { logger.info(`getAssiettesServitudes(${lon},${lat})...`); - // note that EPSG:4326 means lat,lon order for GeoServer -> flipped coordinates... - const cql_filter = `DWITHIN(the_geom,Point(${lat} ${lon}),30,meters)`; - - const sourceGeom = { - "type": "Point", - "coordinates": [lon,lat] - }; + // Using EWKT format with SRID=4326 prefix for standard lon,lat order + const cql_filter = `DWITHIN(the_geom,SRID=4326;POINT(${lon} ${lat}),30,meters)`; - // TODO : avoid useless geometry retrieval at WFS level - const url = 'https://data.geopf.fr/wfs?' + new URLSearchParams({ - service: 'WFS', - request: 'GetFeature', - typeName: ASSIETTES_SUP_TYPES.join(','), - outputFormat: 'application/json', - cql_filter: cql_filter - }).toString(); + const sourceGeom = toGeoJsonPoint(lon, lat); - const featureCollection = await fetcher(url); - if (!Array.isArray(featureCollection?.features)) { - throw new Error(URBANISME_INVALID_COLLECTION_ERROR); - } - return featureCollection.features.map((feature) => { - // parse type from id (ex: "commune.3837") - const type = feature.id.split('.')[0]; - // ignore geometry and extend properties - return Object.assign({ - type: type, - id: feature.id, - bbox: feature.bbox, - distance: (distance( - sourceGeom, - feature.geometry - ) * 1000.0) - }, feature.properties); - }); + const features = await fetchWfsFeatures(ASSIETTES_SUP_TYPES, cql_filter, 'Urbanisme', fetcher); + return features.map((feature) => ({ + ...mapWfsFeature(feature, ASSIETTES_SUP_TYPES), + distance: distance(sourceGeom, feature.geometry) * 1000.0, + })); } diff --git a/src/gpf/wfs.ts b/src/gpf/wfs.ts index ec5c377..1485e47 100644 --- a/src/gpf/wfs.ts +++ b/src/gpf/wfs.ts @@ -7,6 +7,7 @@ import { MiniSearchCollectionSearchEngine, MiniSearchCollectionSearchOptions, } from '@ignfab/gpf-schema-store'; +import { z } from 'zod'; // --- Constants --- @@ -15,9 +16,6 @@ export const GPF_WFS_URL = "https://data.geopf.fr/wfs"; // Environment variable used to inject MiniSearch options at runtime (JSON string). const GPF_WFS_MINISEARCH_OPTIONS_ENV = "GPF_WFS_MINISEARCH_OPTIONS"; -// Keys accepted at the top level of the search options object. -const TOP_LEVEL_MINISEARCH_OPTION_KEYS = ["fields", "combineWith", "fuzzy", "boost"] as const; - // Shared keys used by both `fields` and `boost` in MiniSearchCollectionSearchOptions. const MINISEARCH_INDEXED_OPTION_KEYS = [ "namespace", @@ -29,14 +27,10 @@ const MINISEARCH_INDEXED_OPTION_KEYS = [ "identifierTokens", ] as const; -const MINISEARCH_FIELD_OPTION_KEYS = MINISEARCH_INDEXED_OPTION_KEYS; const MINISEARCH_COMBINE_WITH_VALUES = ["AND", "OR"] as const; -const MINISEARCH_BOOST_OPTION_KEYS = MINISEARCH_INDEXED_OPTION_KEYS; // --- Types --- -type MiniSearchFieldOptionKey = typeof MINISEARCH_FIELD_OPTION_KEYS[number]; -type MiniSearchBoostOptionKey = typeof MINISEARCH_BOOST_OPTION_KEYS[number]; type MiniSearchOptions = MiniSearchCollectionSearchOptions; // --- Errors --- @@ -50,90 +44,30 @@ export class FeatureTypeNotFoundError extends Error { // --- Helpers --- -function isPlainObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function isFiniteNumber(value: unknown): value is number { - return typeof value === "number" && Number.isFinite(value); -} - function invalidSearchOptionsError(reason: string): Error { return new Error(`Invalid ${GPF_WFS_MINISEARCH_OPTIONS_ENV}: ${reason}`); } -// --- Search options parsing --- +// --- Search options schema --- + +const miniSearchOptionsSchema = z.object({ + fields: z.array(z.enum(MINISEARCH_INDEXED_OPTION_KEYS)).optional(), + combineWith: z.enum(MINISEARCH_COMBINE_WITH_VALUES).optional(), + fuzzy: z.number().finite().optional(), + boost: z.record(z.enum(MINISEARCH_INDEXED_OPTION_KEYS), z.number().finite()).optional(), +}).strict(); // Parses and validates a plain-object value into MiniSearchCollectionSearchOptions. // Throws a descriptive error if the value has unexpected keys or wrong value types. function parseMiniSearchOptions(value: unknown): MiniSearchOptions { - if (!isPlainObject(value)) { - throw invalidSearchOptionsError("expected a JSON object"); - } - - for (const key of Object.keys(value)) { - if (!TOP_LEVEL_MINISEARCH_OPTION_KEYS.includes(key as typeof TOP_LEVEL_MINISEARCH_OPTION_KEYS[number])) { - throw invalidSearchOptionsError(`unexpected key '${key}'`); - } - } - - const options: MiniSearchOptions = {}; - - if (value.fields !== undefined) { - if (!Array.isArray(value.fields)) { - throw invalidSearchOptionsError("expected 'fields' to be an array"); - } - const fields: MiniSearchFieldOptionKey[] = []; - for (const field of value.fields) { - if (typeof field !== "string") { - throw invalidSearchOptionsError("expected every 'fields' item to be a string"); - } - if (!MINISEARCH_FIELD_OPTION_KEYS.includes(field as MiniSearchFieldOptionKey)) { - throw invalidSearchOptionsError(`unexpected value 'fields.${field}'`); - } - fields.push(field as MiniSearchFieldOptionKey); - } - options.fields = fields; + const result = miniSearchOptionsSchema.safeParse(value); + if (!result.success) { + const issue = result.error.issues[0]; + const path = issue.path.length > 0 ? issue.path.join('.') : undefined; + const detail = path ? `${path}: ${issue.message}` : issue.message; + throw invalidSearchOptionsError(detail); } - - if (value.combineWith !== undefined) { - if (typeof value.combineWith !== "string") { - throw invalidSearchOptionsError("expected 'combineWith' to be a string"); - } - const combineWith = value.combineWith as typeof MINISEARCH_COMBINE_WITH_VALUES[number]; - if (!MINISEARCH_COMBINE_WITH_VALUES.includes(combineWith)) { - throw invalidSearchOptionsError("expected 'combineWith' to be 'AND' or 'OR'"); - } - options.combineWith = combineWith; - } - - if (value.fuzzy !== undefined) { - if (!isFiniteNumber(value.fuzzy)) { - throw invalidSearchOptionsError("expected 'fuzzy' to be a finite number"); - } - options.fuzzy = value.fuzzy; - } - - if (value.boost !== undefined) { - if (!isPlainObject(value.boost)) { - throw invalidSearchOptionsError("expected 'boost' to be an object"); - } - - const boost: Partial> = {}; - for (const key of Object.keys(value.boost)) { - if (!MINISEARCH_BOOST_OPTION_KEYS.includes(key as MiniSearchBoostOptionKey)) { - throw invalidSearchOptionsError(`unexpected key 'boost.${key}'`); - } - const rawScore = value.boost[key]; - if (!isFiniteNumber(rawScore)) { - throw invalidSearchOptionsError(`expected 'boost.${key}' to be a finite number`); - } - boost[key as MiniSearchBoostOptionKey] = rawScore; - } - options.boost = boost; - } - - return options; + return result.data; } function createMiniSearchEngineOptions(miniSearch?: MiniSearchOptions) { diff --git a/src/helpers/http.js b/src/helpers/http.js index 8a542a9..953695b 100644 --- a/src/helpers/http.js +++ b/src/helpers/http.js @@ -24,11 +24,7 @@ function getChild(element, localName) { function extractXmlError(text) { try { const root = parseXml(text).children.find((child) => child instanceof XmlElement); - if (!root) { - return null; - } - - const rootName = root.name.split(":").pop(); + const rootName = root?.name.split(":").pop(); if (rootName !== "ExceptionReport" && rootName !== "ServiceExceptionReport") { return null; } @@ -129,3 +125,22 @@ export async function fetchJSON(url) { logger.debug(`[HTTP] GET ${url} : ${JSON.stringify(result)}`) return result; } + +function buildFetchOptions(method, body, headers) { + return { + ...fetchOpts, + method, + headers: new Headers({ + ...Object.fromEntries(fetchOpts.headers.entries()), + ...(headers || {}) + }), + ...(body !== undefined ? { body } : {}) + }; +} + +export async function fetchJSONPost(url, body = "", headers = {}) { + logger.info(`[HTTP] POST ${url} ...`); + const result = await fetch(url, buildFetchOptions("POST", body, headers)).then(parseJsonResponse); + logger.debug(`[HTTP] POST ${url} : ${JSON.stringify(result)}`); + return result; +} diff --git a/src/helpers/jsonSchema.ts b/src/helpers/jsonSchema.ts new file mode 100644 index 0000000..9f78575 --- /dev/null +++ b/src/helpers/jsonSchema.ts @@ -0,0 +1,8 @@ +import { toJsonSchemaCompat } from "@modelcontextprotocol/sdk/server/zod-json-schema-compat.js"; + +export function generatePublishedInputSchema(schema: any) { + return toJsonSchemaCompat(schema, { + strictUnions: true, + pipeStrategy: "input", + }); +} diff --git a/src/helpers/schemas.ts b/src/helpers/schemas.ts new file mode 100644 index 0000000..e3d800f --- /dev/null +++ b/src/helpers/schemas.ts @@ -0,0 +1,20 @@ +import { z } from "zod"; + +export const lonSchema = z + .number() + .finite() + .min(-180) + .max(180) + .describe("La longitude du point."); + +export const latSchema = z + .number() + .finite() + .min(-90) + .max(90) + .describe("La latitude du point."); + +export const featureRefSchema = z.object({ + typename: z.string().describe("Le `typename` WFS réutilisable pour une requête ultérieure."), + feature_id: z.string().describe("L'identifiant WFS réutilisable du feature."), +}); diff --git a/src/helpers/wfs.js b/src/helpers/wfs.js new file mode 100644 index 0000000..b352cf1 --- /dev/null +++ b/src/helpers/wfs.js @@ -0,0 +1,59 @@ +import { fetchJSON } from './http.js'; + +const GPF_WFS_BASE_URL = process.env.GPF_WFS_BASE_URL || 'https://data.geopf.fr/wfs'; + +/** + * Fetch features from a GPF WFS endpoint. + * + * @param {string[]} typeNames - fully qualified WFS type names + * @param {string} cqlFilter - CQL_FILTER value + * @param {string} errorLabel - service label used in the error message + * @param {(url: string) => Promise} [fetcher] + * @returns {Promise} raw GeoJSON features array + */ +export async function fetchWfsFeatures(typeNames, cqlFilter, errorLabel, fetcher = fetchJSON) { + const url = GPF_WFS_BASE_URL + '?' + new URLSearchParams({ + service: 'WFS', + request: 'GetFeature', + typeName: typeNames.join(','), + outputFormat: 'application/json', + cql_filter: cqlFilter, + }).toString(); + + const featureCollection = await fetcher(url); + if (!Array.isArray(featureCollection?.features)) { + throw new Error(`Le service ${errorLabel} n'a pas retourné de collection d'objets exploitable`); + } + return featureCollection.features; +} + +/** + * Builds a GeoJSON Point from longitude and latitude. + * + * @param {number} lon + * @param {number} lat + * @returns {object} GeoJSON Point + */ +export function toGeoJsonPoint(lon, lat) { + return { type: "Point", coordinates: [lon, lat] }; +} + +/** + * Maps a raw WFS feature into a flat result object, stripping geometry + * and resolving a reusable feature_ref from the known type names. + * + * @param {object} feature - Raw GeoJSON feature from WFS + * @param {string[]} knownTypeNames - Fully qualified WFS type names used for feature_ref resolution + * @returns {object} Flat result with type, id, bbox, optional feature_ref, and spread properties + */ +export function mapWfsFeature(feature, knownTypeNames) { + const type = feature.id.split('.')[0]; + const typename = knownTypeNames.find((t) => t.endsWith(`:${type}`)); + return { + ...feature.properties, + type, + id: feature.id, + bbox: feature.bbox, + ...(typename ? { feature_ref: { typename, feature_id: feature.id } } : {}), + }; +} diff --git a/src/resources/WfsCqlFilterResource.ts b/src/resources/WfsCqlFilterResource.ts deleted file mode 100644 index 999be91..0000000 --- a/src/resources/WfsCqlFilterResource.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { MCPResource, ResourceContent } from "mcp-framework"; -import { readFile } from "fs/promises"; -import { dirname, join } from "path"; -import { fileURLToPath } from "url"; - -class WfsCqlFilterResource extends MCPResource { - uri = "geocontext://wfs-cql-filter"; - name = "geocontext-wfs-cql-filter"; - description = "Aide-mémoire pour construire des cql_filter GeoServer utilisables avec les tools WFS."; - mimeType = "text/markdown"; - protected title = "Cheatsheet cql_filter"; - - async read(): Promise { - const text = await readFile( - join(dirname(fileURLToPath(import.meta.url)), "content", "wfs-cql-filter.md"), - "utf-8" - ); - - return [ - { - uri: this.uri, - mimeType: this.mimeType, - text, - }, - ]; - } -} - -export default WfsCqlFilterResource; diff --git a/src/resources/content/wfs-cql-filter.md b/src/resources/content/wfs-cql-filter.md deleted file mode 100644 index 4a146d0..0000000 --- a/src/resources/content/wfs-cql-filter.md +++ /dev/null @@ -1,215 +0,0 @@ -# cql_filter GeoServer - -Utiliser `cql_filter` avec `gpf_wfs_get_features` pour restreindre les objets renvoyés. - -## Checklist - -Avant d'écrire un `cql_filter` : - -1. Appeler `gpf_wfs_describe_type`. -2. Identifier le champ géométrique. -3. Lire son `defaultCrs`. -4. Si `defaultCrs = EPSG:4326`, écrire les coordonnées en `lat lon`. -5. Si `defaultCrs = EPSG:2154`, écrire les coordonnées en `x y`. -6. Fournir `cql_filter` en texte brut, sans URL encoding manuel. -7. Utiliser `cql_filter` pour choisir quels objets sont renvoyés, et `property_names` pour choisir quelles propriétés de ces objets sont renvoyées. - -## Champ géométrique - -Règles pratiques : - -- Un type WFS expose en pratique au plus un seul champ géométrique. -- Le repère le plus fiable est la présence de `defaultCrs`. -- Utiliser le nom exact du champ renvoyé par `gpf_wfs_describe_type`, par exemple `geom`, `geometrie` ou `the_geom`. -- Le `type` du champ géométrique peut être `geometry`, `point`, `linestring`, `polygon`, `multipolygon`, etc. - -Exemple : - -```json -{ - "name": "geom", - "type": "multipolygon", - "defaultCrs": "EPSG:4326" -} -``` - -## Ordre des coordonnées - -### `EPSG:4326` - -Utiliser `lat lon`. - -Exemples : - -- `POINT(48.8566 2.3522)` -- `POLYGON((48.85 2.34,48.86 2.34,48.86 2.36,48.85 2.36,48.85 2.34))` - -### `EPSG:2154` - -Utiliser `x y`, c'est-à-dire `easting northing`. - -Exemples : - -- `POINT(700000 6600000)` -- `POLYGON((700000 6600000,700500 6600000,700500 6600500,700000 6600500,700000 6600000))` - -## URL encoding - -Ne pas encoder manuellement `cql_filter`. - -Le tool `gpf_wfs_get_features` encode déjà les paramètres d'URL avec `URLSearchParams`. - -Conséquences : - -- écrire `cql_filter` sous forme lisible -- ne pas remplacer les espaces par `%20` -- ne pas encoder `(`, `)`, `,`, `'` -- si `result_type = "url"`, l'URL renvoyée sera déjà encodée - -Correct : - -```text -INTERSECTS(geom,POINT(48.8566 2.3522)) -``` - -À éviter : - -```text -INTERSECTS%28geom%2CPOINT%2848.8566%202.3522%29%29 -``` - -## Templates - -### Égalité - -```text -code_insee = '75056' -``` - -### Comparaison numérique - -```text -population > 100000 -``` - -### Liste de valeurs - -```text -nature IN ('route', 'chemin', 'sentier') -``` - -### Intervalle - -```text -surface BETWEEN 1000 AND 5000 -``` - -### LIKE - -```text -nom LIKE 'Saint-%' -``` - -### INTERSECTS avec point en `EPSG:4326` - -```text -INTERSECTS(geom,POINT(48.8566 2.3522)) -``` - -### INTERSECTS avec point en `EPSG:2154` - -```text -INTERSECTS(geom,POINT(700000 6600000)) -``` - -### BBOX recommandé en `EPSG:4326` - -```text -BBOX(geom, 48.80, 2.20, 48.90, 2.45) -``` - -### BBOX recommandé en `EPSG:2154` - -```text -BBOX(geom, 699000, 6599000, 701000, 6601000) -``` - -### POLYGON en `EPSG:4326` - -```text -INTERSECTS(geom,POLYGON((48.85 2.34,48.86 2.34,48.86 2.36,48.85 2.36,48.85 2.34))) -``` - -### POLYGON en `EPSG:2154` - -```text -INTERSECTS(geom,POLYGON((700000 6600000,700500 6600000,700500 6600500,700000 6600500,700000 6600000))) -``` - -### Distance - -```text -DWITHIN(geom,POINT(48.8566 2.3522),100,meters) -``` - -### Attributaire + spatial - -```text -type = 'hopital' AND BBOX(geom, 48.80, 2.20, 48.90, 2.45) -``` - -## Filtres attributaires - -Exemples fréquents : - -- `statut = 'actif'` -- `population >= 100000` -- `surface < 5000` -- `etat <> 'supprime'` -- `code_insee IN ('75056', '69123', '31555')` -- `population BETWEEN 100000 AND 500000` -- `nom LIKE '%Paris%'` -- `nature = 'route' AND importance >= 3` - -## Filtres spatiaux - -Bon premier choix pour un LLM : - -- préférer `BBOX(...)` quand un rectangle suffit -- utiliser `INTERSECTS(...)` pour un point ou un polygone explicite -- utiliser `DWITHIN(...)` pour une recherche par distance - -Exemples : - -- `BBOX(geom, 48.80, 2.20, 48.90, 2.45)` -- `INTERSECTS(geom,POINT(48.8566 2.3522))` -- `INTERSECTS(geom,POLYGON((48.85 2.34,48.86 2.34,48.86 2.36,48.85 2.36,48.85 2.34)))` -- `DWITHIN(geom,POINT(48.8566 2.3522),100,meters)` - -## Erreurs à éviter - -- Inventer un nom de propriété. -- Oublier d'appeler `gpf_wfs_describe_type`. -- Confondre `lat lon` et `lon lat` en `EPSG:4326`. -- Utiliser `x y` sur un type en `EPSG:4326`. -- Pré-encoder `cql_filter`. -- Utiliser `cql_filter` pour limiter les champs au lieu de `property_names`. -- Construire un `POLYGON` non fermé. - -## Bonnes pratiques pour un LLM - -- Commencer par un filtre simple. -- Préférer `BBOX(...)` si la zone de recherche est rectangulaire. -- Vérifier systématiquement `defaultCrs` avant toute géométrie. -- Utiliser des apostrophes pour les chaînes. -- Garder le filtre lisible et minimal. -- Ajouter ensuite les conditions supplémentaires si nécessaire. - -## Procédure recommandée - -1. Trouver le type avec `gpf_wfs_search_types`. -2. Lire les propriétés avec `gpf_wfs_describe_type`. -3. Identifier le champ géométrique via `defaultCrs`. -4. Choisir l'ordre des coordonnées selon le CRS. -5. Écrire un premier filtre simple. -6. Ajouter ensuite les contraintes attributaires ou spatiales. diff --git a/src/tools/AdminexpressTool.ts b/src/tools/AdminexpressTool.ts index 5a65c2f..ee802f9 100644 --- a/src/tools/AdminexpressTool.ts +++ b/src/tools/AdminexpressTool.ts @@ -2,20 +2,12 @@ import { MCPTool } from "mcp-framework"; import { z } from "zod"; import { getAdminUnits, ADMINEXPRESS_TYPES, ADMINEXPRESS_SOURCE } from "../gpf/adminexpress.js"; -import logger from "../logger.js"; import { READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js"; +import { featureRefSchema, lonSchema, latSchema } from "../helpers/schemas.js"; const adminexpressInputSchema = z.object({ - lon: z - .number() - .min(-180) - .max(180) - .describe("La longitude du point."), - lat: z - .number() - .min(-90) - .max(90) - .describe("La latitude du point."), + lon: lonSchema, + lat: latSchema, }).strict(); type AdminexpressInput = z.infer; @@ -25,6 +17,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\"`."), }) .catchall(z.unknown()); @@ -36,13 +29,12 @@ class AdminexpressTool extends MCPTool { name = "adminexpress"; title = "Unités administratives"; annotations = READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS; - 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. (source : ${ADMINEXPRESS_SOURCE}).`; + 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. (source : ${ADMINEXPRESS_SOURCE}).`; protected outputSchemaShape = adminexpressOutputSchema; schema = adminexpressInputSchema; async execute(input: AdminexpressInput) { - logger.info(`adminexpress(${input.lon},${input.lat})...`); return { results: await getAdminUnits(input.lon, input.lat), }; diff --git a/src/tools/AltitudeTool.ts b/src/tools/AltitudeTool.ts index 3d857ed..d59862b 100644 --- a/src/tools/AltitudeTool.ts +++ b/src/tools/AltitudeTool.ts @@ -2,20 +2,12 @@ import { MCPTool } from "mcp-framework"; import { z } from "zod"; import { ALTITUDE_SOURCE, getAltitudeByLocation } from "../gpf/altitude.js"; -import logger from "../logger.js"; import { READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js"; +import { lonSchema, latSchema } from "../helpers/schemas.js"; const altitudeInputSchema = z.object({ - lon: z - .number() - .min(-180) - .max(180) - .describe("La longitude du point."), - lat: z - .number() - .min(-90) - .max(90) - .describe("La latitude du point."), + lon: lonSchema, + lat: latSchema, }).strict(); type AltitudeInput = z.infer; @@ -41,7 +33,6 @@ class AltitudeTool extends MCPTool { schema = altitudeInputSchema; async execute(input: AltitudeInput) { - logger.info(`altitude(${input.lon},${input.lat})...`); return { result: await getAltitudeByLocation(input.lon, input.lat), }; diff --git a/src/tools/AssietteSupTool.ts b/src/tools/AssietteSupTool.ts index 622de4c..ceebbba 100644 --- a/src/tools/AssietteSupTool.ts +++ b/src/tools/AssietteSupTool.ts @@ -2,20 +2,12 @@ import { MCPTool } from "mcp-framework"; import { z } from "zod"; import { getAssiettesServitudes, URBANISME_SOURCE } from "../gpf/urbanisme.js"; -import logger from "../logger.js"; import { READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js"; +import { featureRefSchema, lonSchema, latSchema } from "../helpers/schemas.js"; const assietteSupInputSchema = z.object({ - lon: z - .number() - .min(-180) - .max(180) - .describe("La longitude du point."), - lat: z - .number() - .min(-90) - .max(90) - .describe("La latitude du point."), + lon: lonSchema, + lat: latSchema, }).strict(); type AssietteSupInput = z.infer; @@ -25,6 +17,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(), distance: z.number().describe("La distance entre le point demandé et l'assiette retenue."), }) .catchall(z.unknown()); @@ -37,13 +30,12 @@ class AssietteSupTool extends MCPTool { name = "assiette_sup"; title = "Servitudes d’utilité publique"; annotations = READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS; - description = `Renvoie, pour un point donné par sa longitude et sa latitude, la liste des assiettes de servitudes d'utilité publique (SUP) pertinentes à proximité, avec leurs propriétés associées. Les résultats peuvent inclure des assiettes ponctuelles, linéaires ou surfaciques. (source : ${URBANISME_SOURCE}).`; + description = `Renvoie, pour un point donné par sa longitude et sa latitude, la liste des assiettes de servitudes d'utilité publique (SUP) pertinentes à proximité, avec leurs propriétés associées. Les résultats peuvent inclure des assiettes ponctuelles, linéaires ou surfaciques et exposent un \`feature_ref\` WFS réutilisable quand il est disponible. (source : ${URBANISME_SOURCE}).`; protected outputSchemaShape = assietteSupOutputSchema; schema = assietteSupInputSchema; async execute(input: AssietteSupInput) { - logger.info(`assiette_sup(${input.lon},${input.lat})...`); return { results: await getAssiettesServitudes(input.lon, input.lat), }; diff --git a/src/tools/CadastreTool.ts b/src/tools/CadastreTool.ts index 91c470f..7910f1d 100644 --- a/src/tools/CadastreTool.ts +++ b/src/tools/CadastreTool.ts @@ -2,20 +2,12 @@ import { MCPTool } from "mcp-framework"; import { z } from "zod"; import { getParcellaireExpress, PARCELLAIRE_EXPRESS_TYPES, PARCELLAIRE_EXPRESS_SOURCE } from "../gpf/parcellaire-express.js"; -import logger from "../logger.js"; import { READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js"; +import { featureRefSchema, lonSchema, latSchema } from "../helpers/schemas.js"; const cadastreInputSchema = z.object({ - lon: z - .number() - .min(-180) - .max(180) - .describe("La longitude du point."), - lat: z - .number() - .min(-90) - .max(90) - .describe("La latitude du point."), + lon: lonSchema, + lat: latSchema, }).strict(); type CadastreInput = z.infer; @@ -25,6 +17,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\"`."), distance: z.number().describe("La distance entre le point demandé et l'objet cadastral retenu."), source: z.string().describe("La source des données cadastrales."), }) @@ -38,13 +31,12 @@ class CadastreTool extends MCPTool { name = "cadastre"; title = "Informations cadastrales"; annotations = READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS; - 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. (source : ${PARCELLAIRE_EXPRESS_SOURCE}).`; + 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. (source : ${PARCELLAIRE_EXPRESS_SOURCE}).`; protected outputSchemaShape = cadastreOutputSchema; schema = cadastreInputSchema; async execute(input: CadastreInput) { - logger.info(`cadastre(${input.lon},${input.lat})...`); return { results: await getParcellaireExpress(input.lon, input.lat), }; diff --git a/src/tools/GeocodeTool.ts b/src/tools/GeocodeTool.ts index 5b7ed79..f183993 100644 --- a/src/tools/GeocodeTool.ts +++ b/src/tools/GeocodeTool.ts @@ -2,7 +2,6 @@ import { MCPTool } from "mcp-framework"; import { z } from "zod"; import { geocode, GEOCODE_SOURCE } from "../gpf/geocode.js"; -import logger from "../logger.js"; import { READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js"; const geocodeInputSchema = z.object({ @@ -45,7 +44,6 @@ class GeocodeTool extends MCPTool { schema = geocodeInputSchema; async execute(input: GeocodeInput) { - logger.info(`geocode(${input.text}, ${input.maximumResponses ?? 3})...`); return { results: await geocode(input.text, input.maximumResponses), }; diff --git a/src/tools/GpfWfsGetFeaturesTool.ts b/src/tools/GpfWfsGetFeaturesTool.ts index acfe893..fbbd269 100644 --- a/src/tools/GpfWfsGetFeaturesTool.ts +++ b/src/tools/GpfWfsGetFeaturesTool.ts @@ -1,86 +1,52 @@ import { MCPTool } from "mcp-framework"; -import { z } from "zod"; +import type { Collection } from "@ignfab/gpf-schema-store"; -import { GPF_WFS_URL } from "../gpf/wfs.js"; -import { fetchJSON } from "../helpers/http.js"; +import { wfsClient } from "../gpf/wfs.js"; +import { fetchJSONPost } from "../helpers/http.js"; import logger from "../logger.js"; import { READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js"; - -// This tool intentionally does not expose a single outputSchemaShape. -// - `result_type="results"` can return a large FeatureCollection, and we -// avoid duplicating that payload into structuredContent. -// - Compact modes (`hits`, `url`) are handled explicitly in createSuccessResponse(). - -const gpfWfsGetFeaturesInputSchema = z.object({ - typename: z - .string() - .trim() - .min(1, "le nom du type ne doit pas être vide") - .describe("L'identifiant exact du type WFS à interroger (ex : `BDTOPO_V3:batiment`). Ce paramètre détermine la collection interrogée et doit correspondre à un type valide. Utiliser `gpf_wfs_search_types` pour trouver un `typename` pertinent, puis `gpf_wfs_describe_type` pour inspecter ses propriétés avant la requête."), - property_names: z - .string() - .optional() - .describe("La liste des propriétés à inclure dans chaque objet renvoyé, séparées par des virgules (ex : \"code_insee,nom_officiel,geometrie\"). Ce paramètre limite les champs présents dans la réponse, sans filtrer les objets eux-mêmes. Les noms doivent correspondre exactement aux propriétés du type WFS ; utiliser `gpf_wfs_describe_type` pour les connaître."), - sort_by: z - .string() - .optional() - .describe("Les propriétés à utiliser pour trier les objets renvoyés, avec la syntaxe `field [A|D]` où `A` signifie tri ascendant et `D` tri descendant. Plusieurs critères peuvent être séparés par des virgules (ex : `nom_officiel A, population D`). Les noms doivent correspondre exactement aux propriétés du type WFS ; utiliser `gpf_wfs_describe_type` pour les connaître."), - count: z - .number() - .int() - .min(1) - .max(1000) - .optional() - .describe("Le nombre maximum d'objets à retourner dans la réponse (entre 1 et 1000). Ce paramètre limite les résultats renvoyés, sans modifier le nombre total d'objets correspondant à la requête. Il est surtout utile avec `result_type=\"results\"`."), - cql_filter: z - .string() - .optional() - .describe([ - "Un filtre `cql_filter` GeoServer pour restreindre les objets renvoyés par la requête.", - "Il faut utiliser les noms exacts des propriétés du type WFS ; utiliser `gpf_wfs_describe_type` pour les connaître.", - "Attention : en `EPSG:4326`, les coordonnées des géométries doivent être exprimées en `lat lon` (y x), y compris pour les points, lignes et polygones.", - "Exemples :", - "- filtre attributaire : `code_insee = '75056'`", - "- filtre spatial point : `DWITHIN(geom,Point(48.8566 2.3522),100,meters)`", - "- filtre spatial polygone : `INTERSECTS(geom,POLYGON((48.85 2.34,48.86 2.34,48.86 2.36,48.85 2.36,48.85 2.34)))`", - ].join("\r\n")), - result_type: z - .enum(["results", "hits", "url"]) - .optional() - .describe([ - "Choisit le type de résultat renvoyé par le tool :", - "- `results` : retourne les objets trouvés sous forme de `FeatureCollection` GeoJSON complète (défaut)", - "- `hits` : retourne uniquement le nombre total d'objets correspondant à la requête", - "- `url` : retourne uniquement l'URL WFS construite pour la requête, utile pour inspection, débogage ou réutilisation côté client", - ].join("\r\n")) -}).strict(); - -type GpfWfsGetFeaturesInput = z.infer; - -const gpfWfsGetFeaturesHitsOutputSchema = z.object({ - result_type: z.literal("hits").describe("Indique que la réponse contient uniquement un comptage."), - totalFeatures: z.number().describe("Le nombre total d'objets correspondant à la requête."), -}); - -const gpfWfsGetFeaturesUrlOutputSchema = z.object({ - result_type: z.literal("url").describe("Indique que la réponse contient uniquement l'URL de la requête."), - url: z.string().describe("L'URL WFS générée pour la requête."), -}); +import { compileQueryParts, geometryToEwkt, getGeometryProperty, getSpatialFilter } from "./gpfWfsGetFeatures/compile.js"; +import { buildMainRequest, buildReferenceGeometryRequest, type CompiledRequest } from "./gpfWfsGetFeatures/request.js"; +import { transformFeatureCollectionResponse } from "./gpfWfsGetFeatures/response.js"; +import { + gpfWfsGetFeaturesHitsOutputSchema, + gpfWfsGetFeaturesInputSchema, + type GpfWfsGetFeaturesInput, + gpfWfsGetFeaturesPublishedInputSchema, + gpfWfsGetFeaturesRequestOutputSchema, +} from "./gpfWfsGetFeatures/schema.js"; class GpfWfsGetFeaturesTool extends MCPTool { name = "gpf_wfs_get_features"; title = "Lecture d’objets WFS"; annotations = READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS; description = [ - "Récupère les objets d'un type WFS à partir d'un `typename` valide, avec filtres, tri et sélection de propriétés optionnels.", - "Exécute une requête WFS sur un type connu (`typename`) et renvoie soit les objets trouvés, soit leur nombre total, soit l'URL WFS correspondante.", - "Utiliser `gpf_wfs_search_types` puis `gpf_wfs_describe_type` avant ce tool lorsque le type ou ses propriétés ne sont pas connus.", - "Le paramètre `result_type` permet de récupérer soit les données complètes (`results`), soit uniquement le comptage (`hits`), soit l'URL WFS générée (`url`).", - "Les paramètres optionnels permettent de filtrer, trier ou restreindre les champs et le nombre d'objets renvoyés.", + "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.", + "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 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`.", ].join("\r\n"); schema = gpfWfsGetFeaturesInputSchema; + /** + * Exposes an input schema variant that stays compatible with most MCP integrations. + * + * @returns The published input schema exposed through the MCP tool definition. + */ + get inputSchema() { + return gpfWfsGetFeaturesPublishedInputSchema; + } + + /** + * Formats compact responses (`hits`, `request`) into `structuredContent`. + * Full result sets are still delegated to the framework default behavior. + * + * @param data Raw execution result returned by the tool implementation. + * @returns An MCP success response, optionally enriched with structured content. + */ protected createSuccessResponse(data: unknown) { if ( typeof data === "object" && @@ -91,12 +57,7 @@ class GpfWfsGetFeaturesTool extends MCPTool { typeof data.totalFeatures === "number" ) { return { - content: [ - { - type: "text" as const, - text: JSON.stringify(data.totalFeatures), - }, - ], + content: [{ type: "text" as const, text: JSON.stringify(data.totalFeatures) }], structuredContent: gpfWfsGetFeaturesHitsOutputSchema.parse(data), }; } @@ -105,79 +66,176 @@ class GpfWfsGetFeaturesTool extends MCPTool { typeof data === "object" && data !== null && "result_type" in data && - data.result_type === "url" && - "url" in data && - typeof data.url === "string" + data.result_type === "request" ) { return { - content: [ - { - type: "text" as const, - text: data.url, - }, - ], - structuredContent: gpfWfsGetFeaturesUrlOutputSchema.parse(data), + content: [{ type: "text" as const, text: JSON.stringify(data) }], + structuredContent: gpfWfsGetFeaturesRequestOutputSchema.parse(data), }; } return super.createSuccessResponse(data); } - async execute(input: GpfWfsGetFeaturesInput) { - const params: Record = { - service: 'WFS', - request: 'GetFeature', - typeName: input.typename, - outputFormat: 'application/json' - }; - - // Only add optional parameters if they are defined - if (input.cql_filter) { - params.cql_filter = input.cql_filter; + /** + * Loads a WFS feature type description from the embedded catalog. + * + * @param typename Exact WFS typename to load from the embedded schema store. + * @returns The matching feature type description. + */ + protected async getFeatureType(typename: string) { + return wfsClient.getFeatureType(typename); + } + + /** + * Executes a compiled WFS request as POST and returns the JSON FeatureCollection. + * + * @param request Compiled request split into query-string parameters and POST body. + * @returns The parsed JSON response returned by the WFS endpoint. + */ + protected async fetchFeatureCollection(request: CompiledRequest) { + const url = `${request.url}?${new URLSearchParams(request.query).toString()}`; + return fetchJSONPost(url, request.body, { + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + }); + } + + /** + * Extracts a result count from a WFS response, preferring `numberMatched`. + * Explicitly rejects responses that do not provide a usable total. + * + * @param featureCollection Parsed WFS response object. + * @returns The total number of matching features. + */ + protected getMatchedFeatureCount(featureCollection: Record) { + if (typeof featureCollection.numberMatched === "number") { + return featureCollection.numberMatched; } - if (input.count) { - params.count = String(input.count); + if (featureCollection.numberMatched === "unknown") { + throw new Error("Le service WFS a renvoyé un comptage indéterminé (numberMatched=\"unknown\")."); } - if (input.sort_by) { - params.sortBy = input.sort_by; + if (typeof featureCollection.totalFeatures === "number") { + return featureCollection.totalFeatures; } - if (input.property_names && input.property_names.length > 0) { - params.propertyName = input.property_names; + throw new Error("Le service WFS n'a pas retourné de comptage exploitable"); + } + + /** + * Enriches transformed features with a complete `feature_ref`, reusable + * in particular by `intersects_feature`. + * + * @param featureCollection Raw WFS FeatureCollection response. + * @param typename Typename of the main queried layer. + * @returns The transformed FeatureCollection with fully populated feature references. + */ + protected attachFeatureRefs(featureCollection: Record, typename: string) { + const transformed = transformFeatureCollectionResponse(featureCollection) as Record; + + if (!Array.isArray(transformed.features)) { + return transformed; } - // Si result_type est 'hits', on utilise count=1 pour récupérer juste le totalFeatures - // On fait cela parce que Geoserver ne renvoie pas de json avec resultType=hits - // On est obligé de faire une requete getfeature pour récupérer le totalFeatures... - if (input.result_type === 'hits') { - params.count = "1"; - // On n'a pas besoin des propriétés détaillées pour un comptage - delete params.propertyName; + transformed.features = transformed.features.map((feature) => { + if (typeof feature !== "object" || feature === null || !("feature_ref" in feature)) { + return feature; + } + const featureRef = feature.feature_ref; + if (typeof featureRef !== "object" || featureRef === null) { + return feature; + } + return { + ...feature, + feature_ref: { + ...featureRef, + typename, + }, + }; + }); + + return transformed; + } + + /** + * Resolves the geometry of a reference feature when `intersects_feature` is used, + * then converts it to EWKT for CQL compilation. + * + * @param input Normalized tool input. + * @returns The resolved reference geometry, or `undefined` when no reference feature is needed. + */ + protected async resolveIntersectsFeatureGeometry(input: GpfWfsGetFeaturesInput) { + const spatialFilter = getSpatialFilter(input); + if (!spatialFilter || spatialFilter.operator !== "intersects_feature") { + return undefined; } - - const url = `${GPF_WFS_URL}?` + new URLSearchParams(params).toString(); - logger.info(`[gpf_wfs_get_features] ${url}`); + const referenceFeatureType = await this.getFeatureType(spatialFilter.typename); + const referenceGeometryProperty = getGeometryProperty(referenceFeatureType); + const request = buildReferenceGeometryRequest( + spatialFilter.typename, + spatialFilter.feature_id, + referenceGeometryProperty.name + ); + const featureCollection = await this.fetchFeatureCollection(request); + const referenceFeature = Array.isArray(featureCollection?.features) ? featureCollection.features[0] : undefined; + if (!referenceFeature) { + throw new Error(`Le feature de référence '${spatialFilter.feature_id}' est introuvable dans '${spatialFilter.typename}'.`); + } + if (!referenceFeature?.geometry) { + throw new Error(`Le feature de référence '${spatialFilter.feature_id}' n'a pas de géométrie exploitable.`); + } - if ( input.result_type === 'url' ) { + return { + typename: spatialFilter.typename, + feature_id: spatialFilter.feature_id, + geometry_ewkt: geometryToEwkt(referenceFeature.geometry), + }; + } + + /** + * Orchestrates the full tool execution flow: + * catalog lookup -> compilation -> WFS request -> response post-processing. + * + * @param input Normalized tool input. + * @returns Either a compiled request, a hit count, or a transformed FeatureCollection. + */ + async execute(input: GpfWfsGetFeaturesInput) { + const featureType: Collection = await this.getFeatureType(input.typename); + const resolvedGeometryRef = await this.resolveIntersectsFeatureGeometry(input); + const compiled = compileQueryParts(input, featureType, resolvedGeometryRef); + const request = buildMainRequest(input, compiled); + logger.info(`[gpf_wfs_get_features] POST ${request.url}?${new URLSearchParams(request.query).toString()}`); + + if (input.result_type === "request") { return { - result_type: "url" as const, - url, + result_type: "request" as const, + method: request.method, + url: request.url, + query: request.query, + body: request.body, + get_url: request.get_url ?? null, }; } - const featureCollection = await fetchJSON(url); - - if (input.result_type === 'hits') { - if (typeof featureCollection?.totalFeatures !== "number") { - throw new Error("Le service WFS n'a pas retourné de comptage exploitable"); + let featureCollection: any; + try { + featureCollection = await this.fetchFeatureCollection(request); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes(`Illegal property name: ${compiled.geometryProperty.name}`)) { + throw new Error(`Le champ géométrique '${compiled.geometryProperty.name}' issu du catalogue embarqué est rejeté par le WFS live pour '${input.typename}'. Le catalogue embarqué est probablement désynchronisé. Détail : ${message}`); } + throw error; + } + + if (input.result_type === "hits") { return { result_type: "hits" as const, - totalFeatures: featureCollection.totalFeatures, + totalFeatures: this.getMatchedFeatureCount(featureCollection), }; } - return featureCollection; + return this.attachFeatureRefs(featureCollection, input.typename); } } diff --git a/src/tools/GpfWfsListTypesTool.ts b/src/tools/GpfWfsListTypesTool.ts deleted file mode 100644 index e9c1cc9..0000000 --- a/src/tools/GpfWfsListTypesTool.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { MCPTool } from "mcp-framework"; -import { z } from "zod"; - -import { READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js"; -import { wfsClient } from "../gpf/wfs.js"; - -const gpfWfsListTypesInputSchema = z.object({}).strict(); - -type GpfWfsListTypesInput = z.infer; - -class GpfWfsListTypesTool extends MCPTool { - name = "gpf_wfs_list_types"; - title = "Liste complète des types WFS"; - annotations = READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS; - description = [ - "Renvoie la liste complète des types WFS de la Géoplateforme (GPF).", - "Utiliser ce tool pour un inventaire exhaustif ou une exploration globale du catalogue.", - "Pour trouver rapidement un type pertinent à partir de mots-clés, utiliser de préférence gpf_wfs_search_types.", - ].join("\r\n"); - schema = gpfWfsListTypesInputSchema; - - async execute(input: GpfWfsListTypesInput) { - const featureTypes = await wfsClient.getFeatureTypes(); - return featureTypes.map((featureType) => ({ - id: featureType.id, - title: featureType.title, - description: featureType.description, - })); - } -} - -export default GpfWfsListTypesTool; diff --git a/src/tools/UrbanismeTool.ts b/src/tools/UrbanismeTool.ts index 28bd467..fb7b8f4 100644 --- a/src/tools/UrbanismeTool.ts +++ b/src/tools/UrbanismeTool.ts @@ -2,20 +2,12 @@ import { MCPTool } from "mcp-framework"; import { z } from "zod"; import { getUrbanisme, URBANISME_SOURCE } from "../gpf/urbanisme.js"; -import logger from "../logger.js"; import { READ_ONLY_OPEN_WORLD_TOOL_ANNOTATIONS } from "../helpers/toolAnnotations.js"; +import { featureRefSchema, lonSchema, latSchema } from "../helpers/schemas.js"; const urbanismeInputSchema = z.object({ - lon: z - .number() - .min(-180) - .max(180) - .describe("La longitude du point."), - lat: z - .number() - .min(-90) - .max(90) - .describe("La latitude du point."), + lon: lonSchema, + lat: latSchema, }).strict(); type UrbanismeInput = z.infer; @@ -25,6 +17,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(), distance: z.number().describe("La distance entre le point demandé et l'objet d'urbanisme retenu."), }) .catchall(z.unknown()); @@ -36,6 +29,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\"`.", "Modèles d'URL Géoportail de l'Urbanisme :", "- fiche document: https://www.geoportail-urbanisme.gouv.fr/document/by-id/{gpu_doc_id}", "- carte: https://www.geoportail-urbanisme.gouv.fr/map/?documentId={gpu_doc_id}", @@ -53,7 +47,6 @@ class UrbanismeTool extends MCPTool { schema = urbanismeInputSchema; async execute(input: UrbanismeInput) { - logger.info(`urbanisme(${input.lon},${input.lat})...`); return { results: await getUrbanisme(input.lon, input.lat), }; diff --git a/src/tools/gpfWfsGetFeatures/compile.ts b/src/tools/gpfWfsGetFeatures/compile.ts new file mode 100644 index 0000000..50b26d0 --- /dev/null +++ b/src/tools/gpfWfsGetFeatures/compile.ts @@ -0,0 +1,677 @@ +// --- Imports --- + +import type { Collection, CollectionProperty } from "@ignfab/gpf-schema-store"; + +import type { + GpfWfsGetFeaturesInput, + OrderByClause, + SpatialFilter, + WhereClause, +} from "./schema.js"; + +// --- Local Types --- + +type ScalarValue = string | number | boolean; +type NormalizedWhereClause = + | { property: string; operator: "eq" | "ne"; value: ScalarValue } + | { property: string; operator: "lt" | "lte" | "gt" | "gte"; value: number | string } + | { property: string; operator: "in"; values: ScalarValue[] } + | { property: string; operator: "is_null" }; + +// --- Constants --- + +const SCALAR_COMPARISON_OPERATORS = { + eq: "=", + ne: "<>", +} as const; + +const NUMERIC_COMPARISON_OPERATORS = { + lt: "<", + lte: "<=", + gt: ">", + gte: ">=", +} as const; + +const ORDER_DIRECTION_TO_WFS = { + asc: "A", + desc: "D", +} as const; + +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; + +export type ResolvedFeatureGeometryRef = { + typename: string; + feature_id: string; + geometry_ewkt: string; +}; + +export type CompiledQuery = { + geometryProperty: CollectionProperty; + cqlFilter?: string; + propertyName?: string; + sortBy?: string; +}; + +// --- Property Helpers --- + +/** + * Lists available property names for a feature type, mainly for error reporting. + * + * @param featureType Feature type definition loaded from the embedded catalog. + * @returns A comma-separated list of property names. + */ +function getPropertyList(featureType: Collection) { + return featureType.properties.map((property) => property.name).join(", "); +} + +/** + * Escapes a string literal so it can be embedded safely in a CQL string value. + * + * @param value Raw string value. + * @returns The escaped string value. + */ +function escapeStringLiteral(value: string) { + return value.replace(/'/g, "''"); +} + +/** + * Formats a scalar value as a CQL literal. + * + * @param value Scalar value already normalized for its target property. + * @returns A CQL-ready literal representation. + */ +function formatScalarValue(value: ScalarValue) { + if (typeof value === "string") { + return `'${escapeStringLiteral(value)}'`; + } + if (typeof value === "boolean") { + return value ? "TRUE" : "FALSE"; + } + return String(value); +} + +//TODO : this is not really robust +/** + * Checks whether a property should be treated as boolean for value coercion. + * + * @param property Property metadata from the embedded catalog. + * @returns `true` when the property type is recognized as boolean-like. + */ +function isBooleanProperty(property: CollectionProperty) { + return ["boolean", "bool"].includes(property.type.toLowerCase()); +} + +/** + * Checks whether a property should be treated as an integer for value coercion. + * + * @param property Property metadata from the embedded catalog. + * @returns `true` when the property type is recognized as integer-like. + */ +function isIntegerProperty(property: CollectionProperty) { + return ["integer", "long", "short"].includes(property.type.toLowerCase()); +} + +/** + * Checks whether a property should be treated as numeric for value coercion. + * + * @param property Property metadata from the embedded catalog. + * @returns `true` when the property type is recognized as numeric-like. + */ +function isNumericProperty(property: CollectionProperty) { + return ["integer", "number", "float", "double", "decimal", "long", "short", "numeric"].includes(property.type.toLowerCase()); +} + +/** + * Checks whether a property should be treated as a date for value coercion. + * + * @param property Property metadata from the embedded catalog. + * @returns `true` when the property type or name suggests a date-like value. + */ +function isDateProperty(property: CollectionProperty) { + const type = property.type.toLowerCase(); + return ["date", "datetime", "timestamp", "timestamptz"].includes(type) || property.name.startsWith("date_"); +} + +/** + * Parses a serialized numeric value and rejects non-finite numbers. + * + * @param value Serialized numeric value. + * @param message Error message to throw when parsing fails. + * @returns The parsed numeric value. + */ +function parseNumericString(value: string, message: string) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + throw new Error(message); + } + return parsed; +} + +/** + * Parses a serialized integer value and rejects non-integer numbers. + * + * @param value Serialized integer value. + * @param message Error message to throw when parsing fails. + * @returns The parsed integer value. + */ +function parseIntegerString(value: string, message: string) { + const parsed = Number(value); + if (!Number.isInteger(parsed)) { + throw new Error(message); + } + return parsed; +} + +/** + * Validates a serialized date value using JavaScript date parsing. + * + * @param value Serialized date value. + * @param message Error message to throw when parsing fails. + * @returns The original value once validated. + */ +function parseDateString(value: string, message: string) { + if (!Number.isFinite(Date.parse(value))) { + throw new Error(message); + } + return value; +} + +/** + * Parses a serialized boolean value accepted by the tool contract. + * + * @param value Serialized boolean value. + * @param message Error message to throw when parsing fails. + * @returns The parsed boolean value. + */ +function parseBooleanString(value: string, message: string) { + if (value === "true") { + return true; + } + if (value === "false") { + return false; + } + throw new Error(message); +} + +/** + * Returns every geometry-like property exposed by a feature type. + * + * @param featureType Feature type definition loaded from the embedded catalog. + * @returns The list of properties carrying a `defaultCrs`. + */ +function getGeometryProperties(featureType: Collection) { + return featureType.properties.filter((property) => property.defaultCrs); +} + +/** + * Resolves the single geometry property expected by the query compiler. + * + * @param featureType Feature type definition loaded from the embedded catalog. + * @returns The unique geometry property for the feature type. + */ +export function getGeometryProperty(featureType: Collection) { + const geometryProperties = getGeometryProperties(featureType); + if (geometryProperties.length === 0) { + throw new Error(`Le type '${featureType.id}' n'expose aucune propriété géométrique exploitable dans le catalogue embarqué.`); + } + if (geometryProperties.length > 1) { + throw new Error(`Le type '${featureType.id}' expose plusieurs propriétés géométriques dans le catalogue embarqué : ${geometryProperties.map((property) => property.name).join(", ")}.`); + } + return geometryProperties[0]; +} + +/** + * Loads a property by exact name and throws a descriptive error when it does not exist. + * + * @param featureType Feature type definition loaded from the embedded catalog. + * @param propertyName Exact property name requested by the caller. + * @returns The matching property metadata. + */ +function getPropertyOrThrow(featureType: Collection, propertyName: string) { + const property = featureType.properties.find((candidate) => candidate.name === propertyName); + if (!property) { + throw new Error(`La propriété '${propertyName}' n'existe pas pour '${featureType.id}'. Utiliser une propriété parmi : ${getPropertyList(featureType)}.`); + } + return property; +} + +/** + * Ensures that a property exists and is not the geometry column of the feature type. + * + * @param featureType Feature type definition loaded from the embedded catalog. + * @param geometryProperty Geometry property already resolved for the feature type. + * @param propertyName Exact property name requested by the caller. + * @param message Error message template used when the property is geometric. + * @returns The matching non-geometric property metadata. + */ +function ensureNonGeometryProperty(featureType: Collection, geometryProperty: CollectionProperty, propertyName: string, message: string) { + const property = getPropertyOrThrow(featureType, propertyName); + if (property.name === geometryProperty.name || property.defaultCrs) { + throw new Error(message.replace("{property}", property.name)); + } + return property; +} + +/** + * Extracts the single serialized value required by operators such as `eq` or `gt`. + * + * @param filter Raw where clause. + * @param message Error message to throw when the clause shape is invalid. + * @returns The serialized scalar value carried by the clause. + */ +function getSingleStringValue(filter: WhereClause, message: string) { + if (typeof filter.value !== "string" || filter.values !== undefined) { + throw new Error(message); + } + return filter.value; +} + +// --- Value Coercion --- + +/** + * Ensures that ordered comparison operators only target numeric-like or date-like properties. + * + * @param property Property metadata from the embedded catalog. + * @param operator Ordered comparison operator being compiled. + * @returns Nothing. Throws when the property type is incompatible with ordered comparisons. + */ +function ensureNumericProperty(property: CollectionProperty, operator: keyof typeof NUMERIC_COMPARISON_OPERATORS) { + if (!isNumericProperty(property) && !isDateProperty(property)) { + throw new Error(`L'opérateur '${operator}' n'est supporté que pour une propriété numérique ou de date. '${property.name}' est de type '${property.type}'.`); + } +} + +/** + * Coerces a serialized scalar value according to the target property metadata. + * + * @param property Property metadata from the embedded catalog. + * @param value Serialized scalar value received from the tool input. + * @returns A normalized scalar value ready for CQL formatting. + */ +function coerceScalarValueForProperty(property: CollectionProperty, value: string) { + if (isIntegerProperty(property)) { + return parseIntegerString(value, `La propriété '${property.name}' exige une valeur entière sérialisée en texte.`); + } + if (isNumericProperty(property)) { + return parseNumericString(value, `La propriété '${property.name}' exige une valeur numérique sérialisée en texte.`); + } + if (isBooleanProperty(property)) { + return parseBooleanString(value, `La propriété '${property.name}' exige une valeur booléenne sérialisée en texte ('true' ou 'false').`); + } + if (isDateProperty(property)) { + return parseDateString(value, `La propriété '${property.name}' exige une date sérialisée en texte valide.`); + } + if (Array.isArray(property.enum) && property.enum.length > 0 && !property.enum.includes(value)) { + throw new Error(`La propriété '${property.name}' exige une valeur parmi : ${property.enum.join(", ")}.`); + } + return value; +} + +/** + * Coerces a serialized value used by ordered comparison operators. + * + * @param property Property metadata from the embedded catalog. + * @param value Serialized scalar value received from the tool input. + * @param operator Ordered comparison operator being compiled. + * @returns A normalized date or numeric value ready for CQL formatting. + */ +function coerceOrderedValueForProperty(property: CollectionProperty, value: string, operator: keyof typeof NUMERIC_COMPARISON_OPERATORS) { + ensureNumericProperty(property, operator); + if (isDateProperty(property)) { + return parseDateString(value, `L'opérateur '${operator}' exige une propriété \`value\` date valide.`); + } + if (isIntegerProperty(property)) { + return parseIntegerString(value, `L'opérateur '${operator}' exige une propriété \`value\` entière.`); + } + return parseNumericString(value, `L'opérateur '${operator}' exige une propriété \`value\` numérique.`); +} + +/** + * Validates and normalizes a raw where clause into a shape that is easier to compile. + * + * @param property Property metadata targeted by the clause. + * @param clause Raw where clause received from the tool input. + * @returns A normalized where clause with coerced values. + */ +function normalizeWhereClause(property: CollectionProperty, clause: WhereClause): NormalizedWhereClause { + switch (clause.operator) { + case "eq": + case "ne": + return { + property: clause.property, + operator: clause.operator, + value: coerceScalarValueForProperty(property, getSingleStringValue(clause, `L'opérateur '${clause.operator}' exige exactement une propriété \`value\`.`)), + }; + case "lt": + case "lte": + case "gt": + case "gte": + return { + property: clause.property, + operator: clause.operator, + value: coerceOrderedValueForProperty(property, getSingleStringValue(clause, `L'opérateur '${clause.operator}' exige exactement une propriété \`value\`.`), clause.operator), + }; + case "in": + if (clause.value !== undefined || !Array.isArray(clause.values) || clause.values.length === 0 || !clause.values.every((value) => typeof value === "string")) { + throw new Error("L'opérateur 'in' exige une propriété `values` non vide."); + } + return { + property: clause.property, + operator: "in", + values: clause.values.map((value) => coerceScalarValueForProperty(property, value)), + }; + case "is_null": + if (clause.value !== undefined || clause.values !== undefined) { + throw new Error("L'opérateur 'is_null' n'accepte ni `value` ni `values`."); + } + return { + property: clause.property, + operator: "is_null", + }; + } +} + +// --- Attribute Compilation --- + +/** + * Compiles a structured where clause into a CQL fragment. + * + * @param featureType Feature type definition loaded from the embedded catalog. + * @param geometryProperty Geometry property already resolved for the feature type. + * @param clause Raw where clause received from the tool input. + * @returns A CQL predicate fragment. + */ +function compileWhereClause(featureType: Collection, geometryProperty: CollectionProperty, clause: WhereClause) { + const property = ensureNonGeometryProperty( + featureType, + geometryProperty, + clause.property, + "La propriété '{property}' est géométrique. Utiliser `spatial_operator` et ses paramètres dédiés." + ); + const normalized = normalizeWhereClause(property, clause); + + switch (normalized.operator) { + case "eq": + case "ne": + return `${property.name} ${SCALAR_COMPARISON_OPERATORS[normalized.operator]} ${formatScalarValue(normalized.value)}`; + case "lt": + case "lte": + case "gt": + case "gte": + return `${property.name} ${NUMERIC_COMPARISON_OPERATORS[normalized.operator]} ${formatScalarValue(normalized.value)}`; + case "in": + return `${property.name} IN (${normalized.values.map(formatScalarValue).join(", ")})`; + case "is_null": + return `${property.name} IS NULL`; + } +} + +/** + * Compiles a structured sort clause into a WFS `sortBy` fragment. + * + * @param featureType Feature type definition loaded from the embedded catalog. + * @param geometryProperty Geometry property already resolved for the feature type. + * @param clause Raw order-by clause received from the tool input. + * @returns A WFS `sortBy` fragment. + */ +function compileOrderByClause(featureType: Collection, geometryProperty: CollectionProperty, clause: OrderByClause) { + const property = ensureNonGeometryProperty( + featureType, + geometryProperty, + clause.property, + "La propriété '{property}' est géométrique. Utiliser une propriété non géométrique pour `order_by`." + ); + return `${property.name} ${ORDER_DIRECTION_TO_WFS[clause.direction]}`; +} + +/** + * Validates a selected property name and returns the exact property name to expose. + * + * @param featureType Feature type definition loaded from the embedded catalog. + * @param geometryProperty Geometry property already resolved for the feature type. + * @param propertyName Raw selected property name. + * @returns The validated non-geometric property name. + */ +function compileSelectProperty(featureType: Collection, geometryProperty: CollectionProperty, propertyName: string) { + return ensureNonGeometryProperty( + featureType, + geometryProperty, + propertyName, + "La propriété '{property}' est géométrique. `select` accepte uniquement des propriétés non géométriques." + ).name; +} + +// --- Spatial Compilation --- + +/** + * Compiles a bbox spatial filter into a CQL predicate. + * + * @param geometryProperty Geometry property already resolved for the feature type. + * @param spatialFilter Normalized bbox filter. + * @returns A CQL bbox predicate. + */ +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`."); + } + if (spatialFilter.south >= spatialFilter.north) { + throw new Error("Le bbox est invalide : `bbox_south` doit être strictement inférieur à `bbox_north`."); + } + return `BBOX(${geometryProperty.name},${spatialFilter.west},${spatialFilter.south},${spatialFilter.east},${spatialFilter.north},'EPSG:4326')`; +} + +/** + * Compiles an intersects-point spatial filter into a CQL predicate. + * + * @param geometryProperty Geometry property already resolved for the feature type. + * @param spatialFilter Normalized point intersection filter. + * @returns A CQL intersects predicate. + */ +function compileIntersectsPointSpatialFilter(geometryProperty: CollectionProperty, spatialFilter: Extract) { + return `INTERSECTS(${geometryProperty.name},SRID=4326;POINT(${spatialFilter.lon} ${spatialFilter.lat}))`; +} + +/** + * Compiles a distance-based spatial filter into a CQL predicate. + * + * @param geometryProperty Geometry property already resolved for the feature type. + * @param spatialFilter Normalized distance filter. + * @returns A CQL dwithin predicate. + */ +function compileDwithinSpatialFilter(geometryProperty: CollectionProperty, spatialFilter: Extract) { + return `DWITHIN(${geometryProperty.name},SRID=4326;POINT(${spatialFilter.lon} ${spatialFilter.lat}),${spatialFilter.distance_m},meters)`; +} + +/** + * Compiles an `intersects_feature` spatial filter once the reference geometry is already serialized. + * + * @param geometryProperty Geometry property already resolved for the feature type. + * @param geometryEwkt Reference geometry serialized as EWKT. + * @returns A CQL intersects predicate. + */ +function compileIntersectsFeatureSpatialFilter(geometryProperty: CollectionProperty, geometryEwkt: string) { + return `INTERSECTS(${geometryProperty.name},${geometryEwkt})`; +} + +// --- Geometry Serialization --- + +/** + * Serializes a GeoJSON-like geometry object into EWKT for CQL spatial predicates. + * + * @param geometry Geometry object exposing a GeoJSON `type` and `coordinates`. + * @returns The EWKT representation of the geometry. + */ +export function geometryToEwkt(geometry: { type: string; coordinates: unknown }) { + switch (geometry.type) { + case "Point": + return `SRID=4326;POINT(${positionToWkt(geometry.coordinates as [number, number])})`; + case "MultiPoint": + return `SRID=4326;MULTIPOINT(${(geometry.coordinates as [number, number][]) + .map((position) => `(${positionToWkt(position)})`) + .join(",")})`; + case "LineString": + return `SRID=4326;LINESTRING(${(geometry.coordinates as [number, number][]).map(positionToWkt).join(",")})`; + case "MultiLineString": + return `SRID=4326;MULTILINESTRING(${(geometry.coordinates as [number, number][][]).map((line) => `(${line.map(positionToWkt).join(",")})`).join(",")})`; + case "Polygon": + return `SRID=4326;POLYGON(${(geometry.coordinates as [number, number][][]).map((ring) => `(${ring.map(positionToWkt).join(",")})`).join(",")})`; + case "MultiPolygon": + return `SRID=4326;MULTIPOLYGON(${(geometry.coordinates as [number, number][][][]).map((polygon) => `(${polygon.map((ring) => `(${ring.map(positionToWkt).join(",")})`).join(",")})`).join(",")})`; + default: + throw new Error(`Le type de géométrie '${geometry.type}' n'est pas supporté pour \`intersects_feature\`.`); + } +} + +/** + * Serializes a single coordinate pair into a WKT position. + * + * @param position Coordinate pair expressed as `[lon, lat]`. + * @returns A WKT position string. + */ +function positionToWkt(position: [number, number]) { + return `${position[0]} ${position[1]}`; +} + +// --- Spatial Input Normalization --- + +/** + * 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); +} + +/** + * Normalizes the raw spatial input into a discriminated spatial filter object. + * + * @param input Normalized tool input. + * @returns A normalized 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."); + } + 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 }; + 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."); + } + 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 }; + 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."); + } + 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 }; + 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."); + } + 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 }; + } +} + +/** + * Builds the list of non-geometric properties to request from the WFS layer. + * + * @param featureType Feature type definition loaded from the embedded catalog. + * @param geometryProperty Geometry property already resolved for the feature type. + * @param input Normalized tool input. + * @returns The list of selected property names, or every non-geometric property when `select` is omitted. + */ +function buildSelectList(featureType: Collection, geometryProperty: CollectionProperty, input: GpfWfsGetFeaturesInput) { + return input.select && input.select.length > 0 + ? input.select.map((propertyName) => compileSelectProperty(featureType, geometryProperty, propertyName)) + : featureType.properties + .filter((property) => !property.defaultCrs) + .map((property) => property.name); +} + +// --- Query Compilation --- + +/** + * Compiles normalized tool input into query fragments ready to be turned into a WFS request. + * + * @param input Normalized tool input. + * @param featureType Feature type definition loaded from the embedded catalog. + * @param resolvedGeometryRef Optional resolved reference geometry for `intersects_feature`. + * @returns Compiled query parts used by request builders. + */ +export function compileQueryParts( + input: GpfWfsGetFeaturesInput, + featureType: Collection, + resolvedGeometryRef?: ResolvedFeatureGeometryRef +): CompiledQuery { + const geometryProperty = getGeometryProperty(featureType); + const spatialFilter = getSpatialFilter(input); + const fragments: string[] = []; + + // 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) { + case "bbox": + fragments.push(compileBboxSpatialFilter(geometryProperty, spatialFilter)); + break; + case "intersects_point": + fragments.push(compileIntersectsPointSpatialFilter(geometryProperty, spatialFilter)); + break; + case "dwithin_point": + fragments.push(compileDwithinSpatialFilter(geometryProperty, spatialFilter)); + break; + case "intersects_feature": + if (!resolvedGeometryRef) { + throw new Error("Le filtre spatial `intersects_feature` exige la résolution préalable de la géométrie de référence."); + } + fragments.push(compileIntersectsFeatureSpatialFilter(geometryProperty, resolvedGeometryRef.geometry_ewkt)); + break; + } + } + + for (const clause of input.where ?? []) { + fragments.push(compileWhereClause(featureType, geometryProperty, clause)); + } + + const sortBy = input.order_by && input.order_by.length > 0 + ? input.order_by.map((clause) => compileOrderByClause(featureType, geometryProperty, clause)).join(",") + : undefined; + + const propertyNames = buildSelectList(featureType, geometryProperty, input); + + return { + geometryProperty, + cqlFilter: fragments.length > 0 ? fragments.join(" AND ") : undefined, + propertyName: propertyNames.length > 0 ? propertyNames.join(",") : undefined, + sortBy, + }; +} diff --git a/src/tools/gpfWfsGetFeatures/request.ts b/src/tools/gpfWfsGetFeatures/request.ts new file mode 100644 index 0000000..fabdf13 --- /dev/null +++ b/src/tools/gpfWfsGetFeatures/request.ts @@ -0,0 +1,110 @@ +import { GPF_WFS_URL } from "../../gpf/wfs.js"; +import type { GpfWfsGetFeaturesInput } from "./schema.js"; +import { REQUEST_GET_URL_MAX_LENGTH } from "./schema.js"; + +export type CompiledRequest = { + method: "POST"; + url: string; + query: Record; + body: string; + get_url?: string | null; +}; + +/** + * Encodes the optional CQL filter as an `application/x-www-form-urlencoded` POST body. + * + * @param cqlFilter Compiled CQL filter, when the request needs one. + * @returns The encoded POST body, or an empty string when no filter is present. + */ +function buildBody(cqlFilter?: string) { + if (!cqlFilter) { + return ""; + } + return new URLSearchParams({ cql_filter: cqlFilter }).toString(); +} + +/** + * Builds a portable GET URL variant of the request when it stays below the configured limit. + * + * @param url Base WFS endpoint URL. + * @param query Query-string parameters sent with the request. + * @param cqlFilter Optional CQL filter to append to the GET variant. + * @returns A derived GET URL, or `null` when it would be too long to expose safely. + */ +export function buildGetUrl(url: string, query: Record, cqlFilter?: string) { + const params = new URLSearchParams(query); + if (cqlFilter) { + params.set("cql_filter", cqlFilter); + } + const getUrl = `${url}?${params.toString()}`; + if (getUrl.length > REQUEST_GET_URL_MAX_LENGTH) { + return null; + } + return getUrl; +} + +/** + * Builds the main WFS GetFeature request from normalized tool input and compiled query parts. + * + * @param input Normalized tool input. + * @param compiled Compiled query fragments produced from the input and feature type. + * @returns A POST request split into base URL, query-string parameters, encoded body, and optional GET variant. + */ +export function buildMainRequest( + input: GpfWfsGetFeaturesInput, + compiled: { cqlFilter?: string; propertyName?: string; sortBy?: string } +): CompiledRequest { + const query: Record = { + service: "WFS", + version: "2.0.0", + request: "GetFeature", + typeNames: input.typename, + outputFormat: "application/json", + count: input.result_type === "hits" ? "1" : String(input.limit), + }; + + if (compiled.propertyName && input.result_type !== "hits") { + query.propertyName = compiled.propertyName; + } + if (compiled.sortBy) { + query.sortBy = compiled.sortBy; + } + + const body = buildBody(compiled.cqlFilter); + return { + method: "POST", + url: GPF_WFS_URL, + query, + body, + get_url: buildGetUrl(GPF_WFS_URL, query, compiled.cqlFilter), + }; +} + +/** + * Builds the auxiliary request used to fetch the geometry of a reference feature. + * + * @param typename Typename of the reference layer. + * @param featureId Identifier of the reference feature. + * @param geometryPropertyName Geometry property to request from the reference layer. + * @returns A POST request targeting the reference feature lookup. + */ +export function buildReferenceGeometryRequest(typename: string, featureId: string, geometryPropertyName: string): CompiledRequest { + const query: Record = { + service: "WFS", + version: "2.0.0", + request: "GetFeature", + typeNames: typename, + outputFormat: "application/json", + featureID: featureId, + propertyName: geometryPropertyName, + count: "1", + }; + + return { + method: "POST", + url: GPF_WFS_URL, + query, + body: "", + get_url: buildGetUrl(GPF_WFS_URL, query), + }; +} diff --git a/src/tools/gpfWfsGetFeatures/response.ts b/src/tools/gpfWfsGetFeatures/response.ts new file mode 100644 index 0000000..53360b3 --- /dev/null +++ b/src/tools/gpfWfsGetFeatures/response.ts @@ -0,0 +1,47 @@ +type GenericFeature = { + id?: string; + geometry?: unknown; + geometry_name?: string; + [key: string]: unknown; +}; + +type GenericFeatureCollection = { + features?: GenericFeature[]; + [key: string]: unknown; +}; + +/** + * Removes raw geometry payloads from a FeatureCollection and exposes lightweight `feature_ref` + * objects that can be reused by follow-up requests. + * + * @param featureCollection Raw FeatureCollection returned by the WFS endpoint. + * @returns A transformed FeatureCollection with `geometry` nulled out and optional `feature_ref` metadata. + */ +export function transformFeatureCollectionResponse(featureCollection: GenericFeatureCollection) { + if (!Array.isArray(featureCollection.features)) { + return featureCollection; + } + + const transformedFeatures = featureCollection.features.map((feature) => { + const { geometry: _geometry, geometry_name: _geometryName, ...rest } = feature; + + const nextFeature: Record = { + ...rest, + geometry: null, + }; + + if (typeof feature.id === "string") { + nextFeature.feature_ref = { + typename: null, + feature_id: feature.id, + }; + } + + return nextFeature; + }); + + return { + ...featureCollection, + features: transformedFeatures, + }; +} diff --git a/src/tools/gpfWfsGetFeatures/schema.ts b/src/tools/gpfWfsGetFeatures/schema.ts new file mode 100644 index 0000000..c3f0355 --- /dev/null +++ b/src/tools/gpfWfsGetFeatures/schema.ts @@ -0,0 +1,104 @@ +import { z } from "zod"; + +import { generatePublishedInputSchema } from "../../helpers/jsonSchema.js"; +import { lonSchema, latSchema } from "../../helpers/schemas.js"; + +export const DEFAULT_LIMIT = 100; +export const MAX_LIMIT = 1000; +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 ORDER_DIRECTIONS = ["asc", "desc"] as const; + +const whereClauseSchema = z.object({ + property: z.string().trim().min(1).describe("Nom exact d'une propriété non géométrique du type WFS. Utiliser `gpf_wfs_describe_type` pour connaître les noms exacts disponibles."), + operator: z.enum(WHERE_OPERATORS).describe("Opérateur de filtre : `eq`, `ne`, `lt`, `lte`, `gt`, `gte`, `in`, `is_null`."), + value: z.string().optional().describe("Valeur scalaire sérialisée en texte, utilisée avec tous les opérateurs sauf `in` et `is_null`."), + values: z.array(z.string()).min(1).optional().describe("Liste de valeurs sérialisées en texte, utilisée uniquement avec `operator = \"in\"`."), +}).strict().describe("Clause de filtre structurée. Exemple : `{ property: \"code_insee\", operator: \"eq\", value: \"75056\" }`."); + +const orderBySchema = z.object({ + property: z.string().trim().min(1).describe("Nom exact d'une propriété non géométrique à utiliser pour le tri. Utiliser `gpf_wfs_describe_type` pour connaître les noms exacts disponibles."), + 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\" }`."); + +export const gpfWfsGetFeaturesInputSchema = z.object({ + typename: z + .string() + .trim() + .min(1, "le nom du type ne doit pas être vide") + .describe("Nom exact du type WFS à interroger, par exemple `BDTOPO_V3:batiment`. Utiliser `gpf_wfs_search_types` pour trouver un `typename` valide."), + limit: z + .number() + .int() + .min(1) + .max(MAX_LIMIT) + .default(DEFAULT_LIMIT) + .describe(`Nombre maximum d'objets à renvoyer. Valeur par défaut : ${DEFAULT_LIMIT}. Maximum : ${MAX_LIMIT}.`), + result_type: z + .enum(["results", "hits", "request"]) + .default("results") + .describe("`results` renvoie une FeatureCollection, `hits` renvoie uniquement le nombre total d'objets correspondant à la requête, `request` renvoie la requête compilée."), + select: z + .array(z.string().trim().min(1)) + .min(1) + .optional() + .describe("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\"]`."), + order_by: z + .array(orderBySchema) + .min(1) + .optional() + .describe("Liste ordonnée des critères de tri."), + where: z + .array(whereClauseSchema) + .min(1) + .optional() + .describe("Clauses de filtre attributaire, combinées avec `AND`."), + spatial_operator: z + .enum(SPATIAL_OPERATORS) + .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\"`."), +}).strict(); + +export type GpfWfsGetFeaturesInput = z.infer; +export type WhereClause = NonNullable[number]; +export type OrderByClause = NonNullable[number]; + +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 }; + +export const gpfWfsGetFeaturesHitsOutputSchema = z.object({ + result_type: z.literal("hits").describe("Indique que la réponse contient uniquement un comptage."), + totalFeatures: z.number().describe("Le nombre total d'objets correspondant à la requête."), +}); + +export const gpfWfsGetFeaturesRequestOutputSchema = z.object({ + result_type: z.literal("request").describe("Indique que la réponse contient la requête compilée."), + method: z.literal("POST").describe("Méthode HTTP réellement utilisée pour exécuter la requête."), + url: z.string().describe("URL de base appelée pour la requête POST."), + query: z.record(z.string()).describe("Paramètres WFS envoyés dans la query string."), + body: z.string().describe("Corps de la requête POST, encodé en `application/x-www-form-urlencoded`."), + get_url: z.string().nullable().optional().describe("URL GET dérivée quand la requête reste raisonnablement portable en GET."), +}); + +type PublishedInputSchema = { + type: "object"; + properties?: Record; + required?: string[]; +}; + +export const gpfWfsGetFeaturesPublishedInputSchema = generatePublishedInputSchema(gpfWfsGetFeaturesInputSchema) as PublishedInputSchema; diff --git a/test/gpf/adminexpress.test.ts b/test/gpf/adminexpress.test.ts index 70e77f6..802ccb0 100644 --- a/test/gpf/adminexpress.test.ts +++ b/test/gpf/adminexpress.test.ts @@ -73,6 +73,10 @@ describe("Test getAdminUnits",() => { { const departement = items.filter((item)=>item.type === 'departement')[0]; expect(departement).not.toBeUndefined(); + expect(departement.feature_ref).toEqual({ + typename: "ADMINEXPRESS-COG.LATEST:departement", + feature_id: "departement.1", + }); expect(departement.nom_officiel).toEqual('Doubs'); expect(departement.nom_officiel_en_majuscules).toEqual('DOUBS'); expect(departement.code_insee).toEqual('25'); @@ -84,6 +88,10 @@ describe("Test getAdminUnits",() => { { const commune = items.filter((item)=>item.type === 'commune')[0]; expect(commune).not.toBeUndefined(); + expect(commune.feature_ref).toEqual({ + typename: "ADMINEXPRESS-COG.LATEST:commune", + feature_id: "commune.1", + }); expect(commune.nom_officiel).toEqual('Loray'); expect(commune.nom_officiel_en_majuscules).toEqual('LORAY'); expect(commune.code_insee_du_departement).toEqual('25'); diff --git a/test/gpf/parcellaire-express.test.ts b/test/gpf/parcellaire-express.test.ts index 495bd1c..92688bf 100644 --- a/test/gpf/parcellaire-express.test.ts +++ b/test/gpf/parcellaire-express.test.ts @@ -64,6 +64,10 @@ describe("Test getParcellaireExpress",() => { { const parcelle = items.filter((item)=>item.type === 'parcelle')[0]; expect(parcelle).not.toBeUndefined(); + expect(parcelle.feature_ref).toEqual({ + typename: "CADASTRALPARCELS.PARCELLAIRE_EXPRESS:parcelle", + feature_id: "parcelle.1", + }); expect(parcelle.idu).toEqual('25349000AD0023'); } diff --git a/test/gpf/urbanisme.test.ts b/test/gpf/urbanisme.test.ts index 7077858..b6bcad5 100644 --- a/test/gpf/urbanisme.test.ts +++ b/test/gpf/urbanisme.test.ts @@ -92,6 +92,10 @@ describe("Test getUrbanisme",() => { { const document = items.filter((item)=>item.type === 'document')[0]; expect(document).not.toBeUndefined(); + expect(document.feature_ref).toEqual({ + typename: "wfs_du:document", + feature_id: "document.1", + }); // might change (ex : PLU -> PLUi) expect(document.du_type).toEqual('PLU'); } @@ -124,5 +128,9 @@ describe("Test getAssiettesServitudes",() => { const names = items.map((item)=>item.nomsuplitt); expect(names).toContain("Croix de l'ancien cimetière"); expect(names).toContain('Fontaine-lavoir'); + expect(items[0].feature_ref).toEqual({ + typename: "wfs_sup:assiette_sup_s", + feature_id: "assiette_sup_s.1", + }); }); }); diff --git a/test/gpf/wfs.test.ts b/test/gpf/wfs.test.ts index d91634e..862ee25 100644 --- a/test/gpf/wfs.test.ts +++ b/test/gpf/wfs.test.ts @@ -54,7 +54,7 @@ describe("Test WfsClient",() => { process.env[GPF_WFS_MINISEARCH_OPTIONS_ENV] = JSON.stringify({ unsupported: 1, }); - expect(() => loadMiniSearchOptionsFromEnv()).toThrow("unexpected key 'unsupported'"); + expect(() => loadMiniSearchOptionsFromEnv()).toThrow("Unrecognized key(s) in object: 'unsupported'"); }); it("should throw on invalid option value types", () => { @@ -63,35 +63,35 @@ describe("Test WfsClient",() => { title: "4", }, }); - expect(() => loadMiniSearchOptionsFromEnv()).toThrow("expected 'boost.title' to be a finite number"); + expect(() => loadMiniSearchOptionsFromEnv()).toThrow("boost.title: Expected number, received string"); }); it("should throw on invalid fields value", () => { process.env[GPF_WFS_MINISEARCH_OPTIONS_ENV] = JSON.stringify({ fields: ["title", "not_a_field"], }); - expect(() => loadMiniSearchOptionsFromEnv()).toThrow("unexpected value 'fields.not_a_field'"); + expect(() => loadMiniSearchOptionsFromEnv()).toThrow("fields.1:"); }); it("should throw on invalid combineWith value", () => { process.env[GPF_WFS_MINISEARCH_OPTIONS_ENV] = JSON.stringify({ combineWith: "XOR", }); - expect(() => loadMiniSearchOptionsFromEnv()).toThrow("expected 'combineWith' to be 'AND' or 'OR'"); + expect(() => loadMiniSearchOptionsFromEnv()).toThrow("combineWith:"); }); }); describe("getFeatureTypes",() => { it("should return the list of feature types with BDTOPO_V3:batiment", async () => { const featureTypes = await wfsClient.getFeatureTypes(); - expect(featureTypes).toBeDefined(); - expect(featureTypes.length).toBeGreaterThan(0); - - const featureTypeNames= featureTypes.map((featureType)=>featureType.id); - expect(featureTypeNames).toContain("BDTOPO_V3:batiment"); - }); - }); - + expect(featureTypes).toBeDefined(); + expect(featureTypes.length).toBeGreaterThan(0); + + const featureTypeNames= featureTypes.map((featureType)=>featureType.id); + expect(featureTypeNames).toContain("BDTOPO_V3:batiment"); + }); + }); + describe("searchFeatureTypesWithScores",() => { it("should find BDTOPO_V3:batiment for 'bâtiments bdtopo'", async () => { const featureTypes = await wfsClient.searchFeatureTypesWithScores("bâtiments bdtopo"); @@ -136,15 +136,15 @@ describe("Test WfsClient",() => { }); describe("getFeatureType",() => { - it("should return the feature type with BDTOPO_V3:batiment", async () => { - const featureType = await wfsClient.getFeatureType("BDTOPO_V3:batiment"); - expect(featureType).toBeDefined(); - expect(featureType?.id).toEqual("BDTOPO_V3:batiment"); - }); - - it("should throw an error if the feature type does not exist", async () => { - await expect(wfsClient.getFeatureType("BDTOPO_V3:not_found")).rejects.toThrow(FeatureTypeNotFoundError); - }); - }); + it("should return the feature type with BDTOPO_V3:batiment", async () => { + const featureType = await wfsClient.getFeatureType("BDTOPO_V3:batiment"); + expect(featureType).toBeDefined(); + expect(featureType?.id).toEqual("BDTOPO_V3:batiment"); + }); + + it("should throw an error if the feature type does not exist", async () => { + await expect(wfsClient.getFeatureType("BDTOPO_V3:not_found")).rejects.toThrow(FeatureTypeNotFoundError); + }); + }); }); diff --git a/test/gpfWfsGetFeatures/compile.test.ts b/test/gpfWfsGetFeatures/compile.test.ts new file mode 100644 index 0000000..107ea40 --- /dev/null +++ b/test/gpfWfsGetFeatures/compile.test.ts @@ -0,0 +1,122 @@ +import type { Collection } from "@ignfab/gpf-schema-store"; + +import { compileQueryParts, geometryToEwkt } from "../../src/tools/gpfWfsGetFeatures/compile"; +import type { GpfWfsGetFeaturesInput } from "../../src/tools/gpfWfsGetFeatures/schema"; + +describe("gpfWfsGetFeatures/compile", () => { + const featureType: Collection = { + id: "ADMINEXPRESS-COG.LATEST:commune", + namespace: "ADMINEXPRESS-COG.LATEST", + name: "commune", + title: "Commune", + description: "Description de test", + properties: [ + { name: "code_insee", type: "string" }, + { name: "nature", type: "string", enum: ["Chapelle", "Eglise"] }, + { name: "population", type: "integer" }, + { name: "hauteur", type: "numeric" }, + { name: "actif", type: "boolean" }, + { name: "date_creation", type: "string" }, + { name: "geometrie", type: "multipolygon", defaultCrs: "EPSG:4326" }, + ], + }; + + const baseInput: GpfWfsGetFeaturesInput = { + typename: "ADMINEXPRESS-COG.LATEST:commune", + limit: 100, + result_type: "results", + }; + + it("should compile where clauses", () => { + const compiled = compileQueryParts({ + ...baseInput, + where: [ + { property: "code_insee", operator: "eq", value: "94080" }, + { property: "population", operator: "gt", value: "1000" }, + { property: "actif", operator: "is_null" }, + ], + }, featureType); + + expect(compiled.cqlFilter).toEqual("code_insee = '94080' AND population > 1000 AND actif IS NULL"); + }); + + 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, + }, featureType); + + expect(compiled.cqlFilter).toEqual("BBOX(geometrie,2.4,48.7,2.5,48.8,'EPSG:4326')"); + }); + + it("should compile point spatial filters", () => { + const intersects = compileQueryParts({ + ...baseInput, + spatial_operator: "intersects_point", + intersects_lon: 2.3522, + intersects_lat: 48.8566, + }, featureType); + + const dwithin = compileQueryParts({ + ...baseInput, + spatial_operator: "dwithin_point", + dwithin_lon: 2.3522, + dwithin_lat: 48.8566, + dwithin_distance_m: 250, + }, featureType); + + expect(intersects.cqlFilter).toEqual("INTERSECTS(geometrie,SRID=4326;POINT(2.3522 48.8566))"); + expect(dwithin.cqlFilter).toEqual("DWITHIN(geometrie,SRID=4326;POINT(2.3522 48.8566),250,meters)"); + }); + + 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", + }, featureType, { + typename: "ADMINEXPRESS-COG.LATEST:commune", + feature_id: "commune.1", + geometry_ewkt: "SRID=4326;MULTIPOLYGON(((2 48,2.2 48,2.2 48.2,2 48,2 48)))", + }); + + expect(compiled.cqlFilter).toEqual("INTERSECTS(geometrie,SRID=4326;MULTIPOLYGON(((2 48,2.2 48,2.2 48.2,2 48,2 48))))"); + }); + + it("should reject geometric properties in select", () => { + expect(() => compileQueryParts({ + ...baseInput, + select: ["geometrie"], + }, featureType)).toThrow("`select` accepte uniquement"); + }); + + 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 build sortBy from structured order_by", () => { + const compiled = compileQueryParts({ + ...baseInput, + order_by: [ + { property: "population", direction: "desc" }, + { property: "code_insee", direction: "asc" }, + ], + }, featureType); + + expect(compiled.sortBy).toEqual("population D,code_insee A"); + }); + + it("should convert referenced geometries to EWKT", () => { + expect(geometryToEwkt({ type: "Point", coordinates: [2.3, 48.8] })).toEqual("SRID=4326;POINT(2.3 48.8)"); + expect(geometryToEwkt({ type: "MultiPoint", coordinates: [[2.3, 48.8], [2.4, 48.9]] })).toEqual("SRID=4326;MULTIPOINT((2.3 48.8),(2.4 48.9))"); + expect(geometryToEwkt({ type: "LineString", coordinates: [[2.3, 48.8], [2.4, 48.9]] })).toEqual("SRID=4326;LINESTRING(2.3 48.8,2.4 48.9)"); + }); +}); diff --git a/test/tools/adminexpress.test.ts b/test/tools/adminexpress.test.ts index 4a96b1a..7db9ceb 100644 --- a/test/tools/adminexpress.test.ts +++ b/test/tools/adminexpress.test.ts @@ -6,6 +6,10 @@ const adminexpressResults = [ type: "commune", id: "commune.1", bbox: [6.4, 47.1, 6.5, 47.2], + feature_ref: { + typename: "ADMINEXPRESS-COG.LATEST:commune", + feature_id: "commune.1", + }, nom_officiel: "Loray", nom_officiel_en_majuscules: "LORAY", code_insee_du_departement: "25", @@ -16,6 +20,10 @@ const adminexpressResults = [ type: "departement", id: "departement.1", bbox: [6.4, 47.1, 6.5, 47.2], + feature_ref: { + typename: "ADMINEXPRESS-COG.LATEST:departement", + feature_id: "departement.1", + }, nom_officiel: "Doubs", nom_officiel_en_majuscules: "DOUBS", code_insee: "25", @@ -76,10 +84,18 @@ describe("Test AdminexpressTool",() => { expect.objectContaining({ type: "commune", nom_officiel: "Loray", + feature_ref: { + typename: "ADMINEXPRESS-COG.LATEST:commune", + feature_id: "commune.1", + }, }), expect.objectContaining({ type: "departement", nom_officiel: "Doubs", + feature_ref: { + typename: "ADMINEXPRESS-COG.LATEST:departement", + feature_id: "departement.1", + }, }), ])); expect(response.structuredContent).toMatchObject({ @@ -87,10 +103,18 @@ describe("Test AdminexpressTool",() => { expect.objectContaining({ type: "commune", nom_officiel: "Loray", + feature_ref: { + typename: "ADMINEXPRESS-COG.LATEST:commune", + feature_id: "commune.1", + }, }), expect.objectContaining({ type: "departement", nom_officiel: "Doubs", + feature_ref: { + typename: "ADMINEXPRESS-COG.LATEST:departement", + feature_id: "departement.1", + }, }), ]), }); diff --git a/test/tools/cadastre.test.ts b/test/tools/cadastre.test.ts index 492c031..7ea364a 100644 --- a/test/tools/cadastre.test.ts +++ b/test/tools/cadastre.test.ts @@ -10,6 +10,10 @@ describe("Test CadastreTool",() => { type: "commune", id: "commune.1", bbox: [6.49, 47.15, 6.50, 47.16], + feature_ref: { + typename: "CADASTRALPARCELS.PARCELLAIRE_EXPRESS:commune", + feature_id: "commune.1", + }, nom_officiel: "Loray", distance: 5, source: "mock", @@ -18,6 +22,10 @@ describe("Test CadastreTool",() => { type: "parcelle", id: "parcelle.1", bbox: [6.49, 47.15, 6.50, 47.16], + feature_ref: { + typename: "CADASTRALPARCELS.PARCELLAIRE_EXPRESS:parcelle", + feature_id: "parcelle.1", + }, idu: "25349000AD0023", distance: 0, source: "mock", @@ -69,6 +77,10 @@ describe("Test CadastreTool",() => { expect.objectContaining({ type: "parcelle", idu: "25349000AD0023", + feature_ref: { + typename: "CADASTRALPARCELS.PARCELLAIRE_EXPRESS:parcelle", + feature_id: "parcelle.1", + }, }), ])); expect(response.structuredContent).toMatchObject({ @@ -76,10 +88,18 @@ describe("Test CadastreTool",() => { expect.objectContaining({ type: "commune", nom_officiel: "Loray", + feature_ref: { + typename: "CADASTRALPARCELS.PARCELLAIRE_EXPRESS:commune", + feature_id: "commune.1", + }, }), expect.objectContaining({ type: "parcelle", idu: "25349000AD0023", + feature_ref: { + typename: "CADASTRALPARCELS.PARCELLAIRE_EXPRESS:parcelle", + feature_id: "parcelle.1", + }, }), ]), }); diff --git a/test/tools/strict-input.test.ts b/test/tools/strict-input.test.ts index ac918d8..021d350 100644 --- a/test/tools/strict-input.test.ts +++ b/test/tools/strict-input.test.ts @@ -5,7 +5,6 @@ import CadastreTool from "../../src/tools/CadastreTool"; import GeocodeTool from "../../src/tools/GeocodeTool"; import GpfWfsDescribeTypeTool from "../../src/tools/GpfWfsDescribeTypeTool"; import GpfWfsGetFeaturesTool from "../../src/tools/GpfWfsGetFeaturesTool"; -import GpfWfsListTypesTool from "../../src/tools/GpfWfsListTypesTool"; import GpfWfsSearchTypesTool from "../../src/tools/GpfWfsSearchTypesTool"; import UrbanismeTool from "../../src/tools/UrbanismeTool"; @@ -45,11 +44,6 @@ const strictInputCases = [ tool: new GpfWfsGetFeaturesTool(), validArguments: { typename: "BDTOPO_V3:batiment" }, }, - { - label: "GpfWfsListTypesTool", - tool: new GpfWfsListTypesTool(), - validArguments: {}, - }, { label: "GpfWfsSearchTypesTool", tool: new GpfWfsSearchTypesTool(), diff --git a/test/tools/urbanisme.test.ts b/test/tools/urbanisme.test.ts index 2bf28f4..d92810d 100644 --- a/test/tools/urbanisme.test.ts +++ b/test/tools/urbanisme.test.ts @@ -10,12 +10,20 @@ describe("Test UrbanismeTool",() => { { type: "document", id: "document.1", + feature_ref: { + typename: "wfs_du:document", + feature_id: "document.1", + }, du_type: "PLU", distance: 0, }, { type: "zone_urba", id: "zone_urba.1", + feature_ref: { + typename: "wfs_du:zone_urba", + feature_id: "zone_urba.1", + }, libelle: "Zone U", distance: 5, }, @@ -66,6 +74,10 @@ describe("Test UrbanismeTool",() => { expect.objectContaining({ type: "document", du_type: "PLU", + feature_ref: { + typename: "wfs_du:document", + feature_id: "document.1", + }, }), ])); expect(response.structuredContent).toMatchObject({ @@ -73,10 +85,18 @@ describe("Test UrbanismeTool",() => { expect.objectContaining({ type: "document", du_type: "PLU", + feature_ref: { + typename: "wfs_du:document", + feature_id: "document.1", + }, }), expect.objectContaining({ type: "zone_urba", libelle: "Zone U", + feature_ref: { + typename: "wfs_du:zone_urba", + feature_id: "zone_urba.1", + }, }), ]), }); @@ -115,12 +135,20 @@ describe("Test AssietteSupTool",() => { { type: "assiette_sup_s", id: "assiette_sup_s.1", + feature_ref: { + typename: "wfs_sup:assiette_sup_s", + feature_id: "assiette_sup_s.1", + }, nomsuplitt: "Croix de l'ancien cimetière", distance: 0, }, { type: "assiette_sup_s", id: "assiette_sup_s.2", + feature_ref: { + typename: "wfs_sup:assiette_sup_s", + feature_id: "assiette_sup_s.2", + }, nomsuplitt: "Fontaine-lavoir", distance: 10, }, @@ -171,6 +199,10 @@ describe("Test AssietteSupTool",() => { expect.objectContaining({ type: "assiette_sup_s", nomsuplitt: "Croix de l'ancien cimetière", + feature_ref: { + typename: "wfs_sup:assiette_sup_s", + feature_id: "assiette_sup_s.1", + }, }), ])); expect(response.structuredContent).toMatchObject({ @@ -178,10 +210,18 @@ describe("Test AssietteSupTool",() => { expect.objectContaining({ type: "assiette_sup_s", nomsuplitt: "Croix de l'ancien cimetière", + feature_ref: { + typename: "wfs_sup:assiette_sup_s", + feature_id: "assiette_sup_s.1", + }, }), expect.objectContaining({ type: "assiette_sup_s", nomsuplitt: "Fontaine-lavoir", + feature_ref: { + typename: "wfs_sup:assiette_sup_s", + feature_id: "assiette_sup_s.2", + }, }), ]), }); diff --git a/test/tools/wfs.test.ts b/test/tools/wfs.test.ts deleted file mode 100644 index 0233583..0000000 --- a/test/tools/wfs.test.ts +++ /dev/null @@ -1,455 +0,0 @@ -import GpfWfsDescribeTypeTool from "../../src/tools/GpfWfsDescribeTypeTool"; -import GpfWfsGetFeaturesTool from "../../src/tools/GpfWfsGetFeaturesTool"; -import GpfWfsListTypesTool from "../../src/tools/GpfWfsListTypesTool"; -import GpfWfsSearchTypesTool from "../../src/tools/GpfWfsSearchTypesTool"; -import type { Collection } from "@ignfab/gpf-schema-store"; - -describe("Test GpfWfsSearchTypesTool",() => { - class TestableGpfWfsSearchTypesTool extends GpfWfsSearchTypesTool { - async execute() { - return { - results: [ - { - id: "BDTOPO_V3:batiment", - title: "Batiment", - description: "Description de test", - score: 12.5, - }, - ], - }; - } - } - - it("should expose an enriched MCP definition", () => { - const tool = new GpfWfsSearchTypesTool(); - expect(tool.toolDefinition.title).toEqual("Recherche de types WFS"); - expect(tool.toolDefinition.inputSchema.properties?.query).toMatchObject({ - type: "string", - minLength: 1, - }); - expect(tool.toolDefinition.inputSchema.properties?.max_results).toMatchObject({ - type: "integer", - minimum: 1, - maximum: 50, - }); - expect(tool.toolDefinition.outputSchema).toBeDefined(); - }); - - it("should return both text content and structuredContent", async () => { - const tool = new TestableGpfWfsSearchTypesTool(); - const response = await tool.toolCall({ - params: { - name: "gpf_wfs_search_types", - arguments: { - query: "batiment", - max_results: 1, - }, - }, - }); - - expect(response.isError).toBeUndefined(); - expect(response.content[0]).toMatchObject({ - type: "text", - }); - const textContent = response.content[0]; - if (textContent.type !== "text") { - throw new Error("expected text content"); - } - expect(JSON.parse(textContent.text)).toMatchObject({ - results: [ - { - id: "BDTOPO_V3:batiment", - score: 12.5, - }, - ], - }); - expect(response.structuredContent).toBeDefined(); - expect(response.structuredContent).toMatchObject({ - results: [ - { - id: "BDTOPO_V3:batiment", - score: 12.5, - }, - ], - }); - }); - - it("should omit score when it is undefined", async () => { - class TestableGpfWfsSearchTypesToolWithoutScore extends GpfWfsSearchTypesTool { - async execute() { - return { - results: [ - { - id: "BDTOPO_V3:batiment", - title: "Batiment", - description: "Description de test", - }, - ], - }; - } - } - - const tool = new TestableGpfWfsSearchTypesToolWithoutScore(); - const response = await tool.toolCall({ - params: { - name: "gpf_wfs_search_types", - arguments: { - query: "batiment", - max_results: 1, - }, - }, - }); - - expect(response.isError).toBeUndefined(); - expect(response.content[0]).toMatchObject({ - type: "text", - }); - const textContent = response.content[0]; - if (textContent.type !== "text") { - throw new Error("expected text content"); - } - - const parsedTextContent = JSON.parse(textContent.text); - expect(parsedTextContent.results[0]).toMatchObject({ - id: "BDTOPO_V3:batiment", - }); - expect(parsedTextContent.results[0]).not.toHaveProperty("score"); - - expect(response.structuredContent).toBeDefined(); - expect(response.structuredContent).toMatchObject({ - results: [ - { - id: "BDTOPO_V3:batiment", - }, - ], - }); - const structuredContent = response.structuredContent as { results: Array> }; - expect(structuredContent.results[0]).not.toHaveProperty("score"); - }); - - it("should return isError=true for invalid input", async () => { - const tool = new GpfWfsSearchTypesTool(); - const response = await tool.toolCall({ - params: { - name: "gpf_wfs_search_types", - arguments: { - query: "", - }, - }, - }); - - expect(response.isError).toBe(true); - expect(response.content[0]).toMatchObject({ - type: "text", - }); - const textContent = response.content[0]; - if (textContent.type !== "text") { - throw new Error("expected text content"); - } - expect(textContent.text).toContain("la requête de recherche ne doit pas être vide"); - }); -}); - -describe("Test GpfWfsListTypesTool",() => { - class TestableGpfWfsListTypesTool extends GpfWfsListTypesTool { - async execute() { - return [ - { - id: "BDTOPO_V3:batiment", - title: "Batiment", - description: "Description de test", - }, - ]; - } - } - - it("should expose an enriched MCP definition", () => { - const tool = new GpfWfsListTypesTool(); - expect(tool.toolDefinition.title).toEqual("Liste complète des types WFS"); - expect(tool.toolDefinition.inputSchema.properties).toEqual({}); - expect(tool.toolDefinition.outputSchema).toBeUndefined(); - }); - - it("should return text content without structuredContent", async () => { - const tool = new TestableGpfWfsListTypesTool(); - const response = await tool.toolCall({ - params: { - name: "gpf_wfs_list_types", - arguments: {}, - }, - }); - - expect(response.isError).toBeUndefined(); - expect(response.content[0]).toMatchObject({ - type: "text", - }); - const textContent = response.content[0]; - if (textContent.type !== "text") { - throw new Error("expected text content"); - } - const results = JSON.parse(textContent.text); - expect(results.length).toBeGreaterThan(0); - expect(results).toEqual(expect.arrayContaining([ - expect.objectContaining({ - id: "BDTOPO_V3:batiment", - }), - ])); - expect(response.structuredContent).toBeUndefined(); - }); -}); - -describe("Test GpfWfsDescribeTypeTool",() => { - const mockCollection: Collection = { - id: "BDTOPO_V3:batiment", - namespace: "BDTOPO_V3", - name: "batiment", - title: "Batiment", - description: "Description de test", - properties: [ - { - name: "hauteur", - type: "number", - }, - ], - }; - - class TestableGpfWfsDescribeTypeTool extends GpfWfsDescribeTypeTool { - async execute() { - return { - result: mockCollection, - }; - } - } - - class TestableGpfWfsDescribeTypeToolError extends GpfWfsDescribeTypeTool { - async execute(): Promise { - throw new Error("Le type 'BDTOPO_V3:not_found' est introuvable. Utiliser gpf_wfs_search_types pour trouver un type valide."); - } - } - - it("should expose an enriched MCP definition", () => { - const tool = new GpfWfsDescribeTypeTool(); - expect(tool.toolDefinition.title).toEqual("Description d’un type WFS"); - expect(tool.toolDefinition.inputSchema.properties?.typename).toMatchObject({ - type: "string", - minLength: 1, - }); - expect(tool.toolDefinition.outputSchema).toBeDefined(); - }); - - it("should return both text content and structuredContent", async () => { - const tool = new TestableGpfWfsDescribeTypeTool(); - const response = await tool.toolCall({ - params: { - name: "gpf_wfs_describe_type", - arguments: { - typename: "BDTOPO_V3:batiment", - }, - }, - }); - - expect(response.isError).toBeUndefined(); - expect(response.content[0]).toMatchObject({ - type: "text", - }); - const textContent = response.content[0]; - if (textContent.type !== "text") { - throw new Error("expected text content"); - } - expect(JSON.parse(textContent.text)).toMatchObject({ - result: { - id: "BDTOPO_V3:batiment", - }, - }); - expect(response.structuredContent).toBeDefined(); - expect(response.structuredContent).toMatchObject({ - result: { - id: "BDTOPO_V3:batiment", - }, - }); - }); - - it("should return isError=true for invalid input", async () => { - const tool = new GpfWfsDescribeTypeTool(); - const response = await tool.toolCall({ - params: { - name: "gpf_wfs_describe_type", - arguments: { - typename: "", - }, - }, - }); - - expect(response.isError).toBe(true); - expect(response.content[0]).toMatchObject({ - type: "text", - }); - const textContent = response.content[0]; - if (textContent.type !== "text") { - throw new Error("expected text content"); - } - expect(textContent.text).toContain("le nom du type ne doit pas être vide"); - }); - - it("should return isError=true when execute fails", async () => { - const tool = new TestableGpfWfsDescribeTypeToolError(); - const response = await tool.toolCall({ - params: { - name: "gpf_wfs_describe_type", - arguments: { - typename: "BDTOPO_V3:not_found", - }, - }, - }); - - expect(response.isError).toBe(true); - expect(response.content[0]).toMatchObject({ - type: "text", - }); - const textContent = response.content[0]; - if (textContent.type !== "text") { - throw new Error("expected text content"); - } - expect(textContent.text).toContain("Le type 'BDTOPO_V3:not_found' est introuvable"); - expect(textContent.text).toContain("gpf_wfs_search_types"); - }); -}); - -describe("Test GpfWfsGetFeaturesTool",() => { - class TestableGpfWfsGetFeaturesTool extends GpfWfsGetFeaturesTool { - respond(data: unknown) { - return this.createSuccessResponse(data); - } - } - - const featureCollection: { - type: string; - features: Array<{ - type: string; - id: string; - geometry: null; - properties: { - code_insee: string; - }; - }>; - totalFeatures: number; - } = { - type: "FeatureCollection", - features: [ - { - type: "Feature", - id: "commune.1", - geometry: null, - properties: { - code_insee: "01001", - }, - }, - ], - totalFeatures: 34877, - }; - - it("should expose an enriched MCP definition", () => { - const tool = new GpfWfsGetFeaturesTool(); - expect(tool.toolDefinition.title).toEqual("Lecture d’objets WFS"); - expect(tool.toolDefinition.inputSchema.properties?.typename).toMatchObject({ - type: "string", - minLength: 1, - }); - expect(tool.toolDefinition.inputSchema.properties?.count).toMatchObject({ - type: "integer", - minimum: 1, - maximum: 1000, - }); - expect(tool.toolDefinition.outputSchema).toBeUndefined(); - }); - - it("should return a FeatureCollection without structuredContent for results", () => { - const tool = new TestableGpfWfsGetFeaturesTool(); - const response = tool.respond(featureCollection); - - expect("isError" in response).toBe(false); - expect(response.structuredContent).toBeUndefined(); - expect(response.content[0]).toMatchObject({ - type: "text", - }); - const textContent = response.content[0]; - if (textContent.type !== "text") { - throw new Error("expected text content"); - } - expect(JSON.parse(textContent.text)).toMatchObject({ - type: "FeatureCollection", - features: expect.any(Array), - }); - }); - - it("should return text content and structuredContent for hits", () => { - const tool = new TestableGpfWfsGetFeaturesTool(); - const response = tool.respond({ - result_type: "hits", - totalFeatures: featureCollection.totalFeatures, - }); - - expect("isError" in response).toBe(false); - expect(response.content[0]).toMatchObject({ - type: "text", - }); - const textContent = response.content[0]; - if (textContent.type !== "text") { - throw new Error("expected text content"); - } - expect(Number(JSON.parse(textContent.text))).toBeGreaterThan(0); - expect(response.structuredContent).toMatchObject({ - result_type: "hits", - totalFeatures: expect.any(Number), - }); - }); - - it("should return text content and structuredContent for url", async () => { - const tool = new GpfWfsGetFeaturesTool(); - const response = await tool.toolCall({ - params: { - name: "gpf_wfs_get_features", - arguments: { - typename: "ADMINEXPRESS-COG.LATEST:commune", - result_type: "url", - }, - }, - }); - - expect(response.isError).toBeUndefined(); - expect(response.content[0]).toMatchObject({ - type: "text", - }); - const textContent = response.content[0]; - if (textContent.type !== "text") { - throw new Error("expected text content"); - } - expect(textContent.text).toContain("service=WFS"); - expect(response.structuredContent).toMatchObject({ - result_type: "url", - url: expect.stringContaining("service=WFS"), - }); - }); - - it("should return isError=true for invalid input", async () => { - const tool = new GpfWfsGetFeaturesTool(); - const response = await tool.toolCall({ - params: { - name: "gpf_wfs_get_features", - arguments: { - typename: "", - }, - }, - }); - - expect(response.isError).toBe(true); - expect(response.content[0]).toMatchObject({ - type: "text", - }); - const textContent = response.content[0]; - if (textContent.type !== "text") { - throw new Error("expected text content"); - } - expect(textContent.text).toContain("le nom du type ne doit pas être vide"); - }); -}); diff --git a/test/tools/wfs/describeType.test.ts b/test/tools/wfs/describeType.test.ts new file mode 100644 index 0000000..63444f3 --- /dev/null +++ b/test/tools/wfs/describeType.test.ts @@ -0,0 +1,120 @@ +import type { Collection } from "@ignfab/gpf-schema-store"; + +import GpfWfsDescribeTypeTool from "../../../src/tools/GpfWfsDescribeTypeTool"; + +describe("Test GpfWfsDescribeTypeTool",() => { + const mockCollection: Collection = { + id: "BDTOPO_V3:batiment", + namespace: "BDTOPO_V3", + name: "batiment", + title: "Batiment", + description: "Description de test", + properties: [ + { + name: "hauteur", + type: "number", + }, + ], + }; + + class TestableGpfWfsDescribeTypeTool extends GpfWfsDescribeTypeTool { + async execute() { + return { + result: mockCollection, + }; + } + } + + class TestableGpfWfsDescribeTypeToolError extends GpfWfsDescribeTypeTool { + async execute(): Promise { + throw new Error("Le type 'BDTOPO_V3:not_found' est introuvable. Utiliser gpf_wfs_search_types pour trouver un type valide."); + } + } + + it("should expose an enriched MCP definition", () => { + const tool = new GpfWfsDescribeTypeTool(); + expect(tool.toolDefinition.title).toEqual("Description d’un type WFS"); + expect(tool.toolDefinition.inputSchema.properties?.typename).toMatchObject({ + type: "string", + minLength: 1, + }); + expect(tool.toolDefinition.outputSchema).toBeDefined(); + }); + + it("should return both text content and structuredContent", async () => { + const tool = new TestableGpfWfsDescribeTypeTool(); + const response = await tool.toolCall({ + params: { + name: "gpf_wfs_describe_type", + arguments: { + typename: "BDTOPO_V3:batiment", + }, + }, + }); + + expect(response.isError).toBeUndefined(); + expect(response.content[0]).toMatchObject({ + type: "text", + }); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + expect(JSON.parse(textContent.text)).toMatchObject({ + result: { + id: "BDTOPO_V3:batiment", + }, + }); + expect(response.structuredContent).toBeDefined(); + expect(response.structuredContent).toMatchObject({ + result: { + id: "BDTOPO_V3:batiment", + }, + }); + }); + + it("should return isError=true for invalid input", async () => { + const tool = new GpfWfsDescribeTypeTool(); + const response = await tool.toolCall({ + params: { + name: "gpf_wfs_describe_type", + arguments: { + typename: "", + }, + }, + }); + + expect(response.isError).toBe(true); + expect(response.content[0]).toMatchObject({ + type: "text", + }); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + expect(textContent.text).toContain("le nom du type ne doit pas être vide"); + }); + + it("should return isError=true when execute fails", async () => { + const tool = new TestableGpfWfsDescribeTypeToolError(); + const response = await tool.toolCall({ + params: { + name: "gpf_wfs_describe_type", + arguments: { + typename: "BDTOPO_V3:not_found", + }, + }, + }); + + expect(response.isError).toBe(true); + expect(response.content[0]).toMatchObject({ + type: "text", + }); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + expect(textContent.text).toContain("Le type 'BDTOPO_V3:not_found' est introuvable"); + expect(textContent.text).toContain("gpf_wfs_search_types"); + }); +}); diff --git a/test/tools/wfs/getFeatures.test.ts b/test/tools/wfs/getFeatures.test.ts new file mode 100644 index 0000000..3a62570 --- /dev/null +++ b/test/tools/wfs/getFeatures.test.ts @@ -0,0 +1,489 @@ +import type { Collection } from "@ignfab/gpf-schema-store"; + +import GpfWfsGetFeaturesTool from "../../../src/tools/GpfWfsGetFeaturesTool"; + +describe("Test GpfWfsGetFeaturesTool",() => { + class TestableGpfWfsGetFeaturesTool extends GpfWfsGetFeaturesTool { + public featureTypes: Record = {}; + public requests: Array<{ url: string; query: Record; body: string }> = []; + public nextResponse: unknown = null; + + respond(data: unknown) { + return this.createSuccessResponse(data); + } + + protected async getFeatureType(typename: string) { + const featureType = this.featureTypes[typename]; + if (!featureType) { + throw new Error(`unexpected typename ${typename}`); + } + return featureType; + } + + protected async fetchFeatureCollection(request: { url: string; query: Record; body: string }) { + this.requests.push(request); + return this.nextResponse; + } + } + + const polygonFeatureType: Collection = { + id: "ADMINEXPRESS-COG.LATEST:commune", + namespace: "ADMINEXPRESS-COG.LATEST", + name: "commune", + title: "Commune", + description: "Description de test", + properties: [ + { name: "code_insee", type: "string" }, + { name: "population", type: "integer" }, + { name: "actif", type: "boolean" }, + { name: "geometrie", type: "multipolygon", defaultCrs: "EPSG:4326" }, + ], + }; + + const pointFeatureType: Collection = { + id: "BDTOPO_V3:point_d_acces", + namespace: "BDTOPO_V3", + name: "point_d_acces", + title: "Point d'acces", + description: "Description de test", + properties: [ + { name: "cleabs", type: "string" }, + { name: "geometrie", type: "point", defaultCrs: "EPSG:4326" }, + ], + }; + + const multipointFeatureType: Collection = { + id: "CADASTRALPARCELS.PARCELLAIRE_EXPRESS:localisant", + namespace: "CADASTRALPARCELS.PARCELLAIRE_EXPRESS", + name: "localisant", + title: "Localisant", + description: "Description de test", + properties: [ + { name: "gid", type: "integer" }, + { name: "idu", type: "string" }, + { name: "geometrie", type: "multipoint", defaultCrs: "EPSG:4326" }, + ], + }; + + const featureCollection: { + type: string; + features: Array<{ + type: string; + id: string; + geometry: null; + properties: { + code_insee: string; + }; + }>; + totalFeatures: number; + } = { + type: "FeatureCollection", + features: [ + { + type: "Feature", + id: "commune.1", + geometry: null, + properties: { + code_insee: "01001", + }, + }, + ], + totalFeatures: 34877, + }; + + it("should expose an enriched MCP definition", () => { + const tool = new GpfWfsGetFeaturesTool(); + expect(tool.toolDefinition.title).toEqual("Lecture d’objets WFS"); + expect(tool.toolDefinition.inputSchema.properties?.typename).toMatchObject({ + type: "string", + minLength: 1, + }); + expect(tool.toolDefinition.inputSchema.properties?.limit).toMatchObject({ + type: "integer", + minimum: 1, + maximum: 1000, + }); + expect(tool.toolDefinition.inputSchema.properties?.select).toMatchObject({ + type: "array", + }); + expect(tool.toolDefinition.inputSchema.properties?.order_by).toMatchObject({ + type: "array", + }); + expect(tool.toolDefinition.inputSchema.properties?.where).toMatchObject({ + type: "array", + }); + expect(tool.toolDefinition.outputSchema).toBeUndefined(); + }); + + it("should return a FeatureCollection without structuredContent for results", () => { + const tool = new TestableGpfWfsGetFeaturesTool(); + const response = tool.respond(featureCollection); + + expect("isError" in response).toBe(false); + expect(response.structuredContent).toBeUndefined(); + expect(response.content[0]).toMatchObject({ + type: "text", + }); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + expect(JSON.parse(textContent.text)).toMatchObject({ + type: "FeatureCollection", + features: expect.any(Array), + }); + }); + + it("should return text content and structuredContent for hits", () => { + const tool = new TestableGpfWfsGetFeaturesTool(); + const response = tool.respond({ + result_type: "hits", + totalFeatures: featureCollection.totalFeatures, + }); + + expect("isError" in response).toBe(false); + expect(response.content[0]).toMatchObject({ + type: "text", + }); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + expect(Number(JSON.parse(textContent.text))).toBeGreaterThan(0); + expect(response.structuredContent).toMatchObject({ + result_type: "hits", + totalFeatures: expect.any(Number), + }); + }); + + it("should return text content and structuredContent for request", async () => { + const tool = new TestableGpfWfsGetFeaturesTool(); + tool.featureTypes[polygonFeatureType.id] = polygonFeatureType; + const response = await tool.toolCall({ + params: { + name: "gpf_wfs_get_features", + arguments: { + typename: "ADMINEXPRESS-COG.LATEST:commune", + result_type: "request", + where: [ + { + property: "code_insee", + operator: "eq", + value: "01001", + }, + ], + }, + }, + }); + + expect(response.isError).toBeUndefined(); + expect(response.content[0]).toMatchObject({ + type: "text", + }); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + const request = JSON.parse(textContent.text); + expect(request.method).toEqual("POST"); + expect(request.url).toContain("https://data.geopf.fr/wfs"); + expect(request.query.service).toEqual("WFS"); + expect(request.body).toContain("cql_filter="); + expect(response.structuredContent).toMatchObject({ + result_type: "request", + method: "POST", + }); + }); + + it("should return isError=true for invalid input", async () => { + const tool = new GpfWfsGetFeaturesTool(); + const response = await tool.toolCall({ + params: { + name: "gpf_wfs_get_features", + arguments: { + typename: "", + }, + }, + }); + + expect(response.isError).toBe(true); + expect(response.content[0]).toMatchObject({ + type: "text", + }); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + expect(textContent.text).toContain("le nom du type ne doit pas être vide"); + }); + + it("should reject legacy inputs removed from the public schema", async () => { + const tool = new GpfWfsGetFeaturesTool(); + const response = await tool.toolCall({ + params: { + name: "gpf_wfs_get_features", + arguments: { + typename: "ADMINEXPRESS-COG.LATEST:commune", + cql_filter: "code_insee = '01001'", + }, + }, + }); + + expect(response.isError).toBe(true); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + expect(textContent.text).toMatch(/unrecognized/i); + expect(textContent.text).toContain("cql_filter"); + }); + + it("should build a POST request with query params and encoded body", async () => { + const tool = new TestableGpfWfsGetFeaturesTool(); + tool.featureTypes[polygonFeatureType.id] = polygonFeatureType; + tool.nextResponse = featureCollection; + + const response = await tool.toolCall({ + params: { + name: "gpf_wfs_get_features", + arguments: { + typename: "ADMINEXPRESS-COG.LATEST:commune", + limit: 7, + select: ["code_insee", "population"], + order_by: [{ property: "population", direction: "desc" }], + where: [{ property: "code_insee", operator: "eq", value: "01001" }], + }, + }, + }); + + expect(response.isError).toBeUndefined(); + expect(tool.requests).toHaveLength(1); + expect(tool.requests[0].query.count).toEqual("7"); + expect(tool.requests[0].query.propertyName).toEqual("code_insee,population"); + expect(tool.requests[0].query.sortBy).toEqual("population D"); + expect(tool.requests[0].body).toContain("cql_filter="); + }); + + it("should keep hits independent from limit and omit propertyName", async () => { + const tool = new TestableGpfWfsGetFeaturesTool(); + tool.featureTypes[polygonFeatureType.id] = polygonFeatureType; + tool.nextResponse = { numberMatched: 321, totalFeatures: 999 }; + + const response = await tool.toolCall({ + params: { + name: "gpf_wfs_get_features", + arguments: { + typename: "ADMINEXPRESS-COG.LATEST:commune", + result_type: "hits", + limit: 999, + select: ["code_insee"], + }, + }, + }); + + expect(response.isError).toBeUndefined(); + expect(tool.requests[0].query.count).toEqual("1"); + expect(tool.requests[0].query.propertyName).toBeUndefined(); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + expect(JSON.parse(textContent.text)).toEqual(321); + }); + + it("should fall back to totalFeatures when numberMatched is absent", async () => { + const tool = new TestableGpfWfsGetFeaturesTool(); + tool.featureTypes[polygonFeatureType.id] = polygonFeatureType; + tool.nextResponse = { totalFeatures: 321 }; + + const response = await tool.toolCall({ + params: { + name: "gpf_wfs_get_features", + arguments: { + typename: "ADMINEXPRESS-COG.LATEST:commune", + result_type: "hits", + }, + }, + }); + + expect(response.isError).toBeUndefined(); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + expect(JSON.parse(textContent.text)).toEqual(321); + }); + + it("should fail clearly when numberMatched is unknown", async () => { + const tool = new TestableGpfWfsGetFeaturesTool(); + tool.featureTypes[polygonFeatureType.id] = polygonFeatureType; + tool.nextResponse = { numberMatched: "unknown" }; + + const response = await tool.toolCall({ + params: { + name: "gpf_wfs_get_features", + arguments: { + typename: "ADMINEXPRESS-COG.LATEST:commune", + result_type: "hits", + }, + }, + }); + + expect(response.isError).toBe(true); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + expect(textContent.text).toContain("numberMatched=\"unknown\""); + }); + + it("should return feature_ref for non point layers and strip geometry", async () => { + const tool = new TestableGpfWfsGetFeaturesTool(); + tool.featureTypes[polygonFeatureType.id] = polygonFeatureType; + tool.nextResponse = { + ...featureCollection, + features: [ + { + type: "Feature", + id: "commune.1", + geometry: { type: "MultiPolygon", coordinates: [] }, + geometry_name: "geometrie", + properties: { code_insee: "01001" }, + }, + ], + }; + + const response = await tool.toolCall({ + params: { + name: "gpf_wfs_get_features", + arguments: { + typename: "ADMINEXPRESS-COG.LATEST:commune", + }, + }, + }); + + expect(response.isError).toBeUndefined(); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + const results = JSON.parse(textContent.text); + expect(results.features[0].geometry).toBeNull(); + expect(results.features[0].feature_ref).toEqual({ + typename: "ADMINEXPRESS-COG.LATEST:commune", + feature_id: "commune.1", + }); + expect(results.features[0].geometry_name).toBeUndefined(); + }); + + it("should strip point geometry too and keep feature_ref only", async () => { + const tool = new TestableGpfWfsGetFeaturesTool(); + tool.featureTypes[pointFeatureType.id] = pointFeatureType; + tool.nextResponse = { + type: "FeatureCollection", + features: [ + { + type: "Feature", + id: "point_d_acces.1", + geometry: { type: "Point", coordinates: [2.3, 48.8] }, + geometry_name: "geometrie", + properties: { cleabs: "id-1" }, + }, + ], + totalFeatures: 1, + }; + + const response = await tool.toolCall({ + params: { + name: "gpf_wfs_get_features", + arguments: { + typename: "BDTOPO_V3:point_d_acces", + select: ["cleabs"], + }, + }, + }); + + expect(response.isError).toBeUndefined(); + expect(tool.requests[0].query.propertyName).toEqual("cleabs"); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + const results = JSON.parse(textContent.text); + expect(results.features[0].geometry).toBeNull(); + expect(results.features[0].feature_ref).toEqual({ + typename: "BDTOPO_V3:point_d_acces", + feature_id: "point_d_acces.1", + }); + expect(results.features[0].geometry_name).toBeUndefined(); + }); + + it("should resolve intersects_feature from MultiPoint references", async () => { + const tool = new TestableGpfWfsGetFeaturesTool(); + tool.featureTypes[multipointFeatureType.id] = multipointFeatureType; + tool.nextResponse = { + type: "FeatureCollection", + features: [ + { + type: "Feature", + id: "localisant.1", + geometry: { type: "MultiPoint", coordinates: [[2.3, 48.8], [2.4, 48.9]] }, + properties: {}, + }, + ], + totalFeatures: 1, + }; + + const response = await tool.toolCall({ + params: { + name: "gpf_wfs_get_features", + arguments: { + typename: "CADASTRALPARCELS.PARCELLAIRE_EXPRESS:localisant", + spatial_operator: "intersects_feature", + intersects_feature_typename: "CADASTRALPARCELS.PARCELLAIRE_EXPRESS:localisant", + intersects_feature_id: "localisant.1", + result_type: "request", + }, + }, + }); + + expect(response.isError).toBeUndefined(); + expect(tool.requests).toHaveLength(1); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + const request = JSON.parse(textContent.text); + expect(request.body).toContain("MULTIPOINT"); + }); + + it("should report missing reference features clearly for intersects_feature", async () => { + const tool = new TestableGpfWfsGetFeaturesTool(); + tool.featureTypes[polygonFeatureType.id] = polygonFeatureType; + tool.nextResponse = { + type: "FeatureCollection", + features: [], + totalFeatures: 0, + }; + + const response = await tool.toolCall({ + params: { + 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.404", + }, + }, + }); + + expect(response.isError).toBe(true); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + expect(textContent.text).toContain("est introuvable"); + expect(textContent.text).toContain("commune.404"); + }); +}); diff --git a/test/tools/wfs/searchTypes.test.ts b/test/tools/wfs/searchTypes.test.ts new file mode 100644 index 0000000..a9c99c3 --- /dev/null +++ b/test/tools/wfs/searchTypes.test.ts @@ -0,0 +1,91 @@ +import GpfWfsSearchTypesTool from "../../../src/tools/GpfWfsSearchTypesTool"; + +describe("Test GpfWfsSearchTypesTool",() => { + class TestableGpfWfsSearchTypesTool extends GpfWfsSearchTypesTool { + async execute() { + return { + results: [ + { + id: "BDTOPO_V3:batiment", + title: "Batiment", + description: "Description de test", + }, + ], + }; + } + } + + it("should expose an enriched MCP definition", () => { + const tool = new GpfWfsSearchTypesTool(); + expect(tool.toolDefinition.title).toEqual("Recherche de types WFS"); + expect(tool.toolDefinition.inputSchema.properties?.query).toMatchObject({ + type: "string", + minLength: 1, + }); + expect(tool.toolDefinition.inputSchema.properties?.max_results).toMatchObject({ + type: "integer", + minimum: 1, + maximum: 50, + }); + expect(tool.toolDefinition.outputSchema).toBeDefined(); + }); + + it("should return both text content and structuredContent", async () => { + const tool = new TestableGpfWfsSearchTypesTool(); + const response = await tool.toolCall({ + params: { + name: "gpf_wfs_search_types", + arguments: { + query: "batiment", + max_results: 1, + }, + }, + }); + + expect(response.isError).toBeUndefined(); + expect(response.content[0]).toMatchObject({ + type: "text", + }); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + expect(JSON.parse(textContent.text)).toMatchObject({ + results: [ + { + id: "BDTOPO_V3:batiment", + }, + ], + }); + expect(response.structuredContent).toBeDefined(); + expect(response.structuredContent).toMatchObject({ + results: [ + { + id: "BDTOPO_V3:batiment", + }, + ], + }); + }); + + it("should return isError=true for invalid input", async () => { + const tool = new GpfWfsSearchTypesTool(); + const response = await tool.toolCall({ + params: { + name: "gpf_wfs_search_types", + arguments: { + query: "", + }, + }, + }); + + expect(response.isError).toBe(true); + expect(response.content[0]).toMatchObject({ + type: "text", + }); + const textContent = response.content[0]; + if (textContent.type !== "text") { + throw new Error("expected text content"); + } + expect(textContent.text).toContain("la requête de recherche ne doit pas être vide"); + }); +});