Generating DCAT-AP Compliant JSON-LD for WMS Layers

Map the OGC service endpoint to a dcat:DataService, each published layer to a dcat:Dataset, and GetMap/GetFeatureInfo operations to dcat:Distribution resources. The most reliable implementation uses Python’s rdflib to construct an RDF graph, bind DCAT and DCT namespaces explicitly, and serialise to JSON-LD with a strict @context that matches the official DCAT-AP v3 prefix registry. This structural alignment guarantees interoperability with European data portals, national spatial data infrastructures, and automated harvesters built on the DCAT-AP for Spatial Data Portals specification.

The Core Challenge: WMS Capabilities XML Carries No DCAT Semantics

A WMS GetCapabilities response — the service contract described in detail under Understanding OGC Web Map Service Specifications — is structured around OGC’s own XML vocabulary, not RDF. The <Layer> element has no native equivalent in the DCAT ontology; bounding boxes appear as either XML attributes (WMS 1.1.1) or child text elements (WMS 1.3.0); and coordinate reference system declarations use OGC-specific codes that DCAT harvesters do not recognise without mapping.

The transformation problem therefore has three distinct parts: extracting the right fields from the WMS XML, expressing each field as the correct DCAT-AP v3 predicate on the correct node type, and serialising the result as JSON-LD with an @context that catalog harvesters will accept without normalisation.

WMS to DCAT-AP Node Mapping A flow diagram showing WMS GetCapabilities XML on the left, an arrow in the centre labelled Python rdflib transform, and three stacked DCAT-AP nodes on the right: dcat:DataService (service endpoint), dcat:Dataset (one per layer), and dcat:Distribution (GetMap and GetFeatureInfo operations). WMS GetCapabilities XML <Service><Title> <OnlineResource href> <Layer><Name> <Layer><Title> <EX_GeographicBoundingBox> <KeywordList><Keyword> <Layer><Name> (op) Python rdflib dcat:DataService dcat:endpointURL · dct:title dcat:Dataset dct:title · dcat:spatial · dcat:theme dcat:Distribution dcat:accessURL · dcat:mediaType JSON-LD output @graph

The mapping between WMS Capabilities elements and DCAT-AP v3 predicates is not one-to-one. Several fields require transformation before they are valid RDF literals, particularly bounding boxes and keyword-to-vocabulary mappings:

WMS Capabilities element DCAT-AP v3 property Notes
<Service><Title> / <Abstract> dct:title / dct:description Always tag with @language (e.g. "en")
<OnlineResource xlink:href> (service) dcat:endpointURL Attaches to the dcat:DataService node
<Layer><Name> dct:identifier Use as the URI fragment for dataset and distribution nodes
<Layer><Title> / <Abstract> dct:title / dct:description Attach to dcat:Dataset, language-tagged
<EX_GeographicBoundingBox> dcat:spatialdcat:Locationdcat:bbox Space-delimited string: "west south east north"
<KeywordList><Keyword> dcat:theme Must resolve to a URI in a controlled vocabulary
GetMap operation URL dcat:accessURL on dcat:Distribution One distribution per format/operation

Production-Ready Implementation

The following script builds on the GetCapabilities parsing pattern covered in How to Parse OGC WMS GetCapabilities XML in Python. It accepts a pre-parsed layer dictionary (matching the output of that parser) and produces a DCAT-AP v3 JSON-LD document for one layer. Install rdflib>=6.2.0 before running.

"""
wms_to_dcat_ap.py — Generate DCAT-AP v3 JSON-LD for a single WMS layer.

Requirements:
    pip install rdflib>=6.2.0

Usage:
    python wms_to_dcat_ap.py
"""

import json
from rdflib import Graph, Literal, Namespace, URIRef, BNode
from rdflib.namespace import DCAT, DCTERMS, RDF, XSD

# DCAT-AP v3 canonical @context — replace rdflib's auto-generated one
DCAT_AP_V3_CONTEXT: dict = {
    "dcat": "http://www.w3.org/ns/dcat#",
    "dct":  "http://purl.org/dc/terms/",
    "xsd":  "http://www.w3.org/2001/XMLSchema#",
    "locn": "http://www.w3.org/ns/locn#",
    "geo":  "http://www.opengis.net/ont/geosparql#",
}

# WMS 1.3.0 conformance URI used in dct:conformsTo on every Distribution
WMS_130_SPEC_URI = "http://www.opengis.net/spec/wms/1.3.0"

def generate_wms_layer_jsonld(layer: dict, service_url: str) -> str:
    """
    Build a DCAT-AP v3 JSON-LD document for one WMS layer.

    Args:
        layer: dict with keys 'id', 'title', 'abstract', and
               'bbox' containing {'west', 'south', 'east', 'north'}
               as float values in EPSG:4326 (WGS 84).
        service_url: Base URL of the WMS endpoint (no query string).

    Returns:
        Indented JSON-LD string ready for publication.
    """
    g = Graph()
    g.bind("dcat", DCAT)
    g.bind("dct", DCTERMS)
    g.bind("xsd", XSD)

    # ── 1. dcat:DataService (the WMS endpoint itself) ───────────────────────
    svc_uri = URIRef(service_url)
    dataset_uri = URIRef(f"{service_url}#dataset/{layer['id']}")

    g.add((svc_uri, RDF.type, DCAT.DataService))
    g.add((svc_uri, DCTERMS.title, Literal("OGC Web Map Service", lang="en")))
    g.add((svc_uri, DCAT.endpointURL, svc_uri))
    # dcat:servesDataset links the service to each layer's Dataset node
    g.add((svc_uri, DCAT.servesDataset, dataset_uri))

    # ── 2. dcat:Dataset (one per WMS layer) ────────────────────────────────
    g.add((dataset_uri, RDF.type, DCAT.Dataset))
    g.add((dataset_uri, DCTERMS.title,
           Literal(layer["title"], lang="en")))
    g.add((dataset_uri, DCTERMS.description,
           Literal(layer.get("abstract", ""), lang="en")))
    g.add((dataset_uri, DCTERMS.identifier, Literal(layer["id"])))

    # ── 3. Spatial extent — dcat:Location with dcat:bbox ───────────────────
    # DCAT-AP v3 requires a blank node of type dcat:Location attached via
    # dcat:spatial. The bounding box string must follow WGS 84 west-south-
    # east-north order and be typed as xsd:string.
    bbox = layer["bbox"]
    bbox_str = (
        f"{bbox['west']} {bbox['south']} {bbox['east']} {bbox['north']}"
    )
    location_node = BNode()
    g.add((dataset_uri, DCAT.spatial, location_node))
    g.add((location_node, RDF.type, DCAT.Location))
    g.add((location_node, DCAT.bbox,
           Literal(bbox_str, datatype=XSD.string)))

    # ── 4. dcat:Distribution — GetMap operation ────────────────────────────
    dist_uri = URIRef(f"{service_url}#distribution/{layer['id']}/getmap")
    g.add((dataset_uri, DCAT.distribution, dist_uri))
    g.add((dist_uri, RDF.type, DCAT.Distribution))
    g.add((dist_uri, DCTERMS.title,
           Literal(f"{layer['title']} — WMS GetMap (PNG)", lang="en")))
    g.add((dist_uri, DCAT.accessURL, svc_uri))
    # dcat:mediaType: use an IANA-registered MIME type
    g.add((dist_uri, DCAT.mediaType, Literal("image/png")))
    # dct:conformsTo: reference the OGC WMS 1.3.0 specification URI
    g.add((dist_uri, DCTERMS.conformsTo, URIRef(WMS_130_SPEC_URI)))

    # ── 5. Serialise and inject the official DCAT-AP v3 @context ───────────
    # rdflib emits its own verbose context; overwrite it with the canonical
    # DCAT-AP v3 prefix map that harvesters expect.
    raw_jsonld = g.serialize(format="json-ld", indent=2)
    parsed: dict = json.loads(raw_jsonld)

    # rdflib may wrap output in {"@graph": [...]} — unwrap the first node
    # or keep the graph array depending on your portal's ingestion format.
    # For single-record output, flatten to the dataset node.
    graph_nodes = parsed.get("@graph", [parsed])
    # Find the Dataset node to use as the root document
    dataset_node = next(
        (n for n in graph_nodes
         if "@type" in n and "Dataset" in str(n["@type"])),
        graph_nodes[0] if graph_nodes else parsed,
    )
    dataset_node["@context"] = DCAT_AP_V3_CONTEXT

    return json.dumps(dataset_node, indent=2, ensure_ascii=False)

# ── Example usage ──────────────────────────────────────────────────────────────
if __name__ == "__main__":
    sample_layer = {
        "id": "orthophoto_2023",
        "title": "Aerial Orthophoto 2023",
        "abstract": "High-resolution true-colour orthophoto mosaic for the survey area.",
        "bbox": {
            "west":  -8.5,
            "south": 51.4,
            "east":  -6.0,
            "north": 53.5,
        },
    }
    service = "https://gis.example.gov/ows/wms"

    output = generate_wms_layer_jsonld(sample_layer, service)
    print(output)

Step-by-Step Walkthrough

Graph initialisation and namespace binding. Graph() creates an empty in-memory RDF graph. The three g.bind() calls register human-readable prefix aliases for the DCAT, Dublin Core Terms, and XML Schema Datatypes namespaces. These bindings control the prefixes that appear in Turtle serialisation — but for JSON-LD output, the @context override in step 5 is what actually governs prefix resolution.

DataService node construction. svc_uri is the bare WMS base URL cast to a URIRef. This URI is reused as both the dcat:DataService subject and the dcat:endpointURL object — the DCAT-AP specification requires dcat:endpointURL to resolve to a functional service endpoint. The dcat:servesDataset triple links the service node forward to each layer’s dcat:Dataset node, enabling harvesters to traverse from service to dataset without extra lookups.

Dataset node and language tagging. Every Literal() that carries human-readable text receives a lang="en" keyword argument. Harvesters implementing multilingual EU portals reject untagged string literals at the ingestion stage; SHACL shapes enforce this constraint. If your layers carry multilingual titles, add one dct:title triple per language code.

Spatial extent serialisation. The dcat:Location blank node (BNode()) is attached to the dataset via dcat:spatial. DCAT-AP v3 specifies dcat:bbox as a space-delimited string in west–south–east–north order (matching the WGS 84 geographic axis convention for EPSG:4326 bounding boxes), typed as xsd:string. Raw WKT geometry strings (POLYGON(...)) are valid via the GeoSPARQL geo:asWKT predicate, but only if your national profile explicitly requires it — the default DCAT-AP profile does not mandate WKT. The SRS and Coordinate Reference System Handling guide explains why the axis order in WMS 1.3.0 BBOX parameters differs from this serialisation and how to handle the conversion.

Distribution and conformance URI. The dcat:Distribution node represents the GetMap operation as an accessible form of the dataset. Setting dct:conformsTo to the OGC WMS 1.3.0 specification URI tells catalog validators exactly which service protocol version serves this distribution. Use dcat:accessURL (not dcat:downloadURL) for OGC service endpoints — downloadURL implies a direct file download, which GetMap is not. The dcat:mediaType literal must be an IANA-registered MIME type; image/png and image/jpeg are both valid for GetMap; application/vnd.ogc.wms_xml is appropriate for GetCapabilities distributions.

Context injection. rdflib’s JSON-LD serialiser generates its own @context derived from the registered namespace bindings, which typically differs from what DCAT-AP validators expect. Overwriting parsed["@context"] with DCAT_AP_V3_CONTEXT after deserialising the output ensures that downstream tools resolve prefix-qualified terms like dcat:Dataset correctly. If your portal’s ingestion pipeline expects an @graph array (for multi-record documents), skip the node-unwrapping step and inject the context at the top level of the @graph wrapper.

Where each capabilities element lands in the graph A six-row grid mapping WMS capabilities elements onto DCAT-AP properties. The service title becomes the data service title, each named layer becomes a dataset, the abstract becomes a description with the title as fallback, the keyword list becomes plain keyword literals rather than concepts, the geographic bounding box becomes the spatial coverage, and the GetMap URL becomes the distribution's access URL. WMS capabilities element DCAT-AP target Note Service Title dcat:DataService / dct:title one per WMS endpoint Layer Title dcat:Dataset / dct:title one per named layer Layer Abstract dct:description falls back to the title KeywordList dcat:keyword plain literals, not concepts EX_GeographicBoundingBox dct:spatial GeoJSON or WKT literal GetMap URL dcat:accessURL on the Distribution

Verification

Run the script and inspect the output. The dataset node should contain @id, @type, dct:title, dcat:spatial, and dcat:distribution at minimum.

python wms_to_dcat_ap.py

Expected output (key fields shown):

{
  "@context": {
    "dcat": "http://www.w3.org/ns/dcat#",
    "dct": "http://purl.org/dc/terms/",
    "xsd": "http://www.w3.org/2001/XMLSchema#",
    "locn": "http://www.w3.org/ns/locn#",
    "geo": "http://www.opengis.net/ont/geosparql#"
  },
  "@id": "https://gis.example.gov/ows/wms#dataset/orthophoto_2023",
  "@type": "dcat:Dataset",
  "dct:title": { "@value": "Aerial Orthophoto 2023", "@language": "en" },
  "dct:identifier": "orthophoto_2023",
  "dcat:spatial": {
    "@type": "dcat:Location",
    "dcat:bbox": {
      "@value": "-8.5 51.4 -6.0 53.5",
      "@type": "xsd:string"
    }
  },
  "dcat:distribution": {
    "@id": "https://gis.example.gov/ows/wms#distribution/orthophoto_2023/getmap",
    "@type": "dcat:Distribution",
    "dcat:mediaType": "image/png",
    "dct:conformsTo": { "@id": "http://www.opengis.net/spec/wms/1.3.0" }
  }
}

Verify that @id is a resolvable URI, that dcat:bbox uses the west–south–east–north order matching the WGS 84 extent of your layer, and that dct:conformsTo references the OGC specification URI exactly as shown. Feed the output to python -m pyld.jsonld expand output.jsonld to confirm context resolution.

Gotchas and Edge Cases

rdflib wraps multi-node output in @graph. When the graph contains more than one named or blank node, g.serialize(format="json-ld") emits a top-level {"@graph": [...]} structure rather than a single root object. The unwrapping logic in the script handles the common case, but if your output contains blank node IDs like "_:N..." as @id values, the dcat:Location spatial node has leaked to the top level — inspect the raw serialiser output and adjust the flattening logic accordingly.

Coordinate order in dcat:bbox must be west south east north. The DCAT-AP v3 specification mandates WGS 84 axis order for dcat:bbox, which is longitude (west) first, latitude (south) second. WMS 1.1.1 LatLonBoundingBox attributes (minx, miny, maxx, maxy) match this order. WMS 1.3.0 EX_GeographicBoundingBox child text elements (westBoundLongitude, southBoundLatitude, eastBoundLongitude, northBoundLatitude) also match it — but the WMS 1.3.0 GetMap BBOX parameter for EPSG:4326 flips to latitude-first. Extract bounding boxes for DCAT-AP serialisation from EX_GeographicBoundingBox, never from a GetMap URL.

dcat:theme must resolve to a URI. If your WMS layer <KeywordList> contains free-text keywords, do not pass them directly as dcat:theme objects — harvesters validate that theme values are URIs from a registered vocabulary (INSPIRE themes, EuroVoc, AGROVOC, or a national SKOS endpoint). Map keywords to vocabulary URIs in a lookup table before adding them to the graph, or omit dcat:theme and use dcat:keyword for free-text terms instead.

dct:license is mandatory for EU open data portal submission. The script above omits dct:license for brevity, but DCAT-AP SHACL shapes enforce its presence on the dcat:Dataset node for submission to EU open data portals. Add g.add((dataset_uri, DCTERMS.license, URIRef("https://creativecommons.org/licenses/by/4.0/"))) (or your applicable licence URI) before serialising. Validate the complete graph with pyshacl against the official DCAT-AP shapes before pushing to production. The Automated Metadata Harvesting Workflows guide covers how to integrate SHACL validation into a continuous harvesting pipeline.


Back to DCAT-AP for Spatial Data Portals

Serialise last, and always through a frame A four-stage chain. The capabilities document is parsed namespace-aware; an rdflib graph is built with one dataset node per named layer; the graph is validated against the DCAT-AP shapes and a violation fails the build rather than being logged; and only then is it serialised to JSON-LD through a frame, which keeps key order stable so the output diffs cleanly between runs. Parse capabilities namespace-aware Build the graph rdflib triples Validate SHACL against DCAT-AP Serialise JSON-LD, framed ElementTree one node per layer fail the build

Related