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
4 changes: 4 additions & 0 deletions src/proxy/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
ProxyTokenTooLargeError,
} from "./token.js";
import { ResponseTooLargeError, ServiceResponseError } from "../helpers/http.js";
import { CqlFilterTooLargeError } from "../wfs/spatialCql.js";
import { ZodError } from "zod";

// --- Error → HTTP status mapping ---
Expand Down Expand Up @@ -74,6 +75,9 @@ function toHttpError(error: unknown): HttpError {
// contract (duplicate / id mismatch / unusable body): an upstream anomaly.
return { status: 502, detail: "Le service WFS a renvoyé une réponse incohérente pour cet objet." };
}
if (error instanceof CqlFilterTooLargeError) {
return { status: 413, detail: "La couche demandée exige un filtrage spatial trop lourd pour être calculé." };
}
if (error instanceof ServiceResponseError) {
const upstream = error.httpStatus ?? 502;
// 4xx from the WFS is a bad request we forwarded; expose as 502 unless it is a
Expand Down
5 changes: 5 additions & 0 deletions src/wfs/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@ export class WfsSchemaStore {
throw new FeatureTypeNotFoundError(name);
}

/** Checks whether a typename exists in the embedded catalog without throwing for missing entries. */
hasFeatureType(name: string): boolean {
return this.catalog.getCollectionSchema(name) !== undefined;
}

}

// --- Default singleton ---
Expand Down
16 changes: 15 additions & 1 deletion src/wfs/queryPreparation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ import {
compileDwithinSpatialFilter,
compileIntersectsFeatureSpatialFilter,
compileIntersectsPointSpatialFilter,
CqlFilterTooLargeError,
encodedCqlFilterBytes,
MAX_CQL_FILTER_BYTES,
} from "./spatialCql.js";
import type { Geometry } from "geojson";

Expand Down Expand Up @@ -207,7 +210,9 @@ export function compileQueryParts(
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(geometryName, resolvedGeometryRef));
fragments.push(
compileIntersectsFeatureSpatialFilter(geometryName, resolvedGeometryRef, spatialFilter.typename),
);
break;
case "travel_time":
if (!resolvedGeometryRef) {
Expand All @@ -226,6 +231,15 @@ export function compileQueryParts(
}

const cqlFilter = fragments.length > 0 ? fragments.join(" AND ") : undefined;
if (cqlFilter) {
const cqlFilterBytes = encodedCqlFilterBytes(cqlFilter);
if (cqlFilterBytes > MAX_CQL_FILTER_BYTES) {
throw new CqlFilterTooLargeError(
`Le filtre CQL est trop volumineux : ${cqlFilterBytes} octets une fois encodé (limite : ${MAX_CQL_FILTER_BYTES}). ` +
"Réduire le nombre de clauses attributaires ou simplifier les filtres.",
);
}
}

if (!isGetFeatures) {
// for CountFeatures: only return the required parts
Expand Down
17 changes: 11 additions & 6 deletions src/wfs/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import { GPF_WFS_URL } from "./catalog.js";
import type { CompiledQuery } from "./queryPreparation.js";
import { queryIsGetFeaturesInput, type GpfQueryFeaturesInput } from "./schema.js";
import { CqlFilterTooLargeError, MAX_CQL_FILTER_BYTES } from "./spatialCql.js";

// --- Transport Types ---

Expand All @@ -26,12 +27,20 @@ export type CompiledRequest = {
*
* @param cqlFilter Compiled CQL filter, when the request needs one.
* @returns The encoded POST body, or an empty string when no filter is present.
* @throws When the body exceeds `MAX_CQL_FILTER_BYTES`, since the WFS would silently ignore the filter.
*/
function buildBody(cqlFilter?: string) {
if (!cqlFilter) {
return "";
}
return new URLSearchParams({ cql_filter: cqlFilter }).toString();
const body = new URLSearchParams({ cql_filter: cqlFilter }).toString();
// The URL-encoded body is pure ASCII: its length is its size in bytes.
if (body.length > MAX_CQL_FILTER_BYTES) {
throw new CqlFilterTooLargeError(
`Le filtre CQL est trop volumineux : ${body.length} octets une fois encodé (limite : ${MAX_CQL_FILTER_BYTES}).`,
);
}
return body;
}

// --- Public Builders ---
Expand Down Expand Up @@ -164,14 +173,10 @@ export function buildMultiTypenameRequest(
srsName: "EPSG:4326",
};

const body = expandedCqlFilter
? new URLSearchParams({ cql_filter: expandedCqlFilter }).toString()
: "";

return {
method: "POST",
url: GPF_WFS_URL,
query,
body,
body: buildBody(expandedCqlFilter),
};
}
73 changes: 70 additions & 3 deletions src/wfs/spatialCql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,41 @@
import type { Geometry } from "geojson";
import type { SpatialFilter } from "./schema.js";
import { geometryToEwkt } from "./geometry.js";
import { wfsSchemaStore } from "./catalog.js";

/**
* Maximum size of the URL-encoded `cql_filter=...` POST body accepted by the Géoplateforme WFS.
* This matches Tomcat's default `maxPostSize` (a server setting that can change); beyond it,
* the server silently ignores form parameters, so it returns the whole layer (measured on 2026-10-13).
*/
export const MAX_CQL_FILTER_BYTES = 2 * 1024 * 1024;

export class CqlFilterTooLargeError extends Error {
constructor(message: string) {
super(message);
this.name = "CqlFilterTooLargeError";
}
}

/**
* Computes the size of the POST body carrying a CQL filter, to compare with `MAX_CQL_FILTER_BYTES`.
*
* @param cqlFilter Compiled CQL filter.
* @returns The size in bytes of the URL-encoded body (pure ASCII, so its length is its byte size).
*/
export function encodedCqlFilterBytes(cqlFilter: string) {
return new URLSearchParams({ cql_filter: cqlFilter }).toString().length;
}

/** Returns the same-version CARTO-PE twin only when its derived typename exists in the catalog. */
function toSmallScaleTypename(typename: string): string | undefined {
const match = /^ADMINEXPRESS-COG\.([^:.]+):(.+)$/.exec(typename);
if (!match) {
return undefined;
}
const candidate = `ADMINEXPRESS-COG-CARTO-PE.${match[1]}:${match[2]}`;
return wfsSchemaStore.hasFeatureType(candidate) ? candidate : undefined;
}

// --- Spatial Predicate Compilation ---

Expand Down Expand Up @@ -51,12 +86,44 @@ export function compileDwithinSpatialFilter(geometryName: string, spatialFilter:
}

/**
* Compiles an `intersects_feature` spatial filter from the resolved reference geometry.
* Compiles a spatial filter from the intersection with a resolved reference geometry.
* Used in `intersects_feature` and `travel_time` filters.
* Throw if the filter is too big.
*
* @param geometryName Geometry property already resolved for the feature type.
* @param geometry Reference geometry.
* @param referenceTypename Typename of the reference feature for `intersects_feature`, nothing for `travel_time`. Used for error diagnostic.
* @returns A CQL intersects predicate.
* @throws When the predicate exceeds the budget.
*/
export function compileIntersectsFeatureSpatialFilter(geometryName: string, geometry: Geometry) {
return `INTERSECTS(${geometryName},${geometryToEwkt(geometry)})`;
export function compileIntersectsFeatureSpatialFilter(
geometryName: string,
geometry: Geometry,
referenceTypename?: string,
) {
const exactFilter = `INTERSECTS(${geometryName},${geometryToEwkt(geometry)})`;
const filterBytes = encodedCqlFilterBytes(exactFilter);
if (filterBytes <= MAX_CQL_FILTER_BYTES) {
return exactFilter;
}

const recommendation = (() => {
if (!referenceTypename) {
return "Réduire la taille de l'isochrone.";
}
const peTwin = toSmallScaleTypename(referenceTypename);
if (peTwin) {
return `Choisir une géométrie de référence moins détaillée : privilégier la couche '${peTwin}' au lieu de '${referenceTypename}'. Les feature_id diffèrent entre ces deux couches : ne pas réutiliser le même identifiant, retrouver l'objet dans '${peTwin}' par sa propriété cleabs (identique dans les deux couches).`;
}
if (referenceTypename.includes("CARTO-PE")) {
return "Choisir une géométrie de référence moins détaillée.";
}
return "Choisir une géométrie de référence moins détaillée. Utiliser le tool gpf_search_types pour trouver une couche adaptée et disponible dans le catalogue.";
})();

const filterName = referenceTypename ? "La géométrie de référence" : "L'isochrone";
throw new CqlFilterTooLargeError([
`${filterName} est trop volumineuse pour \`${referenceTypename ? "intersects_feature" : "travel_time"}_filter\` : ${filterBytes} octets de filtre encodé (limite : ${MAX_CQL_FILTER_BYTES}).`,
recommendation,
].join("\n"));
}
11 changes: 11 additions & 0 deletions test/proxy/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { encodeToken } from "../../src/proxy/token";
import { resetEnv } from "../../src/config/env";
import { PROXY_TOKEN_KIND } from "../../src/wfs/schema";
import { FeatureNotFoundError, FeatureCardinalityError } from "../../src/wfs/byId";
import { CqlFilterTooLargeError } from "../../src/wfs/spatialCql";
import { ServiceResponseError, ResponseTooLargeError } from "../../src/helpers/http";

// Mock the proxy engine + transport so the server is exercised WITHOUT network.
Expand Down Expand Up @@ -119,6 +120,16 @@ describe("proxy/server", () => {
expect(res.text).not.toContain("upstream timed out");
});

it("413 on a CQL filter built from an oversized isochrone", async () => {
const detail = "La couche demandée exige un filtrage spatial trop lourd pour être calculé.";
runGeometryFeatureQuery.mockRejectedValue(new CqlFilterTooLargeError(detail));

const res = await request(baseUrl).get(layerPath(validToken()));

expect(res.status).toBe(413);
expect(res.body).toEqual({ error: true, detail });
});

it("500 on an unexpected (non-typed) error", async () => {
runGeometryFeatureQuery.mockRejectedValue(new Error("boom"));
const res = await request(baseUrl).get(layerPath(validToken()));
Expand Down
10 changes: 10 additions & 0 deletions test/wfs/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,16 @@ describe("Test WfsSchemaStore",() => {
});
});

describe("hasFeatureType", () => {
it("should recognize an existing catalog entry", () => {
expect(wfsSchemaStore.hasFeatureType("BDTOPO_V3:batiment")).toBe(true);
});

it("should return false for a missing entry without throwing", () => {
expect(wfsSchemaStore.hasFeatureType("BDTOPO_V3:not_found")).toBe(false);
});
});

describe("getFeatureType",() => {
it("should return the feature type with BDTOPO_V3:batiment", async () => {
const featureType = await wfsSchemaStore.getFeatureType("BDTOPO_V3:batiment");
Expand Down
9 changes: 9 additions & 0 deletions test/wfs/queryPreparation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,15 @@ describe("gpfGetFeatures/queryPreparation", () => {
);
});

it("should reject queries when combined CQL filter exceeds MAX_CQL_FILTER_BYTES once URL-encoded", () => {
// ~1.6 MB as raw text, ~2.3 MB once URL-encoded
const hugeWhereValues = Array.from({ length: 120_000 }, (_, i) => `val_${i}`);
expect(() => compileQueryParts({
...baseInput,
where: [{ property: "code_insee", operator: "in", values: hugeWhereValues }],
}, wrappedFeatureType)).toThrow(/Le filtre CQL est trop volumineux/);
});

it("should build sortBy from structured order_by", () => {
const compiled = compileQueryParts({
...baseInput,
Expand Down
52 changes: 52 additions & 0 deletions test/wfs/referenceGeometry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, it, vi } from "vitest";

import type { GpfFeatureType } from "../../src/wfs/catalog.js";
import type { CompiledRequest } from "../../src/wfs/request.js";
import {
resolveFeatureGeometry,
type ReferenceGeometryClient,
} from "../../src/wfs/referenceGeometry.js";

const geometry = { type: "Point", coordinates: [2, 48] } as const;

function featureType(typename: string): GpfFeatureType {
return {
typename,
schema: { properties: { geom: { format: "geometry-point" }, nom: { type: "string" } } },
} as unknown as GpfFeatureType;
}

function makeClient(knownTypenames: string[]) {
const getFeatureType = vi.fn(async (typename: string) => {
if (!knownTypenames.includes(typename)) {
throw new Error(`unknown ${typename}`);
}
return featureType(typename);
});
const fetchFeatureCollection = vi.fn(async (_request: CompiledRequest) => ({
type: "FeatureCollection",
features: [{ type: "Feature", id: "commune.1", geometry, properties: {} }],
}));
const client = { getFeatureType, fetchFeatureCollection } as unknown as ReferenceGeometryClient;
return { client, getFeatureType, fetchFeatureCollection };
}

const ref = (typename: string) => ({ typename, feature_id: "commune.1" });

describe("resolveFeatureGeometry", () => {
it.each([
"ADMINEXPRESS-COG.2025:commune",
"ADMINEXPRESS-COG.2026:commune",
"ADMINEXPRESS-COG.LATEST:commune",
"ADMINEXPRESS-COG.2024:commune",
"BDTOPO_V3:batiment",
])("queries the requested typename %s directly without automatic remapping", async (typename) => {
const { client, getFeatureType, fetchFeatureCollection } = makeClient([typename]);

await expect(resolveFeatureGeometry(client, ref(typename))).resolves.toEqual(geometry);

expect(getFeatureType).toHaveBeenCalledTimes(1);
expect(getFeatureType).toHaveBeenCalledWith(typename);
expect(fetchFeatureCollection.mock.calls[0][0].query.typeNames).toBe(typename);
});
});
41 changes: 41 additions & 0 deletions test/wfs/request.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { describe, expect, it } from "vitest";
import { buildMultiTypenameRequest } from "../../src/wfs/request";
import { MAX_CQL_FILTER_BYTES } from "../../src/wfs/spatialCql";

/** Builds a `code_insee IN (...)` filter: under the limit as raw text, but ~1.7x larger once URL-encoded. */
function inFilter(count: number) {
return `code_insee IN (${Array.from({ length: count }, (_, i) => `'${i}'`).join(",")})`;
}

describe("wfs_engine/request", () => {
it("should build a multi-typename request with encoded cql_filter", () => {
Expand Down Expand Up @@ -90,4 +96,39 @@ describe("wfs_engine/request", () => {
expect(request.body).toContain("cql_filter=");
expect(request.body.length).toBeGreaterThan(6000);
});

it("accepts a POST body of exactly MAX_CQL_FILTER_BYTES and rejects one byte more", () => {
const filterAtLimit = "a".repeat(MAX_CQL_FILTER_BYTES - "cql_filter=".length);
const request = buildMultiTypenameRequest({
typenames: ["ADMINEXPRESS-COG.LATEST:commune"],
cqlFilter: filterAtLimit,
});
expect(request.body.length).toBe(MAX_CQL_FILTER_BYTES);

expect(() => buildMultiTypenameRequest({
typenames: ["ADMINEXPRESS-COG.LATEST:commune"],
cqlFilter: `${filterAtLimit}a`,
})).toThrow(/Le filtre CQL est trop volumineux/);
});

it("rejects cqlFilter when its URL-encoded body exceeds MAX_CQL_FILTER_BYTES", () => {
const hugeFilter = inFilter(200_000);
expect(Buffer.byteLength(hugeFilter)).toBeLessThan(MAX_CQL_FILTER_BYTES);
expect(() => buildMultiTypenameRequest({
typenames: ["ADMINEXPRESS-COG.LATEST:commune"],
cqlFilter: hugeFilter,
})).toThrow(/Le filtre CQL est trop volumineux/);
});

it("rejects multi-typename cqlFilters when expanded filter exceeds MAX_CQL_FILTER_BYTES", () => {
const largeFilter = inFilter(100_000);
expect(() => buildMultiTypenameRequest({
typenames: ["ADMINEXPRESS-COG.LATEST:commune"],
cqlFilter: largeFilter,
})).not.toThrow();
expect(() => buildMultiTypenameRequest({
typenames: ["ADMINEXPRESS-COG.LATEST:commune", "ADMINEXPRESS-COG.LATEST:departement"],
cqlFilters: [largeFilter, largeFilter],
})).toThrow(/Le filtre CQL est trop volumineux/);
});
});
Loading
Loading