Generating Gateway Routes from Domain Manifests
A hand-maintained gateway configuration accumulates routes whose purpose is forgotten and overlaps whose precedence is accidental, until adding a domain becomes a change-management exercise. Generating the table from domain manifests removes both problems at once: precedence becomes explicit, overlaps become compile errors, and a new domain becomes routable by registering rather than by editing a shared file. This guide builds that compiler, including the checks that make a bad table fail a build instead of a request. It implements the generation discipline in API Gateway Mapping for GIS Services within Federated Ownership & Routing Architecture.
Prerequisites
| Requirement | Value / Assumption | Notes |
|---|---|---|
| Tools | python3 ≥ 3.11, the gateway CLI, CI |
The compile step gates the deploy |
| Manifests | One per product, in the registry | The only input; nothing is hand-added |
| Routing key | The x-spatial-domain header, set at the edge |
Client-supplied keys are rewritten first |
| Access roles | CI applies; nobody edits the generated table | An editable generated file will be edited |
| Environment | REGISTRY_API, GATEWAY_ENV |
Exported before running |
Step-by-Step Implementation
1. Compile manifests into routes with explicit precedence
# compile_routes.py — manifests in, gateway table out. Deterministic.
import hashlib
import json
def routes_for(manifest: dict) -> list[dict]:
"""One route per declared port. Precedence is derived from match specificity,
so it cannot depend on the order manifests happen to be read in."""
m, s = manifest["metadata"], manifest["spec"]
out = []
for port in s["ports"]:
match = {
"header:x-spatial-domain": m["domain"],
"path_prefix": f"/{m['domain']}/{m['name']}",
"port_type": port["type"],
}
if port.get("matrixSet"):
match["header:x-spatial-matrixset"] = port["matrixSet"]
if s["storage"].get("crs"):
match["header:x-spatial-crs"] = s["storage"]["crs"]
out.append({
"id": f"{m['domain']}.{m['name']}.{port['type']}",
"match": match,
# More match conditions = more specific = higher precedence. Explicit,
# total, and independent of file order.
"priority": len(match) * 100,
"backend": f"{m['domain']}-{m['name']}-{port['type']}.svc",
"sla_tier": s.get("slo", {}).get("availability", 0.999),
"owner": m["owner"],
})
return out
def compile_table(manifests: list[dict]) -> dict:
routes = [r for m in manifests for r in routes_for(m)]
routes.sort(key=lambda r: (-r["priority"], r["id"])) # deterministic ordering
digest = hashlib.sha256(
json.dumps(routes, sort_keys=True, separators=(",", ":")).encode()
).hexdigest()[:16]
return {"version": digest, "routes": routes}
Verify the compile is deterministic — the same manifests must always produce the same table digest, or the drift signal is worthless:
python3 compile_routes.py --registry "$REGISTRY_API" | jq -r .version
python3 compile_routes.py --registry "$REGISTRY_API" | jq -r .version
# The two digests must match.
2. Fail the build on an ambiguous or unreachable route
# checks.py — the two failures that must never reach production.
def check_no_ambiguity(routes: list[dict]) -> list[str]:
"""Two routes that can match one request without a defined ordering between
them make dispatch depend on evaluation order rather than on ownership."""
errors = []
for i, a in enumerate(routes):
for b in routes[i + 1:]:
if a["priority"] != b["priority"]:
continue # precedence resolves it
shared = set(a["match"].items()) & set(b["match"].items())
if len(shared) == len(a["match"]) == len(b["match"]):
errors.append(
f"ambiguous: {a['id']} ({a['owner']}) and {b['id']} ({b['owner']}) "
f"match identically at priority {a['priority']}")
return errors
def check_backends_exist(routes: list[dict], resolve) -> list[str]:
"""A route to a service that does not exist is a 503 waiting for a consumer."""
return [f"unreachable: {r['id']} -> {r['backend']}"
for r in routes if not resolve(r["backend"])]
Verify both checks bite, using a deliberately broken manifest:
python3 compile_routes.py --registry "$REGISTRY_API" --extra-manifest /tmp/conflicting.yaml \
2>&1 | grep -E '^(ambiguous|unreachable):'
echo "exit=$?" # non-zero — the build fails and names both owners
3. Review the generated diff, not the manifest patch
The reviewable artifact is what changes about routing, which a manifest diff does not show.
# What this change does to the table, in terms a reviewer can evaluate.
python3 compile_routes.py --registry "$REGISTRY_API" --ref main > /tmp/before.json
python3 compile_routes.py --registry "$REGISTRY_API" --ref HEAD > /tmp/after.json
python3 - <<'PY'
import json
before = {r["id"]: r for r in json.load(open("/tmp/before.json"))["routes"]}
after = {r["id"]: r for r in json.load(open("/tmp/after.json"))["routes"]}
for rid in sorted(after.keys() - before.keys()): print(f"+ {rid} -> {after[rid]['backend']}")
for rid in sorted(before.keys() - after.keys()): print(f"- {rid}")
for rid in sorted(before.keys() & after.keys()):
if before[rid] != after[rid]:
print(f"~ {rid}: priority {before[rid]['priority']} -> {after[rid]['priority']}")
PY
4. Deploy as a full replacement, never a patch
# Idempotent by construction: applying the same table twice changes nothing, and
# rolling back is a rebuild from a previous registry state rather than an undo.
gateway-cli apply --table /tmp/after.json --env "$GATEWAY_ENV" --replace
# Confirm the live table's digest matches what was compiled.
gateway-cli describe --env "$GATEWAY_ENV" | jq -r .table_version
jq -r .version /tmp/after.json
Configuration Reference
| Element | Value | Effect |
|---|---|---|
| Input | Registry manifests only | Nothing is hand-added |
| Precedence | len(match) * 100 |
Specificity, not file order |
| Ambiguity | Compile failure, both owners named | Never a runtime coin flip |
| Backend check | Compile failure | A route with no backend is a 503 in waiting |
| Table digest | SHA-256 of the sorted routes | Drift detection and rollback identity |
| Deploy mode | --replace |
Full replacement is idempotent; a patch is not |
| Rollback | Rebuild from a prior registry ref | No hand-crafted undo |
Common Failure Modes & Fixes
The digest changes on every compile with no manifest change. Root cause: a non-deterministic input — a timestamp, an unsorted dictionary, a set iteration. Fix: sort everything before hashing; without determinism the digest cannot detect drift.
Two domains conflict and the build passes. Root cause: their match conditions differ by one inert field, so the priorities differ and the ambiguity check does not fire — yet both still match a real request. Fix: check for overlapping match sets, not identical ones, whenever priorities are close.
A generated route is edited by hand in an incident. Root cause: entirely understandable and it will happen. Fix: make the next compile overwrite it and alert on the divergence, so the fix is captured in a manifest rather than living in the gateway until someone rebuilds.
Reviewers approve a change that moves traffic unexpectedly. Root cause: the review artifact was the manifest patch. Fix: the route diff above; a rule intended to add a domain that captures a neighbour’s extent shows up as routes moving, which the patch does not reveal.
Deploy succeeds and old routes persist.
Root cause: applying as a patch rather than a replacement. Fix: --replace; incremental application is how orphaned routes accumulate.
FAQ
Why derive precedence from specificity rather than declaring it?
Because a declared priority is a number someone has to choose, and choosing it correctly requires knowing every other route — which is exactly the global knowledge the generation step has and a manifest author does not. Deriving it from match specificity gives the intuitive behaviour for free: a route matching on domain, CRS and matrix set beats one matching on domain alone, which is what anyone would expect. Where two routes genuinely tie, that is an ambiguity worth failing the build over rather than resolving with an arbitrary tiebreak.
Should the gateway table live in version control?
The manifests should; the table should not. Committing a generated artifact invites someone to edit it, and the moment that happens the generation step stops being the source of truth. Keeping the digest in the deployment record gives the same auditability — any live table traces to a registry state and therefore to the manifests that produced it — without creating a file whose relationship to its inputs is a convention rather than a guarantee.
How does this handle a domain with an unusual routing need?
Through a manifest field rather than a hand-written route. A domain needing an extra match condition, a non-standard path, or a specific timeout declares it in the manifest, and the compiler emits the corresponding route. If the compiler cannot express it, that is a signal the platform’s routing model needs extending — and extending it in one place is far better than one domain having a bespoke route that every future refactor has to remember.
What happens during the window between compile and deploy?
Nothing, because the compile is read-only and produces an artifact. The deploy is the only mutating step, it is a full replacement, and it is atomic at the gateway. A compile that fails deploys nothing; a deploy that fails leaves the previous table live. That property is what makes it safe to compile on every pull request, which is what turns routing conflicts into build failures rather than production incidents.
Can a domain see the routes generated for it?
It should, and exposing them removes a common source of confusion. A domain engineer looking at a manifest cannot easily predict which routes it produces, and a routing question that requires reading the compiler is a question that becomes a support ticket. Publishing the generated routes per product in the catalog — the match conditions, the derived priority, the backend — lets a domain confirm that what they declared is what will be matched, before they discover otherwise from a 404. It also makes the ownership column in the table useful: any route in production traces to a manifest and therefore to a team, which is exactly what an on-call engineer needs at three in the morning.
Related
- API Gateway Mapping for GIS Services — the parent topic and the generation rationale
- Mapping API Gateways to Distributed GIS Endpoints — the route table’s runtime behaviour
- Cross-Domain Routing Strategies — the precedence and default rules this enforces