Skip to content
Open
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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

compileQueryParts now only populates compiled.geometryProperty inside if (spatialFilter), but buildPropertyName still forces the geometry column into propertyName whenever spatial_extras is non-empty resolving it locally (properties.ts:142) without handing it back. So for a query with spatial_extras and no spatial filter, the column is in the request while this field is undefined, and the rewrite is skipped.

The two names have drifted apart:

  • compiled.geometryProperty now means "column used by the spatial filter"
  • catalogDesync needs "column injected into the request"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed and tested in 1842373

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will have to be updated based on decisions deriving from other comments

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing to do so far, but keeping this open while we don't have a common conclusion on the issues above.

*/
export function buildSelectList(
export function buildPropertyName(
featureType: Collection,
geometryProperty: CollectionProperty,
input: GpfGetFeaturesInput,
) {
const shouldIncludeGeometry = (input.spatial_extras ?? []).length > 0;
select?: string[],
spatial_extras?: string[],

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

spatial_extras is never read in the body, its only uses are this declaration, the JSDoc, and the
default value of includeGeometry.
buildPropertyNameWithGeometry(ft, select, spatial_extras)
silently ignores its 3rd argument, since it forces includeGeometry: true. Both real callers pass only (featureType, select).

Maybe drop spatial_extras from both signatures and let each caller compute includeGeometry
explicitly at the call site (it already did before this PR).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer the current design, it avoids duplicating the logic to detect whether or not includeGeometry should be set. That was actually one of the main points of this refactor.

However, I agree that spatial_extras is irrelevant for buildPropertyNameWithGeometry so I removed the spatial_extras argument from buildPropertyNameWithGeometry in 0c810c5

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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

geometryProperty ?? getGeometryProperty(featureType); is an expression statement whose result is
discarded. It only exists to throw. And the only caller with includeGeometry: true always passes
geometryProperty, so the right-hand side never runs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the result is discarded, it only exists to throw. But no, the right-hand side can run, because includeGeometry can be set on the other caller if spatial_extra is not empty. That would be the case of a GetFeatures call with empty select but non-empty spatial_extra on a geometry-less collection: this is tested in the new tests of this PR at https://github.com/ignfab/geocontext/pull/175/changes#diff-188df1952a07ac03771df78672fa7af090aa35892cbc19c19840fa99b6e3f970R437-R459

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