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
31 changes: 13 additions & 18 deletions src/proxy/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@
* for `intersects_feature` / `travel_time` filters.
*
* Unlike the LLM-facing `executeQueryFeatures` (which strips geometry to `null`
* via `attachFeatureRefs` to save tokens), the proxy needs the OPPOSITE: a
* via `postProcessFeatureCollection` to save tokens), the proxy needs the OPPOSITE: a
* FeatureCollection with FULL geometry, because MCP Carto renders it on a map.
*
* This module reuses the WFS query-compilation primitives (`compileQueryParts`,
* `buildMainRequest`, reference-geometry resolution) but:
* - forces the geometry column into the request `propertyName` itself, without
* touching `buildSelectList` (which stays coupled to the LLM `select`/`spatial_extras` knobs);
* - returns the RAW FeatureCollection, never `attachFeatureRefs`;
* touching `buildPropertyName` / `buildPropertyNameWithGeometry`, which stay
* coupled to the LLM `select`/`spatial_extras` knobs);
* - returns the RAW FeatureCollection, never `postProcessFeatureCollection`;
* - runs against an INJECTED WfsClient, so it is fully testable without network
* and lets the HTTP layer supply a size-bounded, rate-limited client.
*/
Expand All @@ -31,7 +32,8 @@ import {
getSpatialFilter,
type ResolvedFeatureGeometryRef,
} from "../wfs/queryPreparation.js";
import { buildPropertyName, requireSingleFeatureById } from "../wfs/byId.js";
import { requireSingleFeatureById } from "../wfs/byId.js";
import { buildPropertyNameWithGeometry } from "../wfs/properties.js"
import { resolveFeatureGeometryEwkt } from "../wfs/referenceGeometry.js";
import { rethrowIdentifiedCatalogDesyncError } from "../wfs/catalogDesync.js";
import { ServiceResponseError, extractJsonServiceError } from "../helpers/http.js";
Expand Down Expand Up @@ -72,20 +74,16 @@ export type GeometryFeatureQueryDeps = {

/**
* Appends the geometry column to a compiled `propertyName` selection so the WFS
* returns full geometry. An empty/undefined selection means "all properties",
* which already includes geometry, so it is left untouched.
* returns full geometry.
*
* @param propertyName Comma-separated selection from `compileQueryParts`, if any.
* @param propertyName Comma-separated selection from `compileQueryParts`.
* @param geometryProperty Geometry property resolved for the feature type.
* @returns A selection guaranteed to include the geometry column, or `undefined`.
* @returns A selection guaranteed to include the geometry column.
*/
function ensureGeometrySelected(
propertyName: string | undefined,
propertyName: string,
geometryProperty: CollectionProperty,
): string | undefined {
if (!propertyName) {
return undefined;
}
): string {
const columns = propertyName.split(",");
if (columns.includes(geometryProperty.name)) {
return propertyName;
Expand Down Expand Up @@ -255,7 +253,7 @@ export type GeometryFeatureByIdQueryDeps = {
* returned;
* - requests WGS84 (`EPSG:4326`, [lon, lat]) like the query path;
* - enforces strict cardinality (0 or >1 results throw);
* - returns the untransformed single-feature collection — never `attachFeatureRefs`
* - returns the untransformed single-feature collection — never `postProcessFeatureCollection`
* (which would null the geometry).
*
* @param input Validated by-id layer input (`{ typename, feature_id, select? }`).
Expand All @@ -272,10 +270,7 @@ export async function runGeometryFeatureByIdQuery(
// Validate `select` against the same embedded catalog used at URL generation,
// then force the geometry column into the WFS selection. Re-validating here is
// required because decoded proxy tokens remain untrusted input.
const propertyName = buildPropertyName(featureType, {
includeGeometry: true,
select: input.select,
});
const propertyName = buildPropertyNameWithGeometry(featureType, input.select);
const request = buildGetFeatureByIdRequest(
input.typename,
input.feature_id,
Expand Down
10 changes: 5 additions & 5 deletions src/tools/GpfGetFeatureByIdLayerTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import { getEnv } from "../config/env.js";
import { encodeToken } from "../proxy/token.js";
import { buildDataUrl } from "../proxy/dataUrl.js";
import { wfsClient } from "../wfs/execution.js";
import { buildPropertyName } from "../wfs/byId.js";
import { buildPropertyNameWithGeometry, getGeometryProperty } from "../wfs/properties.js";
import {
PROXY_TOKEN_KIND,
gpfGetFeatureByIdLayerInputObjectSchema,
Expand Down Expand Up @@ -125,10 +125,10 @@ class GpfGetFeatureByIdLayerTool extends BaseTool<GpfGetFeatureByIdLayerInput> {
// The `feature_id` cannot be validated here — that is a network lookup resolved
// at fetch time by the proxy (runGeometryFeatureByIdQuery).
const featureType = await wfsClient.getFeatureType(tokenParams.typename);
buildPropertyName(featureType, {
includeGeometry: true,
select: tokenParams.select,
});
buildPropertyNameWithGeometry(featureType, tokenParams.select);

// Also check that the typename corresponds to a collection that has a geometry.
getGeometryProperty(featureType);

logger.info(`[tool] execute ${this.name} ...`, {
input: tokenParams,
Expand Down
3 changes: 3 additions & 0 deletions src/tools/GpfGetFeaturesLayerTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ class GpfGetFeaturesLayerTool extends BaseTool<GpfGetFeaturesLayerInput> {
const featureType = await wfsClient.getFeatureType(compiledInput.typename);
const spatialFilter = getSpatialFilter(compiledInput);

// Also check that the typename corresponds to a collection that has a geometry.
getGeometryProperty(featureType);

// Also validate the reference feature's typename for an intersects_feature
// filter — it is a SECOND typename the proxy would resolve at fetch time
// (execute.ts resolveReferenceGeometry), and an unknown one would otherwise
Expand Down
58 changes: 7 additions & 51 deletions src/wfs/byId.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ import type {
WfsFeatureCollectionResponse,
WfsFeatureResponse,
} from "./types.js";
import { validateSelectProperty, getGeometryProperty } from "./queryPreparation.js";
import { buildGetFeatureByIdRequest } from "./request.js";
import { attachFeatureRefs } from "./response.js";
import { buildPropertyName } from "./properties.js";
import { postProcessFeatureCollection } from "./response.js";

// --- Input Types ---

Expand All @@ -32,59 +32,18 @@ export type GetFeatureByIdExecutionInput = {

// --- Internal Types ---

type PropertySelectionInput = {
includeGeometry?: boolean;
select?: string[];
};

type FetchFeatureByIdInput = {
typename: string;
feature_id: string;
propertyName?: string;
propertyName: string;
};

// --- Property Selection ---

/**
* Builds the optional `propertyName` request parameter from `select`.
*
* @param featureType Feature type definition loaded from the embedded catalog.
* @param input Property selection options derived from the caller's output mode.
* @returns A comma-separated property list, or `undefined` when all properties should be returned.
*/
export function buildPropertyName(
featureType: Collection,
input: PropertySelectionInput,
) {
// `includeGeometry` is also an invariant check: even without `select`, a
// cartographic/derived-geometry caller must fail against a geometry-less type
// before issuing a WFS request.
const geometryProperty = input.includeGeometry
? getGeometryProperty(featureType)
: undefined;

if (!input.select || input.select.length === 0) {
return undefined;
}

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

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

return selectedProperties.join(",");
}

// --- Live Lookup ---

/**
* Executes the live WFS lookup targeting a single `featureID`.
*
* @param input Target layer, expected feature id, and optional property selection.
* @param input Target layer, expected feature id, and property selection.
* @returns The raw FeatureCollection returned by the WFS service.
*/
export async function fetchFeatureById(
Expand Down Expand Up @@ -176,7 +135,7 @@ export function requireSingleFeatureById(
*
* This function:
* - loads the feature type from the embedded catalog
* - builds the optional `propertyName` selection
* - builds the `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
Expand All @@ -194,10 +153,7 @@ export async function executeGetFeatureById(
input: GetFeatureByIdExecutionInput,
) {
const featureType: Collection = await wfsClient.getFeatureType(input.typename);
const propertyName = buildPropertyName(featureType, {
includeGeometry: (input.spatial_extras ?? []).length > 0,
select: input.select,
});
const propertyName = buildPropertyName(featureType, input.select, input.spatial_extras);
const featureCollection = await fetchFeatureById({
typename: input.typename,
feature_id: input.feature_id,
Expand All @@ -213,5 +169,5 @@ export async function executeGetFeatureById(
numberMatched: 1,
};

return attachFeatureRefs(singleFeatureCollection, input.typename, input.spatial_extras);
return postProcessFeatureCollection(singleFeatureCollection, input);
}
8 changes: 5 additions & 3 deletions src/wfs/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
buildMainRequest,
type CompiledRequest,
} from "./request.js";
import { attachFeatureRefs } from "./response.js";
import { postProcessFeatureCollection } from "./response.js";
import type { GpfQueryFeaturesInput } from "./schema.js";

// --- Types ---
Expand Down Expand Up @@ -197,12 +197,14 @@ export async function executeQueryFeatures(input: GpfQueryFeaturesInput) {
} catch (error: unknown) {
// Rewrite an embedded-catalog geometry-column desync into a clear diagnostic
// (shared with the proxy path); any other error passes through unchanged.
rethrowIdentifiedCatalogDesyncError(error, compiled.geometryProperty.name, input.typename);
if (compiled.geometryProperty) {
Comment thread
esgn marked this conversation as resolved.
rethrowIdentifiedCatalogDesyncError(error, compiled.geometryProperty.name, input.typename);
}
throw error;
}

if (isGetFeaturesQuery) {
return attachFeatureRefs(featureCollection, input.typename, input.spatial_extras);
return postProcessFeatureCollection(featureCollection, input);
} else {
return {
numberMatched: getMatchedFeatureCount(featureCollection),
Expand Down
67 changes: 41 additions & 26 deletions src/wfs/properties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
*/

import type { Collection, CollectionProperty } from "@ignfab/gpf-schema-store";
import type { GpfGetFeaturesInput } from "./schema.js";

// --- Property Listing ---

Expand Down Expand Up @@ -78,14 +77,13 @@ function getPropertyOrThrow(featureType: Collection, propertyName: string) {
* 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.
*/
export function resolveNonGeometryProperty(featureType: Collection, geometryProperty: CollectionProperty, propertyName: string, message: string) {
export function resolveNonGeometryProperty(featureType: Collection, propertyName: string, message: string) {
const property = getPropertyOrThrow(featureType, propertyName);
if (property.name === geometryProperty.name || property.defaultCrs) {
if (property.defaultCrs) {
throw new Error(message.replace("{property}", property.name));
}
return property;
Expand All @@ -97,14 +95,12 @@ export function resolveNonGeometryProperty(featureType: Collection, geometryProp
* 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.
*/
export function validateSelectProperty(featureType: Collection, geometryProperty: CollectionProperty, propertyName: string) {
function validateSelectProperty(featureType: Collection, propertyName: string) {
return resolveNonGeometryProperty(
featureType,
geometryProperty,
propertyName,
"La propriété '{property}' est géométrique. `select` accepte uniquement des propriétés non géométriques."
).name;
Expand All @@ -121,42 +117,61 @@ export function validateSelectProperty(featureType: Collection, geometryProperty
* - when `spatial_extras` is non-empty, the geometry column is appended so elements of GPF_GET_FEATURES_SPATIAL_EXTRAS (bbox, centroid, ...) can be derived
*
* @param featureType Feature type definition loaded from the embedded catalog.
* @param select The list of selected non-geometric properties.
* @param spatial_extras The list of selected extra properties to compute on the geometry.
* @param geometryProperty Geometry property already resolved for the feature type.
* @param input Normalized tool input.
* @param includeGeometry Override boolean to force including the geometry in the return query.
* @returns The list of property names to expose in the WFS `propertyName` parameter.
Comment thread
LionelZoubritzky-IGN marked this conversation as resolved.
*/
export function buildSelectList(
export function buildPropertyName(
featureType: Collection,
geometryProperty: CollectionProperty,
input: GpfGetFeaturesInput,
) {
const shouldIncludeGeometry = (input.spatial_extras ?? []).length > 0;
select?: string[],
spatial_extras?: string[],
Comment thread
esgn marked this conversation as resolved.
geometryProperty?: CollectionProperty,
includeGeometry: boolean = (spatial_extras ?? []).length > 0,
) : string {

Comment thread
LionelZoubritzky-IGN marked this conversation as resolved.
// If `select` is specified, only the requested properties are returned
// after validation against the embedded catalog.
if (input.select && input.select.length > 0) {
const selectedProperties = input.select.map((propertyName) =>
validateSelectProperty(featureType, geometryProperty, propertyName),
if (select && select.length > 0) {
const selectedProperties = select.map((propertyName) =>
validateSelectProperty(featureType, propertyName),
);

// Include geometry when `spatial_extras` needs it to derive bbox/centroid/...
if (shouldIncludeGeometry) {
return [...selectedProperties, geometryProperty.name];
if (includeGeometry) {
return [...selectedProperties, (geometryProperty ?? getGeometryProperty(featureType)).name].join(",");
}

return selectedProperties;
return selectedProperties.join(",");
}

// If `select` is omitted, return every non-geometric property from the
// feature type, appending the geometry column only when `spatial_extras`
// needs it.
// feature type, appending the geometry column only when it is required,
// for example when `spatial_extras` needs it to derive bbox/centroid/...

if (includeGeometry) {
// Ensure that the geometric property exists and is unique.
geometryProperty ?? getGeometryProperty(featureType);
Comment thread
esgn marked this conversation as resolved.
return featureType.properties
.map((property: CollectionProperty) => property.name)
.join(","); // return all properties
}

const nonGeometryProperties = featureType.properties
.filter((property: CollectionProperty) => !property.defaultCrs)
.map((property: CollectionProperty) => property.name);

if (shouldIncludeGeometry) {
return [...nonGeometryProperties, geometryProperty.name];
}
return nonGeometryProperties.join(",");
}

return nonGeometryProperties;
/**
* `buildPropertyName` for cartographic callers: the geometry column is always
* selected, and a geometry-less type must fail here rather than at map load.
*/
export function buildPropertyNameWithGeometry(
Comment thread
LionelZoubritzky-IGN marked this conversation as resolved.
featureType: Collection,
select?: string[],
geometryProperty: CollectionProperty = getGeometryProperty(featureType),
) {
return buildPropertyName(featureType, select, [], geometryProperty, true);
}
Loading
Loading