Converting a WFS Endpoint to OGC API Features
Migrating from a Web Feature Service to OGC API - Features and the REST Transition is mostly a translation exercise: the same feature data is exposed through resource-oriented HTTP paths and lower-case query parameters instead of a single GetFeature endpoint driven by capitalised OGC keywords. This guide gives you a concrete parameter-to-path mapping and a Python adapter that lets existing WFS-style callers keep working while the traffic underneath flows to the new REST interface.
TL;DR: WFS typeName becomes a collection id, GetFeature becomes GET /collections/{id}/items, BBOX becomes bbox, count/startIndex become limit plus rel=next paging links, outputFormat=application/json becomes the native GeoJSON default, and SRSNAME becomes a crs URI. Wrap that mapping in a small adapter class so legacy callers issue WFS-shaped calls while the server answers with plain GeoJSON.
The Core Challenge
A Web Feature Service concentrates every read into one operation. A GetFeature request is a query string of capitalised OGC parameters — SERVICE, REQUEST, TYPENAME, BBOX, COUNT, STARTINDEX, SRSNAME, OUTPUTFORMAT — sent to a single service URL. The feature editing and geometry CRUD side of the same protocol is covered in the WFS Transactional Operations Deep Dive; here we care only about the read path.
OGC API - Features breaks that single operation into a small tree of REST resources. Each feature type is a collection addressed by a stable id, features live under /collections/{id}/items, and query refinements are lower-case parameters (bbox, limit, datetime, crs). Paging is expressed through hyperlinks in the response rather than a client-computed offset. Nothing about the underlying data changes — the same PostGIS table or shapefile is being served — but the request contract, the axis-order conventions, and the pagination model all shift. The adapter’s job is to absorb those differences so upstream code does not have to.
The full parameter mapping the adapter implements:
| WFS 2.0 (GetFeature) | OGC API - Features | Translation notes |
|---|---|---|
TYPENAME=ns:roads |
collection id roads in the path |
Strip the namespace prefix; the collection id is a plain slug, discoverable from GET /collections. |
REQUEST=GetFeature |
GET /collections/{id}/items |
The operation is encoded in the path, not a REQUEST parameter. |
BBOX=minx,miny,maxx,maxy |
bbox=minx,miny,maxx,maxy |
Same four-number order in the default CRS84 (lon,lat). Add bbox-crs when the box is in another CRS. |
COUNT=n |
limit=n |
A page-size hint; the server may cap it below n. |
STARTINDEX=k |
offset=k or rel=next link |
offset is a common extension, not core. Prefer following the next hyperlink. |
OUTPUTFORMAT=application/json |
native GeoJSON default | GeoJSON is the default items representation; omit the parameter. Use f=json only to force it. |
SRSNAME=urn:ogc:def:crs:EPSG::4326 |
crs=http://www.opengis.net/def/crs/EPSG/0/4326 |
The CRS identifier becomes an HTTP URI. The default is CRS84 (lon,lat). |
PROPERTYNAME=a,b |
properties=a,b |
Attribute subsetting; support varies by server. |
RESOURCEID=roads.5 |
GET /collections/{id}/items/{featureId} |
Single-feature access is its own path. |
Production-Ready Code
The adapter below accepts WFS-style keyword arguments and translates them into OGC API - Features requests using a pooled requests.Session. It returns a GeoJSON FeatureCollection dictionary and transparently follows rel=next paging links so callers receive the full result set exactly as a GetFeature response would have delivered it.
"""
wfs_to_ogcapi.py — a shim that speaks WFS GetFeature and calls OGC API - Features.
Dependencies (pip install):
requests>=2.31
"""
from __future__ import annotations
from typing import Any, Iterator
from urllib.parse import urljoin
import requests
def epsg_to_crs_uri(srs_name: str) -> str:
"""
Normalise a WFS SRSNAME into an OGC API - Features crs URI.
Accepts 'EPSG:4326', 'urn:ogc:def:crs:EPSG::4326', or a bare code
and returns 'http://www.opengis.net/def/crs/EPSG/0/4326'.
"""
code = srs_name.strip().rsplit(":", 1)[-1] or srs_name
if not code.isdigit():
raise ValueError(f"Unrecognised SRSNAME: {srs_name!r}")
return f"http://www.opengis.net/def/crs/EPSG/0/{code}"
# CRS84 (lon, lat) is the default; only pin an EPSG URI when the caller asks.
CRS84 = "http://www.opengis.net/def/crs/OGC/1.3/CRS84"
class WfsToOgcApiAdapter:
"""Translate WFS-style read calls into OGC API - Features requests."""
def __init__(self, base_url: str, timeout: int = 30) -> None:
# base_url is the API landing page, e.g. https://host/ogcapi/
self.base_url = base_url if base_url.endswith("/") else base_url + "/"
self.timeout = timeout
self.session = requests.Session()
self.session.headers.update({"Accept": "application/geo+json"})
def _collection_id(self, type_name: str) -> str:
"""Drop any WFS namespace prefix: 'topp:roads' -> 'roads'."""
return type_name.split(":")[-1]
def _build_params(
self,
bbox: tuple[float, float, float, float] | None,
count: int | None,
srs_name: str | None,
) -> dict[str, Any]:
"""Map WFS query parameters onto OGC API - Features query parameters."""
params: dict[str, Any] = {}
if bbox is not None:
params["bbox"] = ",".join(str(v) for v in bbox)
if count is not None:
params["limit"] = count # WFS COUNT -> limit
if srs_name is not None:
crs_uri = epsg_to_crs_uri(srs_name)
params["crs"] = crs_uri # WFS SRSNAME -> crs
params["bbox-crs"] = crs_uri # keep the bbox in the same CRS
return params
def _pages(self, url: str, params: dict[str, Any]) -> Iterator[dict[str, Any]]:
"""
Yield each FeatureCollection page, following rel=next hyperlinks.
This replaces client-side STARTINDEX arithmetic: the server hands us
the next URL, so paging stays correct even when limits are capped.
"""
next_url: str | None = url
first = True
while next_url:
resp = self.session.get(
next_url,
params=params if first else None, # next links are fully-formed
timeout=self.timeout,
)
resp.raise_for_status()
page = resp.json()
yield page
first = False
next_url = None
for link in page.get("links", []):
if link.get("rel") == "next":
next_url = link["href"]
break
def get_feature(
self,
type_name: str,
*,
bbox: tuple[float, float, float, float] | None = None,
count: int | None = None,
start_index: int = 0,
srs_name: str | None = None,
max_features: int | None = None,
) -> dict[str, Any]:
"""
WFS GetFeature, answered by OGC API - Features.
Returns a merged GeoJSON FeatureCollection. `start_index` skips that
many features from the accumulated stream; `max_features` caps the
total returned, mirroring COUNT semantics across page boundaries.
"""
collection = self._collection_id(type_name)
items_url = urljoin(self.base_url, f"collections/{collection}/items")
params = self._build_params(bbox, count, srs_name)
features: list[dict[str, Any]] = []
for page in self._pages(items_url, params):
features.extend(page.get("features", []))
if max_features is not None and len(features) >= start_index + max_features:
break
window = features[start_index:]
if max_features is not None:
window = window[:max_features]
return {
"type": "FeatureCollection",
"features": window,
"numberReturned": len(window),
}
if __name__ == "__main__":
adapter = WfsToOgcApiAdapter("https://demo.pygeoapi.io/master/")
fc = adapter.get_feature(
"topp:lakes",
bbox=(-180, -90, 180, 90),
count=50,
max_features=120,
)
print(fc["type"], fc["numberReturned"])
Step-by-Step Walkthrough
CRS identifier normalisation (epsg_to_crs_uri). WFS clients pass SRSNAME as either a short EPSG:4326 token or a URN like urn:ogc:def:crs:EPSG::4326. OGC API - Features expects the CRS as an HTTP URI. The helper splits on the final colon to recover the numeric authority code, validates that it is digits, and rebuilds it as http://www.opengis.net/def/crs/EPSG/0/4326. Anything non-numeric raises immediately so a malformed SRSNAME fails loudly rather than silently reverting to the default.
Collection id resolution (_collection_id). A WFS TYPENAME frequently carries a namespace prefix such as topp:roads. Collection ids in the REST tree are plain slugs, so the method keeps only the part after the last colon. If your server uses different collection ids than the old feature-type names, replace this method with an explicit lookup table populated from GET /collections.
Parameter mapping (_build_params). This is the heart of the shim. bbox is serialised as the same comma-joined four-number string WFS used. count becomes limit. When a srs_name is supplied, both crs and bbox-crs are set to the same URI so the requested bounding box and the returned geometries share one coordinate system — a mismatch here is a classic source of empty result sets.
Link-driven paging (_pages). Rather than translating STARTINDEX into an offset the server may not support, the generator follows the rel=next hyperlink embedded in each FeatureCollection’s links array. Only the first request carries the query parameters; subsequent next URLs are already fully formed, so passing params=None avoids duplicating or corrupting them. Paging ends naturally when a page has no next link.
Windowing (get_feature). The public method accumulates features across pages, then applies start_index and max_features in memory to reproduce WFS STARTINDEX and COUNT semantics precisely, even when the requested window straddles page boundaries. The result is a standard GeoJSON FeatureCollection — no GML parsing, no namespace juggling. Because GeoJSON is the native default, outputFormat=application/json has no analogue in the outgoing request; the Accept: application/geo+json header set on the session is sufficient.
Verification
Run a quick parity probe: request the same extent from both interfaces and compare feature counts. The snippet below assumes the legacy WFS and the new API front the same data source.
import requests
from wfs_to_ogcapi import WfsToOgcApiAdapter
adapter = WfsToOgcApiAdapter("https://demo.pygeoapi.io/master/")
new = adapter.get_feature("lakes", bbox=(-180, -90, 180, 90), count=500)
legacy = requests.get("https://example.org/wfs", params={
"SERVICE": "WFS", "VERSION": "2.0.0", "REQUEST": "GetFeature",
"TYPENAME": "lakes", "OUTPUTFORMAT": "application/json",
"BBOX": "-90,-180,90,180", "COUNT": 500,
}, timeout=30).json()
print("ogcapi:", new["numberReturned"], "wfs:", len(legacy["features"]))
Expected output when the two endpoints are in parity:
ogcapi: 500 wfs: 500
Matching counts confirm the mapping is faithful for that extent. Extend the check to geometry equality — round coordinates to a fixed precision and compare — before you route production traffic through the adapter.
Gotchas & Edge Cases
Can I run WFS and OGC API - Features side by side during migration?
Yes, and you should. GeoServer, MapServer, and pygeoapi can all expose the same backing data through both a legacy WFS endpoint and an OGC API - Features endpoint concurrently. Run them in parallel, point new clients at the REST interface, keep the adapter shim in front of old clients, and only decommission WFS once a feature-parity check confirms identical counts and geometries for a representative set of collections and extents. Choosing which protocol a given client should use long-term is covered in the WMS vs WFS vs WMTS vs OGC API Features decision guide.
Why do coordinates come back swapped after switching to OGC API - Features?
OGC API - Features defaults to CRS84 — longitude, latitude — for the default GeoJSON output, whereas WFS 2.0 with SRSNAME=urn:ogc:def:crs:EPSG::4326 delivers latitude-first axis order. If you request an explicit crs of http://www.opengis.net/def/crs/EPSG/0/4326 you inherit that CRS’s latitude-first axis order again. Keep the default CRS84 for GeoJSON pipelines, or swap axes deliberately when you pin an EPSG URI. This is the same axis-order trap that affects other services, explored more broadly in the OGC API Features and the REST Transition overview.
How do WFS count and startIndex map to OGC API - Features paging?
count becomes limit and startIndex becomes offset, but offset is not guaranteed by the core specification. The interoperable way to page is to follow the rel=next hyperlink returned in the links array of each FeatureCollection response until no next link remains — exactly what the adapter’s _pages generator does. Treat limit as a page-size hint, not a hard slice, because servers cap it at their own maximum (often 10,000).
What replaces WFS outputFormat=application/json?
Nothing needs to replace it. GeoJSON is the native default response media type for OGC API - Features items, so you drop outputFormat entirely and rely on the application/geo+json representation. Use HTTP content negotiation with an Accept header, or the f=json query parameter, only when a server offers multiple encodings and you need to force one. Watch geometry precision, too: some servers truncate GeoJSON coordinates to fewer decimal places than the GML they emitted, so verify precision during your parity checks.
Back to OGC API Features and the REST Transition
Related
- OGC API Features and the REST Transition — the resource model, conformance classes, and paging conventions behind this migration
- WFS Transactional Operations Deep Dive — the feature-editing side of the legacy protocol you are moving away from
- WMS vs WFS vs WMTS vs OGC API Features: A Decision Guide — choosing the right service interface per client and workload