A layer group is one addressable layer backed by several. POST a layerGroup payload naming its members in draw order, a style per member, an explicit mode, and recomputed bounds. The three defaults worth overriding are the draw order — the first member is drawn first and therefore sits at the bottom — the per-member style, and the bounding box, which is not derived from the members unless you ask.
Publishing a group is not the same as publishing a layer. There is no data store behind it and no schema to introspect; it is purely a composition instruction, and every part of that instruction is a decision the API will make for you if you leave it out.
The mode matters most for discoverability. SINGLE advertises one layer and hides the members, which is what most consumers want. NAMED advertises both. CONTAINER advertises only the members, so the group itself cannot be requested at all — which is occasionally intentional for a folder-like structure and is otherwise a confusing way to publish nothing. Getting the mode wrong produces a group that is either missing from GetCapabilities or cluttering it.
from __future__ import annotations
from dataclasses import dataclass
from typing import Sequence
import requests
from requests.auth import HTTPBasicAuth
@dataclass(frozen=True)
class Member:
"""One layer in a group, with the style it should render under."""
qualified_name: str # "workspace:layer"
style: str | None = None # None means that layer's own default
@dataclass
class LayerGroupClient:
base: str
user: str
password: str
timeout: int = 60
@property
def auth(self) -> HTTPBasicAuth:
return HTTPBasicAuth(self.user, self.password)
def _get(self, path: str) -> dict | None:
resp = requests.get(f"{self.base.rstrip('/')}{path}", auth=self.auth,
timeout=self.timeout,
headers={"Accept": "application/json"})
if resp.status_code == 404:
return None
resp.raise_for_status()
return resp.json()
def layer_bounds(self, qualified_name: str) -> tuple[float, ...]:
"""The geographic bounding box GeoServer advertises for one layer."""
layer = self._get(f"/rest/layers/{qualified_name}.json")
if layer is None:
raise KeyError(f"layer {qualified_name!r} does not exist")
resource = requests.get(layer["layer"]["resource"]["href"],
auth=self.auth, timeout=self.timeout).json()
detail = next(iter(resource.values()))
box = detail["latLonBoundingBox"]
return (box["minx"], box["miny"], box["maxx"], box["maxy"])
def union_bounds(self, members: Sequence[Member]) -> dict:
"""Recompute the group extent as the union of its members.
GeoServer does not derive this — a group created without bounds
gets whatever the first member had, and clips everything else.
"""
boxes = [self.layer_bounds(m.qualified_name) for m in members]
return {
"minx": min(b[0] for b in boxes), "miny": min(b[1] for b in boxes),
"maxx": max(b[2] for b in boxes), "maxy": max(b[3] for b in boxes),
"crs": "EPSG:4326",
}
def publish(self, workspace: str, name: str, title: str,
members: Sequence[Member], mode: str = "SINGLE",
bottom_first: bool = True) -> int:
"""Create or replace a layer group.
`members` is given top-to-bottom by default — the order a person
reads a legend in — and reversed here, because GeoServer draws the
first entry first and therefore at the bottom.
"""
if not members:
raise ValueError("a layer group needs at least one member")
for member in members:
if self._get(f"/rest/layers/{member.qualified_name}.json") is None:
raise KeyError(f"member {member.qualified_name!r} is not published")
ordered = list(reversed(members)) if bottom_first else list(members)
payload = {"layerGroup": {
"name": name,
"title": title,
"mode": mode,
"workspace": {"name": workspace},
"publishables": {"published": [
{"@type": "layer", "name": m.qualified_name} for m in ordered]},
"styles": {"style": [
({"name": m.style} if m.style else "") for m in ordered]},
"bounds": self.union_bounds(ordered),
}}
existing = self._get(f"/rest/workspaces/{workspace}/layergroups/{name}.json")
if existing is None:
resp = requests.post(
f"{self.base}/rest/workspaces/{workspace}/layergroups",
auth=self.auth, timeout=self.timeout, json=payload,
headers={"Content-Type": "application/json"})
else:
resp = requests.put(
f"{self.base}/rest/workspaces/{workspace}/layergroups/{name}",
auth=self.auth, timeout=self.timeout, json=payload,
headers={"Content-Type": "application/json"})
if resp.status_code not in (200, 201):
raise RuntimeError(f"{resp.status_code}: {resp.text[:300]}")
return resp.status_code
if __name__ == "__main__":
client = LayerGroupClient("https://example.org/geoserver", "admin", "…")
client.publish(
workspace="basemap", name="topographic", title="Topographic basemap",
members=[ # top of the legend first
Member("basemap:labels", "labels_light"),
Member("basemap:roads", "roads_thin"),
Member("basemap:landuse", "landuse_muted"),
Member("basemap:hillshade"),
])
Draw order is reversed from legend order. GeoServer renders publishables in document order, so the first entry is painted first and everything after covers it. Humans list layers the way a legend reads — labels at the top, terrain at the bottom — so the bottom_first flag reverses the caller’s natural order once, in one place, instead of leaving every caller to remember.
An empty string means “use the member’s default style”. The styles array must be the same length as the members, and each entry is either a style object or an empty string. Omitting the array entirely works but leaves every member on its own default, which is rarely what a composed basemap wants — a road layer styled for standalone viewing usually has labels that fight with the group’s own label layer.
Bounds are not derived. This is the single most surprising behaviour: a group created without a bounds block inherits a box from the first member, and every other member is clipped to it. Computing the union up front costs one request per member and removes an entire class of “half the group is missing” reports.
Publish is create-or-replace. Checking for the group and choosing POST or PUT accordingly makes the operation idempotent, which is what lets it live in the same pipeline as the layer publishing described in Layer Publishing Workflows in Python.
Confirm the group is advertised as one layer and covers the union of its members:
import xml.etree.ElementTree as ET
caps = requests.get(f"{base}/wms", params={"SERVICE": "WMS",
"REQUEST": "GetCapabilities"}).content
names = {el.text for el in ET.fromstring(caps).iter()
if el.tag.endswith("}Name") and el.text}
assert "basemap:topographic" in names
assert "basemap:hillshade" not in names, "SINGLE mode should hide the members"
print(client.union_bounds([Member("basemap:roads"), Member("basemap:hillshade")]))
{'minx': 5.9559, 'miny': 45.818, 'maxx': 10.4921, 'maxy': 47.8085, 'crs': 'EPSG:4326'}
Then render it and compare against the members drawn individually — a group whose output differs from the sum of its parts has an order or style problem.
A group can contain another group. Nesting is supported by setting the publishable type to layerGroup, and it is a good way to compose a basemap from reusable sub-groups. It also makes the draw order harder to reason about, since each nested group contributes its own ordered stack.
Deleting a member breaks the group. GeoServer does not cascade: removing a layer that a group references leaves the group in the catalog pointing at nothing, and requests against it fail. Checking group membership before deleting a layer belongs in whatever tooling does the deleting.
Workspace-scoped versus global groups. A group created under /rest/workspaces/{ws}/layergroups is qualified by that workspace; one created under /rest/layergroups is global and can span workspaces. Global groups are convenient and are a common source of name collisions — prefer workspace-scoped unless the group genuinely spans several.
Styles must exist first. A group naming a style that has not been uploaded is rejected, which is the same ordering constraint the catalog restore has to respect.
Because GeoServer draws the first member first, so it ends up at the bottom of the stack. People almost always write the list the way a legend reads — labels first, terrain last — which produces exactly the inverse. Reverse the list once, in the publishing helper, rather than asking every caller to think about it.
In practice yes. A group created without a bounds block does not compute the union of its members; it takes a box from the first one and clips the rest. Computing the union costs one small request per member and eliminates a failure that otherwise looks like missing data.
SINGLE advertises the group as one layer and hides its members from capabilities. NAMED advertises both, which is useful when consumers should be able to request individual layers as well as the composition. CONTAINER advertises only the members and makes the group itself unrequestable.
A global group can, one created under a workspace cannot. Global groups are the right tool for a basemap composed from layers owned by different teams, at the cost of a flat global namespace where name collisions are easy.
Back to Layer Publishing Workflows in Python
Related