Building a CRS Whitelist for an OGC Service in Python

Pick a short list — the storage system, EPSG:4326, EPSG:3857 and whatever a regulator requires — normalise every incoming identifier across its three common spellings, and reject anything not on the list with an InvalidCRS exception that names what is supported. Advertising every system pyproj can construct is a promise to reproject arbitrarily, which is unbounded work and unbounded accuracy risk.

The Core Challenge: One System, Three Spellings

EPSG:4326, urn:ogc:def:crs:EPSG::4326 and http://www.opengis.net/def/crs/EPSG/0/4326 all identify the same reference system, arrive from real clients, and — critically — do not all imply the same axis order. The short form is conventionally longitude-first; the two authority forms are defined latitude-first. A service that compares identifiers as strings therefore has three entries per system and no way to reason about them.

A short list is a performance decision as much as a correctness one A five-row grid of the reference systems a service should advertise. WGS 84 and Web Mercator each cost a reprojection but are required by the interchange default and by web map clients respectively; the storage system costs nothing because the data already lives in it; a national grid is included when a regulator requires it; and everything else is excluded because the cost of supporting an arbitrary system is unbounded. Advertised CRS Cost per request Why it is on the list EPSG:4326 reproject on read the interchange default EPSG:3857 reproject on read every web map client Storage CRS none the data is already in it National grid reproject on read the regulator asks for it Everything else unbounded it is not on the list

The other half of the problem is scope. pyproj can construct thousands of reference systems, and it is tempting to accept anything it recognises. That converts an InvalidCRS exception — a clear, actionable error — into an open-ended commitment: every request may now trigger a datum transformation the service has never tested, whose accuracy depends on grid files that may not be installed. A whitelist turns that into a known set with known behaviour, which is what the mismatch handling guide assumes exists.

Production-Ready Code

from __future__ import annotations

import re
from dataclasses import dataclass
from functools import lru_cache

from pyproj import CRS
from pyproj.exceptions import CRSError

URN = re.compile(r"^urn:(?:x-)?ogc:def:crs:(?P<auth>[A-Za-z]+):(?:[\d.]*):(?P<code>\w+)$")
HTTP = re.compile(r"^https?://www\.opengis\.net/def/crs/(?P<auth>[A-Za-z]+)/\d*/(?P<code>\w+)$")
SHORT = re.compile(r"^(?P<auth>[A-Za-z]+):(?P<code>\w+)$")

# The authority forms carry the authority's own axis order; the short form
# is conventionally longitude-first regardless of what the authority says.
AUTHORITY_ORDERED = ("urn", "http")

class UnsupportedCRS(Exception):
    """Maps onto an InvalidCRS ServiceException at the protocol boundary."""

    def __init__(self, requested: str, supported: list[str]) -> None:
        self.requested, self.supported = requested, supported
        super().__init__(
            f"{requested!r} is not supported; this service offers "
            + ", ".join(supported))

@dataclass(frozen=True)
class CanonicalCRS:
    authority: str
    code: str
    lat_first: bool

    @property
    def urn(self) -> str:
        return f"urn:ogc:def:crs:{self.authority}::{self.code}"

    @property
    def short(self) -> str:
        return f"{self.authority}:{self.code}"

@lru_cache(maxsize=256)
def _axis_is_lat_first(authority: str, code: str) -> bool:
    """Ask the authority definition, not a hard-coded list of codes."""
    crs = CRS.from_authority(authority, code)
    first = crs.axis_info[0]
    return first.direction.lower() in {"north", "south"}

def normalise(identifier: str) -> CanonicalCRS:
    """Parse any of the three spellings into one canonical value.

    The spelling decides axis order: only the authority forms follow the
    authority's own axis definition.
    """
    token = identifier.strip()
    for pattern, kind in ((URN, "urn"), (HTTP, "http"), (SHORT, "short")):
        match = pattern.match(token)
        if not match:
            continue
        authority = match.group("auth").upper()
        code = match.group("code")
        try:
            authority_lat_first = _axis_is_lat_first(authority, code)
        except CRSError as exc:
            raise UnsupportedCRS(identifier, []) from exc
        return CanonicalCRS(authority, code,
                            lat_first=authority_lat_first and kind in AUTHORITY_ORDERED)
    raise UnsupportedCRS(identifier, [])

class CRSWhitelist:
    """The bounded set this service is willing to serve."""

    def __init__(self, storage: str, offered: list[str]) -> None:
        self.storage = normalise(storage)
        self._allowed = {}
        for item in [storage, *offered]:
            canonical = normalise(item)
            self._allowed[(canonical.authority, canonical.code)] = canonical

    @property
    def advertised(self) -> list[str]:
        """What GetCapabilities should list — both spellings per system."""
        out: list[str] = []
        for crs in self._allowed.values():
            out.extend([crs.short, crs.urn])
        return out

    def resolve(self, identifier: str) -> CanonicalCRS:
        canonical = normalise(identifier)
        key = (canonical.authority, canonical.code)
        if key not in self._allowed:
            raise UnsupportedCRS(identifier, sorted(c.short for c in self._allowed.values()))
        return canonical

    def needs_reprojection(self, target: CanonicalCRS) -> bool:
        return (target.authority, target.code) != (self.storage.authority,
                                                   self.storage.code)

WHITELIST = CRSWhitelist(
    storage="EPSG:2056",                       # what the data actually is
    offered=["EPSG:4326", "EPSG:3857", "EPSG:21781"],
)

Step-by-Step Walkthrough

Axis order is a property of the spelling, not only of the code. _axis_is_lat_first asks pyproj what the authority says, and normalise then applies that answer only for the two authority spellings. This is the single most important line in the module: it is why a request naming EPSG:4326 and one naming urn:ogc:def:crs:EPSG::4326 produce different canonical values despite referring to the same system.

Five steps between a CRS parameter and the data store A five-stage chain. The reference system identifier arrives in whichever parameter the protocol version uses; it is normalised across the urn, http and short spellings into one canonical form; that form is checked against the whitelist and an unknown system is rejected immediately with an InvalidCRS exception; the authority axis order is resolved; and only a canonical identifier reaches the data store. Request arrives CRS= or SRS= Normalise the identifier urn, http and short forms Check the whitelist reject unknown early Resolve axis order from the authority Hand to the backend one canonical form raw one spelling 400 if absent lat-first?

lru_cache on the axis lookup matters. CRS.from_authority parses a definition from the PROJ database, which takes on the order of a millisecond. Doing it per request on a service handling hundreds of GetMap calls per second is real load for an answer that never changes.

Advertise both spellings for each supported system. Clients differ in what they send, and a capabilities document listing only EPSG:4326 causes conformant 1.3.0 clients that prefer the urn form to conclude the system is unsupported. Emitting both from one canonical entry keeps the list honest without duplicating the whitelist.

needs_reprojection makes the cheap path visible. A request in the storage system costs nothing beyond the query; every other system costs a transformation over every geometry. Making that a single explicit predicate lets the request path skip transformer construction entirely for what is usually the most common case.

Verification

Confirm the three spellings normalise correctly and that axis order differs where it should:

short = WHITELIST.resolve("EPSG:4326")
urn = WHITELIST.resolve("urn:ogc:def:crs:EPSG::4326")
assert (short.authority, short.code) == (urn.authority, urn.code)
assert short.lat_first is False, "short form is longitude-first by convention"
assert urn.lat_first is True, "authority form follows the EPSG axis definition"

try:
    WHITELIST.resolve("EPSG:27700")
except UnsupportedCRS as exc:
    print(exc)
'EPSG:27700' is not supported; this service offers EPSG:2056, EPSG:21781, EPSG:3857, EPSG:4326

An error naming the supported set is what lets a client correct itself. InvalidCRS with no detail sends the developer to a capabilities document they have probably already misread.

Gotchas & Edge Cases

Never substitute a nearby system. Serving EPSG:4258 when EPSG:4326 was asked for is tempting — the two agree to within a metre for most purposes — and it is the wrong call. A service that quietly changes the reference system has broken the contract its capabilities document advertises, and the discrepancy surfaces later as a systematic offset nobody can trace.

Rejecting is the only honest answer to an unsupported CRS A decision diamond for a request naming a reference system outside the whitelist. A close relative on the list still warrants an InvalidCRS exception naming the supported alternative rather than a silent substitution; an unresolvable authority code is a plain rejection; a deprecated EPSG code should be rejected with its successor named; and only a system on the list is served, by reprojecting from storage. A client asked for a CRS that is not on the list. What should the service return? InvalidCRS + suggest never silently substitute InvalidCRS the code cannot be resolved InvalidCRS + the successor EPSG retires codes Serve it reproject from storage close relative on list unknown authority deprecated code on the list

Deprecated codes deserve a specific message. EPSG retires codes and publishes successors. A request naming a retired code is best answered with an exception that names the replacement, because the client is usually working from documentation older than the registry.

Datum transformations need grid files. A transformation between two datums may be available as a low-accuracy seven-parameter approximation or as a high-accuracy grid shift, and pyproj silently picks whichever it can find. Where a national grid is on the whitelist, verifying that the corresponding grid file is installed belongs in the same startup check that builds the whitelist — otherwise development and production disagree, exactly the class of drift covered in Environment Parity for Spatial Servers.

The whitelist belongs in configuration, not code. The constant above is illustrative; in a real deployment the list travels with the environment definition so that adding a reference system is a reviewed change rather than a redeploy.

Frequently Asked Questions

Why not just accept anything pyproj can construct?

Because it turns a bounded promise into an unbounded one. Every accepted system implies a datum transformation your service has never exercised, whose accuracy depends on grid files that may or may not be installed on the host, and whose cost you have never measured. A whitelist means the set of transformations in production is the set you tested.

Should the whitelist include the storage CRS?

Yes, and it should be first. Serving data in the system it is already stored in is the only path that costs no transformation at all, and clients that care about fidelity — analysis clients rather than map clients — will ask for it specifically. Excluding it forces every consumer through a reprojection they did not need.

How do I decide the axis order for a system I have not seen?

Ask the authority through pyproj rather than maintaining a list. crs.axis_info gives the ordered axes with their directions; a first axis pointing north or south means latitude-first. That answer is correct for every system in the EPSG registry, including the ones nobody remembers.

What should GetCapabilities advertise?

Both the short and the authority spelling of every whitelisted system, generated from the same canonical entries the request path checks against. Generating the advertisement and the validation from one source is what stops the classic failure where capabilities promises a system the request handler rejects.


Back to SRS and Coordinate Reference System Handling

Related