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.
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.
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.
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"],
)
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.
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.
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.
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.
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.
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.
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.
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.
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