Setting Layer Attribution and Metadata Links From Python

Attribution belongs to the layer object; abstract, keywords and metadata links belong to the underlying resource. That is two different REST endpoints for what looks like one concern, and a MetadataURL without both a type and a format attribute is silently dropped from the capabilities document. Drive all of it from the same metadata record that feeds your catalog.

The Core Challenge: Two Objects, One Apparent Concern

GeoServer models a published dataset as two objects. The resource — a feature type or coverage — describes the data: its title, abstract, keywords, bounding boxes and metadata links. The layer describes how it is published: its default style, whether it is queryable, and its attribution. A single conceptual field like “who owns this data” therefore lands on the resource or the layer depending on which field you mean.

Six fields, set in two different places A six-row grid of publication metadata fields. Attribution text, link and logo are properties of the layer object. Metadata links, keywords and the abstract belong to the underlying resource — the feature type or coverage — which is a different REST endpoint. All six surface in the WMS capabilities document. Field Set on Appears in capabilities as Attribution text the layer <Attribution><Title> Attribution link the layer <Attribution><OnlineResource> Attribution logo the layer <LogoURL> with size and format Metadata link the resource <MetadataURL type=… > Keywords the resource <KeywordList> Abstract the resource <Abstract>

Getting this wrong is quiet. Setting an attribution on the resource payload does not error — the field is simply not part of that object and is ignored — so the call returns 200 and nothing appears in capabilities. The same is true in reverse for metadata links. Since both objects are addressed through similar-looking URLs, the mistake is easy to make and hard to see, which is why the code below models them as two explicit steps.

Production-Ready Code

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any
from xml.etree import ElementTree as ET

import requests
from requests.auth import HTTPBasicAuth

# A MetadataURL is dropped from capabilities unless both are present.
METADATA_TYPES = {
    "ISO19115:2003": "text/xml",
    "TC211": "text/xml",
    "FGDC": "text/xml",
    "other": "text/html",
}

@dataclass
class MetadataLink:
    url: str
    metadata_type: str = "ISO19115:2003"
    content_type: str | None = None

    def as_payload(self) -> dict[str, str]:
        if not self.url.lower().startswith(("http://", "https://")):
            raise ValueError(f"metadata link must be absolute: {self.url!r}")
        return {
            "type": self.metadata_type,
            "metadataType": self.metadata_type,
            "content": self.content_type or METADATA_TYPES.get(
                self.metadata_type, "text/xml"),
        } | {"url": self.url}

@dataclass
class Publication:
    """Everything a published layer should say about itself."""
    title: str
    abstract: str
    keywords: list[str] = field(default_factory=list)
    metadata_links: list[MetadataLink] = field(default_factory=list)
    attribution_title: str | None = None
    attribution_href: str | None = None
    logo_url: str | None = None
    logo_width: int = 0
    logo_height: int = 0
    logo_type: str = "image/png"

@dataclass
class Publisher:
    base: str
    user: str
    password: str
    timeout: int = 60

    @property
    def auth(self) -> HTTPBasicAuth:
        return HTTPBasicAuth(self.user, self.password)

    def _put(self, path: str, payload: dict[str, Any]) -> None:
        resp = requests.put(f"{self.base.rstrip('/')}{path}", auth=self.auth,
                            timeout=self.timeout, json=payload,
                            headers={"Content-Type": "application/json"})
        resp.raise_for_status()

    def apply(self, workspace: str, store: str, layer: str,
              publication: Publication) -> None:
        """Write the resource fields and the layer fields — two calls."""
        resource: dict[str, Any] = {
            "title": publication.title,
            "abstract": publication.abstract,
            "enabled": True,
        }
        if publication.keywords:
            resource["keywords"] = {"string": list(publication.keywords)}
        if publication.metadata_links:
            resource["metadataLinks"] = {
                "metadataLink": [m.as_payload() for m in publication.metadata_links]}

        self._put(f"/rest/workspaces/{workspace}/datastores/{store}"
                  f"/featuretypes/{layer}", {"featureType": resource})

        layer_payload: dict[str, Any] = {}
        if publication.attribution_title or publication.logo_url:
            attribution: dict[str, Any] = {}
            if publication.attribution_title:
                attribution["title"] = publication.attribution_title
            if publication.attribution_href:
                attribution["href"] = publication.attribution_href
            if publication.logo_url:
                # A logo without width, height and type is dropped by most
                # clients even when GeoServer stores it.
                attribution |= {
                    "logoURL": publication.logo_url,
                    "logoWidth": publication.logo_width,
                    "logoHeight": publication.logo_height,
                    "logoType": publication.logo_type,
                }
            layer_payload["attribution"] = attribution

        if layer_payload:
            self._put(f"/rest/layers/{workspace}:{layer}", {"layer": layer_payload})

def advertised_metadata(capabilities: bytes, layer_name: str) -> dict[str, Any]:
    """Read back what clients will actually see — the only real verification."""
    root = ET.fromstring(capabilities)
    ns = "{http://www.opengis.net/wms}"
    for node in root.iter(f"{ns}Layer"):
        name = node.findtext(f"{ns}Name")
        if name != layer_name:
            continue
        attribution = node.find(f"{ns}Attribution")
        return {
            "title": node.findtext(f"{ns}Title"),
            "abstract": node.findtext(f"{ns}Abstract"),
            "keywords": [k.text for k in node.iter(f"{ns}Keyword")],
            "metadata_urls": [
                {"type": m.get("type"),
                 "format": m.findtext(f"{ns}Format"),
                 "href": (m.find(f"{ns}OnlineResource").get(
                     "{http://www.w3.org/1999/xlink}href")
                     if m.find(f"{ns}OnlineResource") is not None else None)}
                for m in node.iter(f"{ns}MetadataURL")],
            "attribution": (attribution.findtext(f"{ns}Title")
                            if attribution is not None else None),
        }
    raise KeyError(f"{layer_name!r} is not advertised")

Step-by-Step Walkthrough

Two calls, deliberately. apply writes the resource first and the layer second, because the fields genuinely live on different objects. Collapsing them into one helper that silently ignores fields on the wrong object is exactly how attribution ends up stored nowhere.

Publish metadata from the record, not from a wiki page A five-stage chain. The authoritative metadata record supplies the values; one mapping converts them to GeoServer's field names; the resource endpoint receives the abstract, keywords and metadata links; the layer endpoint receives the attribution; and the result is asserted in the published capabilities document rather than assumed. Source of truth the metadata record Map to fields abstract, keywords, links PUT the resource feature type endpoint PUT the layer attribution endpoint Assert in capabilities the published contract ISO or DCAT one mapping resource layer

A MetadataURL needs a type and a format. GeoServer stores a metadata link with a missing type and then omits it from the capabilities document, because the WMS schema requires the attribute. The MetadataLink dataclass supplies a sensible content type per metadata type rather than leaving it to the caller, and rejects a relative URL outright — a relative MetadataURL is published and useless, since clients have no base to resolve it against.

Keywords are a list of strings, wrapped. GeoServer’s JSON representation expects {"keywords": {"string": [...]}} rather than a bare array, which is one of several places its XML heritage shows through the JSON API. Sending a bare list is accepted and stores nothing.

The logo needs dimensions. A LogoURL without a width, height and format is stored but ignored by most clients, which read the size to lay the attribution out before the image loads. Since the values are trivially known at publish time, supplying them is free.

Verification

Apply the publication and read it back from capabilities, which is what consumers actually see:

publisher.apply("cadastre", "pg_store", "parcels", Publication(
    title="Cadastral parcels",
    abstract="Parcel boundaries maintained by the cantonal land registry.",
    keywords=["cadastre", "parcels", "INSPIRE:CadastralParcels"],
    metadata_links=[MetadataLink(
        "https://metadata.example.org/records/parcels.xml")],
    attribution_title="Kanton Zurich, Amt fuer Geoinformation",
    attribution_href="https://example.org/geodata",
))

caps = requests.get(f"{base}/wms", params={"SERVICE": "WMS",
                                           "REQUEST": "GetCapabilities"}).content
print(advertised_metadata(caps, "cadastre:parcels"))
{'title': 'Cadastral parcels',
 'abstract': 'Parcel boundaries maintained by the cantonal land registry.',
 'keywords': ['cadastre', 'parcels', 'INSPIRE:CadastralParcels'],
 'metadata_urls': [{'type': 'ISO19115:2003', 'format': 'text/xml',
                    'href': 'https://metadata.example.org/records/parcels.xml'}],
 'attribution': 'Kanton Zurich, Amt fuer Geoinformation'}

Reading the capabilities document rather than the REST catalog is the point: the REST catalog can hold a field that never reaches a client.

Gotchas & Edge Cases

The abstract is the description consumers see. It is what appears in a portal listing and what a DCAT-AP or ISO 19115 record picks up when harvesting the service. Leaving it empty means every downstream catalogue entry falls back to the title.

Three reasons a metadata link does not publish A decision diamond for a metadata link that is stored but not advertised. A metadata link without both a type and a format attribute is dropped from the capabilities document; a link set on the layer object rather than on the underlying resource is stored in the wrong place; a relative URL is published but useless to clients; and when all three are right, the capabilities document is simply cached. The metadata link is in the catalog but not in capabilities. What was wrong? Set type and format both are required Set it on the resource attribution is the layer's Use an absolute URL clients do not resolve it Reload the layer the capabilities cache is stale no type attribute set on the layer relative URL all correct

Keywords with a namespace prefix carry meaning. INSPIRE and several national profiles expect keywords in a vocabulary:term form, and harvesters key on them. Publishing them as free text loses that association without any error.

Capabilities may be cached. After writing metadata, a client fetching capabilities can receive a cached document for some seconds. When asserting in a pipeline, either request with a cache-busting parameter or allow a brief retry — the reload guide covers which caches are in play.

Attribution is per layer, not per group. A layer group does not inherit its members’ attribution, so a composed basemap needs its own — and forgetting it is how a public basemap ends up with no credit line at all.

Frequently Asked Questions

Why does my MetadataURL not show up in capabilities?

Almost always a missing type or format attribute. The WMS schema requires both on a MetadataURL element, so GeoServer stores the link in its catalog and then omits it when generating the document. The second most common cause is setting it on the layer rather than on the underlying resource.

Should metadata live in GeoServer or in a catalogue?

In a catalogue, with GeoServer publishing a pointer to it. The MetadataURL element exists precisely so a service can say ‘the authoritative record is over there’, and duplicating the full record into the service guarantees the two will diverge. Push the title, abstract and keywords from the record so the service description stays a projection of it.

Do keywords affect anything other than display?

Yes. Harvesters and portals index them, and profiles such as INSPIRE define controlled vocabularies whose terms carry legal meaning for discoverability. A keyword written as free text where a vocabulary term was expected is not an error anywhere, and simply fails to be found.

Can I set these fields on a coverage rather than a feature type?

Yes — the resource endpoint differs, using coveragestores and coverages instead of datastores and featuretypes, but the field names and the two-object split are identical. Only the path changes.


Back to Layer Publishing Workflows in Python

Related