Generating an OpenAPI Document for an OGC API - Features Service

OGC API - Features requires a service to publish an OpenAPI 3.0 description, linked from the landing page with rel="service-desc". Generate it from the same collection metadata and queryables the service already reads, so the document cannot drift from the implementation — a hand-maintained YAML file is wrong the first time a property is added.

The Core Challenge: The Document Is a Second Implementation

The core specification defines six resources, each with a fixed path template and a named response schema. Writing them out once in YAML takes an afternoon. Keeping that YAML correct as collections are added, properties change and extensions are enabled is the actual cost, and it is the cost that gets skipped.

Six paths the core specification requires A six-row grid of the resources the OGC API - Features core requires, each with its path template and the response schema the specification names. The landing page, conformance declaration, collections list, single collection, items page and single feature are all mandatory, and a generated OpenAPI document that omits any of them fails validation against the standard. Required resource OpenAPI path Response schema Landing page / landingPage.yaml Conformance /conformance confClasses.yaml Collections /collections collections.yaml One collection /collections/{collectionId} collection.yaml Items /collections/{collectionId}/items featureCollectionGeoJSON.yaml One feature .../items/{featureId} featureGeoJSON.yaml

The consequence is specific rather than vague: consumers generate clients from the document. A property missing from the schema is a field their generated model does not have; a collection missing from the enumeration is one their tooling cannot reach. Because the service itself keeps working, the divergence is invisible from the server side and shows up as a support request about a client library. Generating the document from the catalog — the same source the collections endpoint reads — removes the second implementation entirely.

Production-Ready Code

from __future__ import annotations

import json
from dataclasses import dataclass, field
from typing import Any

OGC_SCHEMA = "https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/schemas"
CONF_CORE = "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core"
CONF_GEOJSON = "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/geojson"
CONF_FILTER = "http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/filter"

@dataclass
class Collection:
    collection_id: str
    title: str
    description: str
    # JSON Schema types keyed by property name — the same queryables the
    # service publishes, so the document cannot describe a different shape.
    properties: dict[str, str] = field(default_factory=dict)
    bbox: tuple[float, float, float, float] = (-180.0, -90.0, 180.0, 90.0)

def _param(name: str, location: str, description: str, schema: dict[str, Any],
           required: bool = False) -> dict[str, Any]:
    return {"name": name, "in": location, "description": description,
            "required": required, "style": "form", "explode": False,
            "schema": schema}

def _common_parameters() -> dict[str, Any]:
    return {
        "limit": _param("limit", "query", "Maximum number of features to return.",
                        {"type": "integer", "minimum": 1, "maximum": 10000,
                         "default": 10}),
        "bbox": _param("bbox", "query",
                       "Only features intersecting this bounding box.",
                       {"type": "array", "minItems": 4, "maxItems": 6,
                        "items": {"type": "number"}}),
        "datetime": _param("datetime", "query",
                           "An instant or interval in RFC 3339 form.",
                           {"type": "string"}),
        "collectionId": _param("collectionId", "path", "Collection identifier.",
                               {"type": "string"}, required=True),
        "featureId": _param("featureId", "path", "Feature identifier.",
                            {"type": "string"}, required=True),
    }

def _feature_schema(collection: Collection) -> dict[str, Any]:
    """A GeoJSON Feature whose properties object is this collection's shape."""
    return {
        "type": "object",
        "required": ["type", "geometry", "properties"],
        "properties": {
            "type": {"type": "string", "enum": ["Feature"]},
            "id": {"oneOf": [{"type": "string"}, {"type": "integer"}]},
            "geometry": {"$ref": f"{OGC_SCHEMA}/geometryGeoJSON.yaml"},
            "properties": {
                "type": "object",
                "properties": {name: {"type": kind}
                               for name, kind in collection.properties.items()},
            },
        },
    }

def _ok(description: str, schema: dict[str, Any]) -> dict[str, Any]:
    return {"description": description,
            "content": {"application/geo+json": {"schema": schema}}}

def build_openapi(title: str, version: str, base_url: str,
                  collections: list[Collection],
                  conformance: list[str] | None = None) -> dict[str, Any]:
    conformance = conformance or [CONF_CORE, CONF_GEOJSON]
    params = _common_parameters()
    ref = lambda name: {"$ref": f"#/components/parameters/{name}"}

    paths: dict[str, Any] = {
        "/": {"get": {
            "summary": "Landing page", "operationId": "getLandingPage",
            "responses": {"200": {"description": "Links to the API description, "
                                                 "conformance declaration and collections."}}}},
        "/conformance": {"get": {
            "summary": "Conformance declaration", "operationId": "getConformance",
            "responses": {"200": {"description": "The conformance classes this "
                                                 "service implements."}}}},
        "/collections": {"get": {
            "summary": "Collections", "operationId": "getCollections",
            "responses": {"200": {"description": "The collections this service offers."}}}},
        "/collections/{collectionId}": {"get": {
            "summary": "Collection metadata", "operationId": "describeCollection",
            "parameters": [ref("collectionId")],
            "responses": {"200": {"description": "Metadata for one collection."},
                          "404": {"description": "No such collection."}}}},
    }

    # Per-collection item paths carry that collection's own feature schema,
    # which is what makes generated clients typed rather than generic.
    for collection in collections:
        item_params = [ref("limit"), ref("bbox"), ref("datetime")]
        if CONF_FILTER in conformance:
            item_params.append(_param(
                "filter", "query", "A CQL2 filter expression.", {"type": "string"}))
            item_params.append(_param(
                "filter-lang", "query", "The filter language.",
                {"type": "string", "enum": ["cql2-text", "cql2-json"],
                 "default": "cql2-text"}))

        paths[f"/collections/{collection.collection_id}/items"] = {"get": {
            "summary": f"Features in {collection.title}",
            "operationId": f"get{collection.collection_id.title()}Items",
            "parameters": item_params,
            "responses": {"200": _ok(
                f"A page of features from {collection.title}.",
                {"type": "object",
                 "required": ["type", "features"],
                 "properties": {
                     "type": {"type": "string", "enum": ["FeatureCollection"]},
                     "features": {"type": "array",
                                  "items": _feature_schema(collection)},
                     "numberReturned": {"type": "integer"},
                     "links": {"type": "array",
                               "items": {"$ref": f"{OGC_SCHEMA}/link.yaml"}}}})}}}

        paths[f"/collections/{collection.collection_id}/items/{{featureId}}"] = {"get": {
            "summary": f"One feature from {collection.title}",
            "operationId": f"get{collection.collection_id.title()}Feature",
            "parameters": [ref("featureId")],
            "responses": {"200": _ok("One feature.", _feature_schema(collection)),
                          "404": {"description": "No such feature."}}}}

    return {
        "openapi": "3.0.3",
        "info": {"title": title, "version": version,
                 "description": "OGC API - Features service description, "
                                "generated from the service catalog."},
        "servers": [{"url": base_url.rstrip("/")}],
        "paths": paths,
        "components": {"parameters": params},
    }

if __name__ == "__main__":
    doc = build_openapi(
        "Cadastre Features API", "1.2.0", "https://example.org/ogcapi",
        [Collection("parcels", "Cadastral parcels", "Parcel boundaries",
                    {"parcel_id": "string", "area_m2": "number",
                     "owner_type": "string"})],
        conformance=[CONF_CORE, CONF_GEOJSON, CONF_FILTER])
    print(json.dumps(doc, indent=2)[:600])

Step-by-Step Walkthrough

Per-collection item paths, not one templated path. The specification’s own example uses /collections/{collectionId}/items with a path parameter, which is correct and produces a generic client where every feature has an untyped properties bag. Emitting one path per collection, each carrying that collection’s property schema, produces generated clients with real field names — which is the entire practical benefit of publishing an API description.

Generate the document from the catalog, never by hand A five-stage generation pipeline. Collection metadata comes from the same catalog the service itself reads; per-collection queryables provide a JSON Schema for the filterable properties; the six core paths plus any filter extension paths are generated from templates; response schemas are attached, combining the standard GeoJSON shapes with each collection's own property schema; and the result is served at the API path and linked from the landing page with a service-desc relation. Collection metadata from your catalog Queryables per collection schema Generate paths six core + filters Attach schemas GeoJSON + your properties Serve at /api link rel=service-desc source of truth JSON Schema templated typed

operationId must be unique and stable. Code generators use it as the method name, so changing it renames a method in every downstream client. Deriving it deterministically from the collection identifier keeps it stable across regenerations; deriving it from an index does not.

Reference the OGC schemas rather than copying them. Link, geometry and exception shapes are published as YAML schemas alongside the specification, and $ref-ing them keeps the document small and correct. The one shape worth inlining is the feature, because that is the only one your collections actually customise.

Extensions must appear in both places. The filter parameters above are only emitted when the filter conformance class is enabled. A document that describes a parameter the service ignores is worse than one that omits it, because it tells a client the filtering works — the same trap the CQL2 guide covers from the client side.

Verification

Validate the generated document, then check the required links resolve:

from openapi_spec_validator import validate_spec

doc = build_openapi(...)
validate_spec(doc)                       # raises on a structural error

required = ["/", "/conformance", "/collections", "/collections/{collectionId}"]
missing = [p for p in required if p not in doc["paths"]]
assert not missing, f"core paths missing: {missing}"

for path, item in doc["paths"].items():
    assert "operationId" in item["get"], f"{path} has no operationId"

Then confirm the landing page advertises it, which is the part clients actually use for discovery:

{"links": [
  {"rel": "service-desc", "type": "application/vnd.oai.openapi+json;version=3.0",
   "href": "https://example.org/ogcapi/api"},
  {"rel": "conformance", "href": "https://example.org/ogcapi/conformance"},
  {"rel": "data", "href": "https://example.org/ogcapi/collections"}
]}

Gotchas & Edge Cases

A large catalog produces a large document. One path pair per collection means a service with four hundred collections publishes a multi-megabyte description that some code generators refuse. The escape hatch is the templated {collectionId} form for the long tail plus explicit paths for the collections consumers actually generate against — a pragmatic split rather than a principled one.

A hand-maintained API document is wrong within a week A decision diamond for a client whose generated code disagrees with the service. A hand-edited OpenAPI document drifts from the catalog immediately; stale queryables mean the documented properties no longer match the data; an undocumented extension means a working path is invisible to code generation; and only when all three are handled is the divergence a client-side problem. A client's generated code does not match the service. Where did they diverge? Generate it the catalog is the source of truth Regenerate on schema change properties drifted Add the conformance class the path exists but is undocumented A client bug the contract is honest hand-edited document stale queryables undeclared extension in sync

The media type in the service-desc link matters. application/vnd.oai.openapi+json;version=3.0 is what clients match on to distinguish the OpenAPI document from an HTML rendering of it. Serving the right bytes under the wrong media type makes the document undiscoverable to conformant tooling.

Regenerate on schema change, not on deploy. If collection properties can change without a deployment — a new column appearing in a view, for instance — the document must be generated at request time or invalidated when the catalog changes. Caching it forever recreates the drift the generation was meant to eliminate.

Do not document what you have not implemented. It is tempting to emit the full parameter set the specification allows. A parameter that appears in the document and is ignored by the service is a contract the service breaks silently, which is materially worse than a missing feature.

Frequently Asked Questions

Is publishing an OpenAPI document actually required?

The core specification requires an API definition linked from the landing page with rel=service-desc, and OpenAPI 3.0 is the format every implementation uses. A service that omits it is non-conformant and, more practically, invisible to the tooling ecosystem that makes OGC API - Features worth adopting over WFS.

Should I emit one path per collection or use a path parameter?

One path per collection when the catalog is small enough, because that is what gives consumers typed models with real field names. Fall back to the templated form for a large catalog, or split — explicit paths for the collections people build against and a template for the rest.

Where do the property types come from?

The same queryables the service publishes per collection, which in turn come from the data store’s schema. Deriving both from one source is the whole point: a property that appears in the data, in the queryables and in the OpenAPI schema is a property a client can rely on, and one that appears in only some of them is a support ticket.

Can I hand-edit the generated document?

You can, and it will be wrong by the next schema change. If something is missing from the generated output, add it to the generator so the next regeneration keeps it. The moment the published document and the generator disagree, the document has stopped being a description of the service and become a second, unmaintained implementation of it.


Back to OGC API - Features and the REST Transition

Related