WFS 2.0 adapter
@honua/sdk-js ships a first-party WFS 2.0 client that conforms to the
shared JS client contract from
docs/shared-client-contract.md. A WFS
source registered through createDataset({ sources }) produces the same
canonical Source<T> / Query<T> / Result<T> / EditEnvelope<T> /
EditResult shapes as the GeoServices and OGC Features adapters, so
mixed-source operator apps do not have to learn WFS / XML specifics.
import { createDataset, PROTOCOL_DEFAULT_CAPABILITIES } from "@honua/sdk-js/contract";
import { HonuaClient } from "@honua/sdk-js";
const client = new HonuaClient({ baseUrl: "https://server.honua.io" });
const dataset = createDataset({
id: "parcels",
client,
sources: [
{
id: "parcels-wfs",
protocol: "wfs",
locator: { url: "https://server.honua.io/wfs", typeName: "parcels:lot" },
capabilities: PROTOCOL_DEFAULT_CAPABILITIES.wfs,
},
],
});
const source = dataset.source("parcels-wfs")!;
// Deprecated WFS-native compatibility text retained for migrations.
const result = await source.query({ where: "STATE = 'CA' AND ACRES > 10" });
Capabilities
The default capability set
(PROTOCOL_DEFAULT_CAPABILITIES.wfs) is query, queryExtent,
queryObjectIds, applyEdits, stream. The capability matrix in
protocol-capability-matrix.md
covers each row's degraded fallbacks (none here — WFS is either
supported or it isn't).
queryAggregate, queryRelated, attachments throw
HonuaCapabilityNotSupportedError because WFS 2.0 does not expose
server-side aggregation, related-records, or feature attachments.
Locator
interface WfsLocator {
url: string; // Fully qualified WFS endpoint (e.g. https://server/wfs)
typeName: string; // Namespace-qualified feature-type name (e.g. parcels:lot)
featureNamespace?: string; // URI bound to the typeName prefix (required for prefixed applyEdits)
}
The endpoint URL must share an origin with the HonuaClient's baseUrl;
cross-origin WFS sources require constructing a separate HonuaClient.
featureNamespace is the namespace URI the server advertises for the
typeName prefix (typically declared as xmlns:<prefix>="…" on the
<wfs:WFS_Capabilities> root). The canonical adapter binds it on the
<wfs:Transaction> root so per-handle feature elements
(<parcels:lot>…</parcels:lot>) and prefixed typeName="…" attribute
references on <wfs:Update> / <wfs:Delete> resolve. When the locator
omits featureNamespace and the type name carries a prefix, the
adapter falls back to a synthetic URN
(urn:honua:wfs:feature-namespace:<prefix>) so the document is
well-formed XML; strict servers will reject the synthetic URI with an
<ows:ExceptionReport> whose locator names the prefix, telling
callers which descriptor field to set. Unprefixed typeName values
do not need featureNamespace.
Capability negotiation
HonuaWfs.capabilities() issues a single GetCapabilities request the
first time query, queryAll, queryObjectIds, stream, or
queryExtent runs (the no-network queryExtent shortcut also reads
the cached snapshot to find the per-feature-type
ows:WGS84BoundingBox). The parsed snapshot is cached per HonuaWfs
instance — subsequent calls reuse it. Use wfs.refresh() to drop the
cache. applyEdits does not pre-fetch capabilities because the
canonical transaction body never needs the output-format negotiation;
servers that do not advertise Transaction surface that as a
server-side OperationProcessingFailed <ows:ExceptionReport> on the
first transaction request, projected onto HonuaWfsExceptionError
(carrying exceptionCode and locator).
The descriptor's capabilities set is the SDK's promise of what the
adapter can fulfil; the constructor does not currently widen or narrow
it from GetCapabilities. Callers that need a downgraded set per
source (for example, dropping applyEdits for a server that publishes
WFS read-only) intersect the default themselves and pass the result
on SourceDescriptor.capabilities.
Content-type negotiation
The adapter treats the server as advertising JSON when
OperationsMetadata.GetFeature lists any of
application/geo+json, application/json,
application/vnd.geo+json, json, or geojson and prefers the
GeoJSON encoding over GML. If only GML is advertised, the canonical
Source.query() throws HonuaCapabilityNotSupportedError("query")
rather than ship raw XML through Result.features. Callers can still
reach the GML payload through
Source.protocol("wfs").getFeature(...) (see "Protocol escape hatch"
below).
GML decoding is intentionally out of scope. A future ticket may add an opt-in GML decoder; for now the canonical surface is GeoJSON-only.
Filter encoding (FES 2.0)
Query.where compiles to FES 2.0 OGC Filter Encoding. The supported
subset is:
- comparison operators:
=,<>,!=,<,<=,>,>= IN (a, b, …)/NOT IN (…)BETWEEN x AND y/NOT BETWEENLIKE 'pattern%'/NOT LIKEIS NULL/IS NOT NULL- boolean combinators
AND,OR,NOT - parenthesization
- string literals (single-quoted,
''escapes), numeric literals, property identifiers (dotted path supported)
Anything richer — function calls, subqueries, vendor extensions — yields
HonuaCapabilityNotSupportedError("query") rather than emitting a
silent partial filter. Callers that need the full FES vocabulary
reach the wire through
Source.protocol("wfs").getFeature({ filter: rawFesXml }).
Query.spatialFilter compiles to FES <fes:BBOX> (envelope geometry
with spatialRel undefined / esriSpatialRelIntersects /
esriSpatialRelEnvelopeIntersects) or the corresponding spatial
predicate (<fes:Intersects>, <fes:Within>, <fes:Contains>,
<fes:Crosses>, <fes:Overlaps>, <fes:Touches>). For envelope
geometry with a non-intersects relation (Contains, Within,
Crosses, Overlaps, Touches), the adapter lowers the envelope to
a GML 3.2 polygon and emits the requested predicate so the server
honors the relation rather than silently widening to bbox semantics.
Geometry serialization is GML 3.2 simple (point / line / polygon);
curves and surfaces throw and require the escape hatch.
Geometry property resolution
The geometry property is named per feature type, not per protocol:
PostGIS via GeoServer serves the_geom, MapServer serves msGeometry,
and app-schema / deegree deployments use whatever the schema declares.
A spatial predicate that references the wrong property matches nothing,
so the adapter resolves the real name instead of assuming one:
locator.geometryName, when set, wins and costs no round-trip.- Otherwise the adapter issues
DescribeFeatureTypeonce per feature type, caches the result alongside the capabilities snapshot, and uses the first geometry property the schema declares. - If neither names it, the request fails closed with
HonuaWfsProtocolError("unresolved-geometry-property")— the adapter never falls back to a vendor default.
Resolution is lazy: it happens only when the request names the property
on the wire (an FES spatial predicate, or <wfs:Insert> / <wfs:Update>
geometry). The bbox= KVP shortcut addresses the default geometry
positionally, so envelope-intersects queries need no schema round-trip.
Feature types that declare several geometries resolve to the first one;
pin a different one with locator.geometryName.
Field projection (outFields and returnGeometry)
WFS propertyName= drops every property the caller does not list,
including the geometry column. The canonical Query contract treats
outFields and returnGeometry as independent controls (geometry is
included unless returnGeometry === false), so the adapter resolves
the two like this:
outFields |
returnGeometry |
wire propertyName= |
|---|---|---|
| unset / empty | unset / true |
omitted — server returns all fields + geometry |
| set | unset / true |
outFields,the_geom (geometry property appended unless already listed) |
| set | false |
outFields only — geometry is intentionally dropped |
| unset / empty | false |
refused — HonuaCapabilityNotSupportedError("query") |
The returnGeometry === false + no-outFields case throws because
WFS cannot suppress geometry without enumerating every non-geometry
property; silently widening to "geometry included" would break the
canonical contract. Callers that need geometry-less rows must list the
non-geometry fields they want, or reach the wire through
Source.protocol("wfs").
queryExtent and queryObjectIds ignore both outFields and
returnGeometry on the drain path so caller intent on those fields
cannot break the drain. queryExtent always issues geometry-bearing
pages (it computes a bbox from each feature's geometry); a
returnGeometry: false paired with queryExtent does not throw,
the field is stripped before the drain. queryObjectIds reads each
feature's GeoJSON id directly, so neither knob affects the result —
both are stripped before the drain so the caller's outFields cannot
push the geometry property onto the wire and returnGeometry: false
cannot trip the propertyName-suppression guard.
GET vs. POST routing
Filters whose encoded length exceeds ~7000 characters are routed
through POST GetFeature with a <wfs:GetFeature> body containing a
<wfs:Query typeNames="…" srsName="…">, optional
<wfs:PropertyName> projections, the same <fes:Filter> tree, and an
optional <fes:SortBy> block. Query.outFields, Query.orderBy, and
Query.outSr survive the GET → POST switch — the only transport
difference is the body encoding. The 7000-character threshold is a
single constant we revise after telemetry lands.
Query.outSr accepts either a string CRS URI / EPSG token (passed
through verbatim) or a numeric WKID. Numeric WKIDs are translated to
the OGC URN form urn:ogc:def:crs:EPSG::<wkid> so the wire shape
matches what OperationsMetadata and Filter_Capabilities advertise.
Pagination
Query.pagination.offset maps to startIndex; Query.pagination.limit
maps to count. queryAll requests limit + 1 rows so the adapter
can stamp Result.exceededTransferLimit: true when more records exist.
stream paginates internally with a 2000-row default page size or
the caller's pagination.limit when supplied.
pagination.limit === 0 is treated as an explicit "zero records" cap
across query, queryAll, stream, and queryObjectIds — the
default page-size fallback only applies when limit is unset or
negative. query / stream / queryObjectIds short-circuit before
the wire call (returning an empty result, no yielded pages, or []
respectively); queryAll still issues a single 1-row lookahead so
exceededTransferLimit can flip when more records exist.
Result.totalCount populates from the GeoJSON numberMatched field;
exceededTransferLimit flips when numberMatched > features.length.
queryObjectIds
WFS 2.0 has no interoperable server-side ids-only mode, so
queryObjectIds drains the matching set across pages and projects the
GeoJSON id from each feature. The default page size is 2000; the
drain stops as soon as the server returns a short page.
Query.pagination.offset chooses where the drain starts (forwarded as
startIndex to GetFeature) and Query.pagination.limit, when set,
caps the global id count — not the per-page count — so callers can
stop the drain without learning the server's default page size. The
adapter shrinks each page to min(2000, remaining) so the final page
never overshoots the cap. A pagination.limit of 0 short-circuits
the drain and returns [] without a wire call.
Query.outFields and Query.returnGeometry are stripped before each
drained page is requested. The drain reads each feature's top-level
GeoJSON id (a sibling of properties and geometry), so neither
knob affects the result; stripping them keeps the wire request from
emitting an unnecessary propertyName= and prevents
returnGeometry: false from tripping the canonical "no outFields"
guard documented in Field projection.
queryExtent
Unfiltered queryExtent() (no where, no spatialFilter, no
outSr) reads the per-feature-type ows:WGS84BoundingBox from
GetCapabilities and returns the cached envelope without any extra
HTTP traffic. Filtered or outSr-bearing requests drain every page of
the matching set (2000 features per page) and compute the bbox
client-side, so the returned extent always covers the full filtered
set rather than just the first server page. Caller pagination
(Query.pagination.offset / .limit), Query.outFields, and
Query.returnGeometry are intentionally ignored on this path —
queryExtent answers "what bbox holds the matching records" rather
than "what bbox holds the first page", and the drain must always see
geometry on the wire to compute a bbox. A caller-supplied outFields
projection would emit propertyName=... and drop geometry from every
drained page; a caller-supplied returnGeometry: false would trip the
field-projection guard. The drain therefore strips all three fields
before issuing each GetFeature page so geometry is preserved
end-to-end and the call cannot be refused on a knob that does not
apply to extent computation. queryExtent returns
{ extent, count? } and does not carry a degraded[] array; the OGC
Features adapter is the only one that flags this fallback today.
Edits (applyEdits)
applyEdits builds a single <wfs:Transaction> POST body with
<wfs:Insert>, <wfs:Update>, and <wfs:Delete> blocks. Geometry
payloads come from CanonicalFeature.geometry (GeoJSON →
GML 3.2). The transaction's releaseAction follows
EditEnvelope.rollbackOnFailure:
rollbackOnFailure |
releaseAction |
|---|---|
true |
ALL |
false / omitted |
SOME |
Per-handle <wfs:InsertResults> <fes:ResourceId rid="…"/> IDs
populate EditOutcome.id. The adapter stamps each <wfs:Insert> with
a stable handle="add-N" (1-based, matching envelope.adds order)
and indexes the returned <wfs:Feature handle="…"> buckets by handle
when mapping ResourceIds back onto EditResult.added, so a server
that reorders the buckets — or omits them under releaseAction="SOME"
when an insert fails — does not misassign IDs to the wrong
envelope.adds[i]. Inserts whose handle is missing from the response
surface as { success: false } rather than silently inheriting the
neighbouring success. The handle attribute is informational in WFS 2.0,
so when no <wfs:Feature> carries one the adapter falls back to the
legacy positional pairing instead of dropping every id.
OperationProcessingFailed and other <ows:ExceptionReport>
responses surface as HonuaWfsExceptionError with .exceptionCode /
.locator preserved.
CanonicalFeature.id is required on every update because each
<wfs:Update> is filtered by <fes:ResourceId>; without an id the
block would mass-update every feature in the type. Updates whose id
is undefined / null are filtered out before the transaction body is
built and surface as deterministic per-item failures
({ success: false, error: { code: 400, description: "update.id is required" } }) on EditResult.updated. Valid updates in the same
envelope still travel as a single transaction. When every operation in
the envelope is absent or malformed the adapter skips the wire
round-trip entirely so the server never sees an unaddressed
transaction.
Stored queries
ListStoredQueries and DescribeStoredQueries are reachable through
the protocol escape hatch:
const wfs = source.protocol("wfs")!; // HonuaWfsFeatureType
const ids = await wfs.root.storedQueries();
const sq = wfs.root.storedQuery("byKey");
const response = await sq.execute({ parameters: { id: 1 } });
A stored query whose output is JSON returns canonical features through
response.kind === "json". Stored queries that advertise only GML
(today: Honua Server's
urn:ogc:def:query:OGC-WFS::GetFeatureById) cannot be projected onto
the canonical envelope — Source.query() does not carry stored-query
intent because that would re-introduce WFS-specific shapes at the top
level. The escape hatch above still returns the raw GML payload.
Protocol escape hatch
Source.protocol("wfs") returns a bound HonuaWfsFeatureType whose
methods carry the raw WFS-shaped payloads:
const wfs = source.protocol("wfs")!;
// Raw XML capabilities payload (cached after the first call).
const snapshot = await wfs.capabilities();
// Custom FES filter (escape unsupported expressions).
await wfs.getFeature({ filter: customFesXml });
// GetPropertyValue (returns raw XML).
await wfs.getPropertyValue({ valueReference: "ACRES" });
// Custom Transaction body.
await wfs.transaction({ body: rawTransactionXml });
HonuaWfs (wfs.root) owns the capabilities cache and exposes
capabilities(), refresh(), rawCapabilities(), storedQueries(),
and storedQuery(id) so callers can drive WFS without going through
the canonical Source surface when they need full FES expressivity.
Locking
WFS LockFeature / GetFeatureWithLock are not implemented. There
is no top-level Source.lock() concept and no typed helper on the
protocol escape hatch either — HonuaWfsFeatureType and HonuaWfs
expose no lockFeature / getFeatureWithLock method, and the adapter
never issues a lock request on the caller's behalf.
The only path to a lock is the raw-XML escape hatch: build the request
document yourself and POST it through HonuaWfs.requestText, then parse
the response text yourself. The SDK does not extract lockId, does not
track lock expiry, and does not attach a lock to a subsequent
applyEdits() transaction.
const wfs = source.protocol("wfs")!;
// `operationUrl()` reads the already-resolved capabilities snapshot
// synchronously and falls back to the configured endpoint URL when none has
// been fetched yet. Resolve capabilities first, or the request goes to the
// endpoint URL instead of the advertised LockFeature DCP address.
await wfs.root.capabilities();
const { text } = await wfs.root.requestText("POST", wfs.root.operationUrl("LockFeature", "POST"), {
accept: "application/xml",
contentType: "text/xml",
body: rawLockFeatureXml,
});
// `text` is the raw `<wfs:LockFeatureResponse>`; parse `lockId` yourself.
Everything requestText shares with the canonical surface still
applies: auth headers, interceptors, retry, timeout, abort signals, and
the response-size cap.
Exception mapping is the one thing that does not fully carry over.
requestText converts an <ows:ExceptionReport> into
HonuaWfsExceptionError only when the server returns it with a non-2xx
status (the conversion runs on the thrown HonuaHttpError). A server
that reports a lock failure as an <ows:ExceptionReport> inside an HTTP
200 — permitted by OWS, and how some servers signal
CannotLockAllFeatures — comes back as ordinary response text. Inspect
the payload for ExceptionReport yourself before treating it as a
<wfs:LockFeatureResponse>.
Defenses
- The capabilities XML walker refuses any document declaring
<!DOCTYPE>or<!ENTITY>. This stops XXE-class attacks before any property is read. The same walker is reused for<ows:ExceptionReport>and<wfs:TransactionResponse>parsing, so every WFS XML payload entering the canonical surface is hardened. Metadata responses are capped at 2 MiB before decoding, and the walker separately caps element count, nesting depth, attributes, attribute bytes, and decoded text. GeoJSON feature responses are capped at 32 MiB. - The WFS adapter never reaches
fetchdirectly; all wire calls go throughHonuaClient.requestText, so the existing interceptor / retry / timeout / abort signal pipeline applies. - Canonical execution binds exact WFS 2.0 version, feature QName and
namespace, advertised GET/POST DCP authority, GeoJSON output format,
and advertised filter/response CRSes before
GetFeature. Qualified GET requests carryNAMESPACES; POST queries declare the QName prefix. Filter geometry CRS and responseoutSrstay independent, including WFS/GML authority-axis ordering for geographic CRS identifiers. - Capability caching stores settled snapshots only, so cancelling one
concurrent caller cannot reject another caller's request. Paged
queryAll()andstream()advance by rows actually returned, continue across server-clamped pages whennumberMatchedproves more data exists, and fail closed if the server stops making progress, returns more rows than the requested count, or would advance beyond JavaScript's safe-integer paging range. - WFS responses with content-type
application/xmlcontaining<ows:ExceptionReport>are turned into typedHonuaWfsExceptionErrorinstances (exceptionCode,locator, and the message as the parser saw it) so the canonical surface surfaces structured WFS errors instead of opaque HTTP failures.HonuaWfsExceptionErroris also raised when an<ows:ExceptionReport>arrives wrapped inside aHonuaHttpErrorbody (the body is sniffed forExceptionReportand re-thrown as the typed error before the failure leaves the adapter). Malformed GeoJSON and inconsistent paging metadata surface asHonuaWfsProtocolErrorrather than empty successful results.
Server compatibility
The adapter targets WFS 2.0 servers. WFS 1.x is intentionally not supported — Honua Server publishes WFS 2.0 only. Non-Honua servers that advertise WFS 2.0 with at least GeoJSON output should work out of the box; servers that only emit GML force the canonical surface to throw and require the escape hatch.