Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

119 changes: 119 additions & 0 deletions src/helpers/wfs_engine/byId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* Execution helpers for exact WFS feature lookup by `feature_id`.
*
* This module runs the structured WFS by-id flow independently from MCP tool
* concerns such as schema exposure and response formatting.
*/

import type { Collection } from "@ignfab/gpf-schema-store";

import { getFeatureType, fetchFeatureCollection } from "./execution.js";
import { compileSelectProperty, getGeometryProperty } from "./compile.js";
import { buildGetFeatureByIdRequest } from "./request.js";
import { attachFeatureRefs } from "./response.js";

export type GetFeatureByIdExecutionInput = {
typename: string;
feature_id: string;
select?: string[];
};

type BuildPropertyNameInput = {
result_type: "results" | "request";
select?: string[];
};

/**
* Builds the optional `propertyName` request parameter from `select`.
*
* @param featureType Feature type definition loaded from the embedded catalog.
* @param input Normalized tool input.
* @returns A comma-separated property list, or `undefined` when all properties should be returned.
*/
export function buildPropertyName(
featureType: Collection,
input: BuildPropertyNameInput,
) {
if (!input.select || input.select.length === 0) {
return undefined;
}

const geometryProperty = getGeometryProperty(featureType);
const selectedProperties = input.select.map((propertyName) =>
compileSelectProperty(featureType, geometryProperty, propertyName),
);

if (input.result_type === "request") {
return [...selectedProperties, geometryProperty.name].join(",");
}

return selectedProperties.join(",");
}

/**
* Executes the structured WFS by-id flow for `result_type="results"`.
*
* This function:
* - loads the feature type from the embedded catalog
* - builds the optional `propertyName` selection
* - executes the WFS request for the requested `feature_id`
* - enforces strict cardinality on the returned FeatureCollection
* - attaches reusable `feature_ref` metadata to the final response
*
* Tool-specific concerns such as MCP schema exposure and request-preview
* formatting remain outside this helper.
*
* This helper is intentionally scoped to the `results` path only. MCP-specific
* request preview assembly remains in the tool layer.
*
* @param input Normalized by-id execution input for the `results` flow.
* @returns A transformed FeatureCollection containing exactly one feature.
*/
export async function executeGetFeatureById(
input: GetFeatureByIdExecutionInput,
) {
const featureType: Collection = await getFeatureType(input.typename);
const propertyName = buildPropertyName(featureType, {
result_type: "results",
select: input.select,
});
const request = buildGetFeatureByIdRequest(
input.typename,
input.feature_id,
propertyName,
);

const featureCollection = await fetchFeatureCollection(request);

if (!Array.isArray(featureCollection?.features)) {
throw new Error("Le service WFS n'a pas retourné de collection d'objets exploitable.");
}

if (featureCollection.features.length === 0) {
throw new Error(`Le feature '${input.feature_id}' est introuvable dans '${input.typename}'.`);
}

if (featureCollection.features.length > 1) {
throw new Error(
`Le feature '${input.feature_id}' dans '${input.typename}' devrait être unique, mais ${featureCollection.features.length} objets ont été retournés.`
);
}

const [firstFeature] = featureCollection.features;

if (firstFeature?.id !== input.feature_id) {
throw new Error(
`Le service WFS a retourné l'identifiant '${String(firstFeature?.id)}' au lieu de '${input.feature_id}'.`
);
}

const singleFeatureCollection = {
...featureCollection,
features: [firstFeature],
totalFeatures: 1,
numberReturned: 1,
numberMatched: 1,
};

return attachFeatureRefs(singleFeatureCollection, input.typename);
}
54 changes: 54 additions & 0 deletions src/helpers/wfs_engine/execution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { CompiledRequest } from "./request.js";
import { wfsClient } from "../../gpf/wfs-schema-catalog.js";
import { fetchJSONPost } from "../../helpers/http.js";

/**
* Shared WFS execution helpers for the structured WFS engine.
*
* This module centralizes catalog lookup, compiled request execution, and a few
* low-level response helpers reused by MCP WFS tools.
*/

/**
* 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.
*/
export async function 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.
*/
export async function 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.
*/
export function getMatchedFeatureCount(featureCollection: Record<string, unknown>) {
if (typeof featureCollection.numberMatched === "number") {
return featureCollection.numberMatched;
}
if (featureCollection.numberMatched === "unknown") {
throw new Error("Le service WFS a renvoyé un comptage indéterminé (numberMatched=\"unknown\").");
}
if (typeof featureCollection.totalFeatures === "number") {
return featureCollection.totalFeatures;
}
throw new Error("Le service WFS n'a pas retourné de comptage exploitable");
}
181 changes: 181 additions & 0 deletions src/helpers/wfs_engine/features.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import type { Collection } from "@ignfab/gpf-schema-store";

import logger from "../../logger.js";
import {
compileQueryParts,
geometryToEwkt,
getGeometryProperty,
getSpatialFilter,
type CompiledQuery,
type ResolvedFeatureGeometryRef,
} from "./compile.js";
import {
fetchFeatureCollection,
getFeatureType,
getMatchedFeatureCount,
} from "./execution.js";
import {
buildMainRequest,
buildReferenceGeometryRequest,
type CompiledRequest,
} from "./request.js";
import { attachFeatureRefs } from "./response.js";
import type { GpfWfsGetFeaturesInput } from "./schema.js";

/**
* Shared execution helpers for structured WFS feature search.
*
* This module owns the WFS-side execution flow for `gpf_wfs_get_features`:
* request preparation, optional reference-geometry lookup, query execution,
* hit counting, and FeatureCollection post-processing.
*/

// --- Types ---

export type PreparedGetFeaturesRequest = {
compiled: CompiledQuery;
request: CompiledRequest;
};

// --- Validation ---

/**
* Rejects `intersects_feature` requests that target the same typename.
*
* In that configuration the predicate may legitimately match multiple
* features, so callers must switch to the by-id tool instead.
*
* @param input Normalized tool input.
*/
export function ensureIntersectsFeatureTargetsOtherTypename(
input: GpfWfsGetFeaturesInput,
) {
if (
input.spatial_operator === "intersects_feature" &&
input.intersects_feature_typename !== undefined &&
input.typename === input.intersects_feature_typename
) {
throw new Error(
"Le filtre `intersects_feature` sur le même `typename` retourne potentiellement plusieurs objets. " +
"Utiliser `gpf_wfs_get_feature_by_id` avec `{ typename, feature_id: intersects_feature_id }` pour cibler exactement un objet.",
);
}
}

// --- Reference Geometry ---

/**
* Resolves the geometry of a reference feature when `intersects_feature` is used,
* then converts it to EWKT for CQL compilation.
*
* This helper currently reads the first feature returned by the reference
* lookup. It ensures that a feature exists and exposes a usable geometry, but
* does not enforce strict uniqueness or exact `id` matching beyond what the WFS
* request itself guarantees.
*
* @param input Normalized tool input.
* @returns The resolved reference geometry, or `undefined` when no reference feature is needed.
*/
export async function resolveIntersectsFeatureGeometry(
input: GpfWfsGetFeaturesInput,
): Promise<ResolvedFeatureGeometryRef | undefined> {
const spatialFilter = getSpatialFilter(input);
if (!spatialFilter || spatialFilter.operator !== "intersects_feature") {
return undefined;
}

const referenceFeatureType = await getFeatureType(spatialFilter.typename);
const referenceGeometryProperty = getGeometryProperty(referenceFeatureType);
const request = buildReferenceGeometryRequest(
spatialFilter.typename,
spatialFilter.feature_id,
referenceGeometryProperty.name,
);
const featureCollection = await 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.`,
);
}

return {
typename: spatialFilter.typename,
feature_id: spatialFilter.feature_id,
geometry_ewkt: geometryToEwkt(referenceFeature.geometry),
};
}

// --- Request Preparation ---

/**
* Prepares the main WFS request for `gpf_wfs_get_features`.
*
* This includes upfront validation of unsupported same-typename
* `intersects_feature` requests, feature type lookup, optional
* reference-geometry resolution, query compilation, and request assembly.
*
* @param input Normalized tool input.
* @returns The compiled query fragments and final WFS request.
*/
export async function prepareGetFeaturesRequest(
input: GpfWfsGetFeaturesInput,
): Promise<PreparedGetFeaturesRequest> {
ensureIntersectsFeatureTargetsOtherTypename(input);

const featureType: Collection = await getFeatureType(input.typename);
const resolvedGeometryRef = await resolveIntersectsFeatureGeometry(input);
const compiled = compileQueryParts(input, featureType, resolvedGeometryRef);
const request = buildMainRequest(input, compiled);

return { compiled, request };
}

// --- Execution ---

/**
* Executes the structured WFS search flow for `result_type="results"` and `hits`.
*
* This function prepares the request, executes it against the live WFS, then
* either extracts a hit count or attaches `feature_ref` metadata to the result
* FeatureCollection.
*
* @param input Normalized tool input.
* @returns Either a hit-count payload or a transformed FeatureCollection.
*/
export async function executeGetFeatures(input: GpfWfsGetFeaturesInput) {
const { compiled, request } = await prepareGetFeaturesRequest(input);

let featureCollection: any;
try {
logger.info(
`[gpf_wfs_get_features] POST ${request.url}?${new URLSearchParams(request.query).toString()}`,
);
featureCollection = await 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: getMatchedFeatureCount(featureCollection),
};
}

return attachFeatureRefs(featureCollection, input.typename);
}
Loading
Loading