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

python
# 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}

Manifests in, table out — and the two checks that fail the buildRegistry manifests are collected, compiled into routes with precedence derived from match specificity, checked for ambiguity and for unreachable backends, and emitted as a table deployed as a full replacement. Two checks drop onto a rail ending in a build failure that names both owning teams. Because the compile is deterministic and the deploy is a full replacement, applying the same registry state twice changes nothing and rolling back is a rebuild from a previous state rather than a hand-crafted undo.Manifestsfrom the registryCompilepriority = specificityChecksambiguity · reachabilityTable deployedfull replacementcollectedroutescleantwo owners namedBuild failsnever a runtime coin flip

Verify the compile is deterministic — the same manifests must always produce the same table digest, or the drift signal is worthless:

bash
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

python
# 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:

bash
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 a reviewer sees, depending on which artifact they are shownThree review artifacts compared on what each reveals. A manifest patch shows the text that changed and hides which requests move. The generated table shows every route and buries the change among hundreds of unchanged ones. The route diff shows exactly which routes were added, removed or re-prioritised, which is the only form in which a rule that accidentally captures a neighbour extent is visible. The last row records the failure the diff prevents: a change approved because its effect was not in the artifact reviewed.Manifest patchWhole tableRoute diffShows text changedyesburiedn/aShows requests movednonoyesReveals a captured extentnohardobviousSize to reviewsmallhundreds of routesa few lines

bash
# 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

bash
# 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

Rollback under generation: rebuild from a prior registry stateFive stages of a rollback. A bad route reaches production and is noticed. The registry is reverted to its previous state rather than the table being edited. The table is recompiled from that state, producing the same digest it had before. It is deployed as a full replacement, and the live digest is compared against the compiled one to confirm. Nothing in this sequence requires anyone to remember what changed, which is the property a hand-edited table cannot offer.bad route livenoticedrevert registrynot the tablerecompilesame digest as beforedeploy --replaceatomicdigests matchconfirmedNo step requires remembering what changed — the registry state is the record

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.