An OpenAPI gateway spec you never edit by hand
Per-service path fragments, a small Node generator that enforces the rules humans forget, and a CI check that keeps the API Gateway config honest.
Every Google Cloud API Gateway I have inherited had the same artefact at its centre: one enormous OpenAPI document, a few thousand lines of JSON or YAML, edited by whoever last needed a route. It works for a while. Then someone copies an operation block, forgets its security section, and a route that is supposed to know who the caller is starts treating everyone as anonymous. Or someone adds a backend override on a single operation, the gateway quietly switches that operation to a different path-translation mode, and /users/{userId} starts arriving at the service as /?userId=42. Neither mistake fails a deploy. Both show up as user-facing bugs, days later, in code that nobody touched.
The fix I have settled on, after running this setup on a large-scale social platform I'm building, is to stop treating the gateway spec as a source file. Humans write small per-service fragments. A generator of about a hundred lines merges them, injects every piece of gateway plumbing, and refuses to produce output when a fragment breaks a rule. The merged spec is committed, CI fails if it is stale, and the deploy pipeline is the only thing that ever turns it into a gateway config. This article walks through that setup end to end, including the traps that motivated each rule.
The constraints#
These are the conditions the design has to survive:
- Several Cloud Run services behind one public hostname. Clients see a single API; each path belongs to exactly one backend.
- Authentication at the edge. The gateway validates Firebase ID tokens (any OIDC issuer works the same way) so services do not each re-implement JWT verification.
- Many small, frequent route changes. Adding an endpoint should be a local edit next to the service that owns it, not a merge conflict in a shared file.
- Staging and production differ only in data. Backend URLs, token issuer and hostname change per environment; route definitions must not.
- Rules that nobody has to remember. Every operation declares its auth. Path parameters actually reach the service. Operation IDs are unique.
- Cheap to operate. A gateway sits on every request, so its pricing and its failure modes matter more than its feature list.
How API Gateway consumes a spec#
API Gateway has three resources. An API is a named container. An API config is an uploaded spec attached to that API, and the documentation is explicit that you cannot modify a config after creating it apart from its labels and display name. A gateway is a regional deployment of the managed Envoy-based proxy (ESPv2) that hosts exactly one config at a time. Shipping a route change therefore always means: create a new config, then point the gateway at it.
For most of the product's life the spec had to be OpenAPI 2.0, the format formerly known as Swagger. The release notes record OpenAPI 3.0 support reaching general availability in November 2025, with a different extension layout built around x-google-api-management. Everything below uses 2.0, because that is what most existing gateways run and because the fragment-and-generator approach does not care which version it emits. If you are starting fresh, the generator's output stage is the only part you would change.
Google-specific behaviour lives in vendor extensions. The two that matter here are x-google-backend, which says where an operation is forwarded, and securityDefinitions entries carrying x-google-issuer, x-google-jwks_uri and x-google-audiences, which say how to validate tokens.
Options for keeping the spec manageable#
| Approach | How it works | Weakness | Verdict |
|---|---|---|---|
| One hand-edited spec | Everyone edits the same file | Merge conflicts, copy-paste errors, no enforced rules | Fine for five routes, painful at fifty |
Several files passed to --openapi-spec | The flag accepts a list of files | In practice each file still repeats the host, extensions and security definitions, so duplication moves rather than disappears, and nothing checks rules across files | Splits the file, not the problem |
| Generate from service code | Each service exports its schema (Fastify, NestJS decorators) and a tool stitches them | Couples the gateway to every service's build; route metadata and gateway metadata get mixed | Attractive if you already publish schemas per service |
Terraform templatefile | Spec templated inside infrastructure code | Routes change daily, infrastructure should not; review happens in the wrong place | Good for the gateway resource, poor for routes |
| Fragments plus a generator | Small JSON fragments per service, merged and validated by a script | One more script to own | My choice |
The deciding factor is where the rules live. In every other row, the rule "every operation declares security explicitly" exists only in a review checklist. With a generator, it exists in code that runs on every commit.
The decision#
The layout I use is a gateway/ directory with four parts:
paths/<service>/*.jsonholds fragments. Each fragment names its service and lists plain OpenAPI 2.0 path items: parameters, responses,operationId,security. No gateway plumbing.envs/<env>.jsonholds per-environment data: the managed service hostname, the Firebase project and a map of service names to Cloud Run URLs.scripts/generate-spec.mjsmerges fragments, injectsx-google-backendand security definitions, validates, and writes one spec per environment.dist/openapi.<env>.jsonis the output. It is committed so that reviewers see the effective diff, and so that deploys are reproducible from a commit without running anything.
The key rule: nobody edits dist/ by hand, and CI enforces that by regenerating and comparing.
Implementation#
A fragment#
A fragment is deliberately boring. It has no backend URL, no issuer, and nothing environment-specific:
{
"service": "users",
"paths": {
"/v1/users/{userId}": {
"get": {
"operationId": "getUser",
"security": [{ "firebase": [] }],
"parameters": [
{ "name": "userId", "in": "path", "required": true, "type": "string" }
],
"responses": { "200": { "description": "The user profile" } }
}
},
"/v1/public/users/{userId}/card": {
"get": {
"operationId": "getPublicUserCard",
"security": [],
"parameters": [
{ "name": "userId", "in": "path", "required": true, "type": "string" }
],
"responses": { "200": { "description": "Public card, no auth" } }
}
}
}
}Note "security": [] on the public route. In OpenAPI 2.0 an empty array means "no authentication", and omitting the key means "inherit the top-level default". The generator forbids omission, so every operation states its intent.
The environment file#
{
"host": "public-api-0a1b2c3d4e5f6.apigateway.my-app-staging.cloud.goog",
"firebaseProject": "my-app-staging",
"backends": {
"users": "https://users-api-abc123-ew.a.run.app",
"media": "https://media-api-abc123-ew.a.run.app"
}
}The host value is the API's managed service name, which gcloud api-gateway apis describe API_ID prints in its managedService field. It matters for CORS, as we will see.
The generator#
This is the whole script. It runs on Node 22 with no dependencies:
import { readFileSync, readdirSync, statSync, writeFileSync, mkdirSync } from "node:fs";
import { dirname, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const ENVS = ["staging", "prod"];
const METHODS = new Set(["get", "put", "post", "delete", "options", "head", "patch"]);
function readJson(file) {
try {
return JSON.parse(readFileSync(file, "utf8"));
} catch (err) {
throw new Error(`${relative(root, file)}: ${err.message}`);
}
}
function* jsonFiles(dir) {
for (const name of readdirSync(dir).sort()) {
const p = join(dir, name);
if (statSync(p).isDirectory()) yield* jsonFiles(p);
else if (name.endsWith(".json")) yield p;
}
}
function build(envName) {
const env = readJson(join(root, "envs", `${envName}.json`));
const errors = [];
const paths = {};
const opIds = new Map();
for (const file of jsonFiles(join(root, "paths"))) {
const where = relative(root, file);
const frag = readJson(file);
const address = env.backends[frag.service];
if (!address) {
errors.push(`${where}: service "${frag.service}" has no backend in envs/${envName}.json`);
continue;
}
for (const [path, item] of Object.entries(frag.paths ?? {})) {
const label = (m) => `${where}: ${m.toUpperCase()} ${path}`;
if (/[^/]\{|\}[^/]/.test(path)) errors.push(`${where}: ${path} uses a partial-segment template`);
const templated = [...path.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]);
paths[path] ??= {};
for (const [method, op] of Object.entries(item)) {
if (!METHODS.has(method)) { errors.push(`${where}: ${path} has unknown key "${method}"`); continue; }
if (paths[path][method]) errors.push(`${label(method)} is defined twice`);
if (!Array.isArray(op.security)) errors.push(`${label(method)} must declare security ([] for public)`);
if (op["x-google-backend"]) errors.push(`${label(method)} sets x-google-backend by hand`);
if (!op.operationId) errors.push(`${label(method)} has no operationId`);
else if (opIds.has(op.operationId)) errors.push(`${label(method)} reuses operationId from ${opIds.get(op.operationId)}`);
else opIds.set(op.operationId, where);
const declared = new Set((op.parameters ?? []).filter((p) => p.in === "path").map((p) => p.name));
for (const name of templated) {
if (!declared.has(name)) errors.push(`${label(method)} does not declare path parameter "${name}"`);
}
paths[path][method] = {
...op,
"x-google-backend": { address, path_translation: "APPEND_PATH_TO_ADDRESS" },
};
}
}
}
if (errors.length) throw new Error(`Spec for ${envName} is invalid:\n ${errors.join("\n ")}`);
return {
swagger: "2.0",
info: {
title: "public-api",
version: "1.0.0",
description: "GENERATED by gateway/scripts/generate-spec.mjs. Edit gateway/paths/ instead.",
},
host: env.host,
"x-google-endpoints": [{ name: env.host, allowCors: true }],
schemes: ["https"],
produces: ["application/json"],
securityDefinitions: {
firebase: {
authorizationUrl: "",
flow: "implicit",
type: "oauth2",
"x-google-issuer": `https://securetoken.google.com/${env.firebaseProject}`,
"x-google-jwks_uri":
"https://www.googleapis.com/service_accounts/v1/metadata/x509/securetoken@system.gserviceaccount.com",
"x-google-audiences": env.firebaseProject,
},
},
paths: Object.fromEntries(Object.keys(paths).sort().map((p) => [p, paths[p]])),
};
}
const check = process.argv.includes("--check");
let stale = false;
for (const envName of ENVS) {
const out = join(root, "dist", `openapi.${envName}.json`);
const next = `${JSON.stringify(build(envName), null, 2)}\n`;
if (check) {
let current = "";
try { current = readFileSync(out, "utf8"); } catch { /* missing counts as stale */ }
if (current !== next) {
console.error(`${relative(root, out)} is stale. Run: node gateway/scripts/generate-spec.mjs`);
stale = true;
}
} else {
mkdirSync(dirname(out), { recursive: true });
writeFileSync(out, next);
}
}
if (stale) process.exit(1);A few choices in there deserve an explanation.
path_translation is always explicit. The extension reference states that when x-google-backend sits at the top level, path_translation defaults to APPEND_PATH_TO_ADDRESS, and when it sits on an operation, it defaults to CONSTANT_ADDRESS. With a constant address, the request goes to exactly the configured URL, and any path parameters are turned into query parameters: a request for /hello/world against the template /hello/{name} arrives as ?name=world. A Cloud Run service that routes on /v1/users/:userId then returns 404 for every call. Because the generator puts the backend on each operation (one spec, many services), it must set APPEND_PATH_TO_ADDRESS every time. Writing it once in code means nobody can forget it in a fragment.
Security definitions come from the environment. The Firebase block matches the one in the Firebase authentication guide; only the project ID changes between environments. The same shape works for any OIDC issuer with a JWKS endpoint.
Output is deterministic. Files are read in sorted order and paths are sorted before writing, so the same inputs always produce byte-identical output. That is what makes the --check comparison meaningful. On Windows machines, add gateway/dist/*.json text eol=lf to .gitattributes, or a checkout with automatic CRLF conversion will look stale forever.
Errors are collected, not thrown one at a time. A developer who breaks three rules gets three lines, not three round trips.
What the backend receives#
Behind the gateway, a service should not verify the token again, and it cannot easily do so anyway. When x-google-backend sets an address, the gateway overrides the original Authorization header with its own ID token for the backend and moves the client's value to X-Forwarded-Authorization. The validated result arrives in X-Apigateway-Api-Userinfo, a base64url-encoded copy of the JWT payload:
import type { FastifyRequest } from "fastify";
export interface GatewayUser {
sub: string;
email?: string;
}
export function gatewayUser(req: FastifyRequest): GatewayUser | null {
const raw = req.headers["x-apigateway-api-userinfo"];
if (typeof raw !== "string" || raw === "") return null;
const claims = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
return typeof claims.sub === "string" ? { sub: claims.sub, email: claims.email } : null;
}Trusting this header is only safe if nothing except the gateway can reach the service. Deploy each Cloud Run service with authentication required and grant roles/run.invoker to the gateway's service account alone. Otherwise anyone who discovers the run.app URL can send a forged header. The least-privilege article covers how I structure those service accounts.
Granting the gateway access to Cloud Run#
The gateway calls backends as the service account you pass when creating the config. Give that account invoker on each service it fronts, and nothing else:
gcloud iam service-accounts create gateway-invoker --project=my-app-staging
for svc in users-api media-api; do
gcloud run services add-iam-policy-binding "$svc" \
--region=europe-west1 --project=my-app-staging \
--member="serviceAccount:gateway-invoker@my-app-staging.iam.gserviceaccount.com" \
--role="roles/run.invoker"
doneThe ID token the gateway mints uses the backend address as its audience by default, which is what Cloud Run expects. You only need jwt_audience when the address and the expected audience differ, for example when the address points at a custom domain in front of the service.
The CI check#
The check is one step, and it runs on every pull request that touches gateway/:
name: gateway
on:
pull_request:
paths: ["gateway/**"]
jobs:
spec-up-to-date:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: node gateway/scripts/generate-spec.mjs --checkA fragment that breaks a rule fails here with a readable message. A fragment that is valid but whose output was not regenerated also fails, which forces the effective spec diff into the pull request where a reviewer can see it.
Deploying a new config#
Because configs are immutable, each deploy creates one with a unique ID and then points the gateway at it. Config IDs are limited to 63 lowercase letters, digits and dashes, so a timestamp plus a short commit SHA fits comfortably:
#!/usr/bin/env bash
set -euo pipefail
ENV="${1:?usage: deploy.sh staging|prod}"
PROJECT="my-app-${ENV}"
CONFIG_ID="cfg-$(date -u +%Y%m%d-%H%M%S)-${GITHUB_SHA:0:7}"
gcloud api-gateway api-configs create "$CONFIG_ID" \
--api=public-api --project="$PROJECT" \
--openapi-spec="gateway/dist/openapi.${ENV}.json" \
--backend-auth-service-account="gateway-invoker@${PROJECT}.iam.gserviceaccount.com"
gcloud api-gateway gateways update public-gw \
--api=public-api --api-config="$CONFIG_ID" \
--location=europe-west1 --project="$PROJECT"The deploy job authenticates with Workload Identity Federation, so no key file exists. According to the config creation guide, creating a config can take several minutes, and up to ten for a complex one, so give the job a generous timeout. The gateway update itself follows a zero-downtime model, with the caveat that some requests may still be handled by the previous config while the new one rolls out.
At the time of writing, API Gateway pricing is per API call per billing account: the first 2 million calls a month are free, then $3.00 per million up to 1 billion, then $1.50 per million. As an illustrative example, 100 million calls a month would cost roughly $294 on top of Cloud Run. Two things move that number. CORS preflights pass through the gateway as calls in their own right, so a browser client can nearly double the count for non-simple requests; an Access-Control-Max-Age header on preflight responses lets browsers cache them. And chatty clients that poll cost the same per call as meaningful ones, so batch endpoints pay for themselves quickly at this layer.
Traps and failure modes#
Operation-level backends and path parameters. This is the trap described above, and the reason the generator writes path_translation itself. If you ever see a Cloud Run service receiving requests on its root path with unexpected query strings, check this first.
CORS preflight. Browsers send an OPTIONS preflight with no Authorization header and no API key. With allowCors: true in x-google-endpoints, the gateway passes CORS requests through to your backend, which must answer them. The name in that extension has to match the API's managed service name, which is why host lives in the environment file. In my experience, the painful failures come from anything that makes the gateway reject the preflight before it reaches the backend, such as a hand-written OPTIONS operation that inherits an auth requirement or a per-operation quota. The rejection carries no CORS headers, so the browser reports a CORS error and hides the real status. If you add explicit OPTIONS operations, give them "security": [].
A missing security block. Without an auth requirement on an operation, the gateway forwards the request without validating anything and without the userinfo header. A handler that treats "no user" as "anonymous" then serves everyone as logged out, or worse, a handler that expected a user fails in some odd way. This is the failure that made me make explicit security a hard generator rule rather than a convention.
Partial path segments. A template such as /files/{name}.json is not supported; only whole segments are. The generator's regex catches it before the upload does.
Deadlines. The default backend deadline is 15 seconds. Upload or export endpoints that legitimately take longer need a deadline in x-google-backend, which non-streaming gateways cap at 600 seconds. If you need this, let fragments set an allow-listed deadline field and have the generator copy it across.
Config accumulation. Every deploy leaves a config behind. They are harmless but clutter listings; a scheduled job that deletes all but the last twenty (never the one in use) keeps things tidy.
Spec valid, backend wrong. The generator checks shape, not behaviour. A route pointed at the wrong service still passes. A post-deploy smoke test that calls one public and one authenticated route per service catches most of these within a minute.
Rolling out breaking changes. During a config rollout, old and new configs briefly serve side by side, and old mobile clients keep calling old routes for months. Keep route changes additive, add the new path, migrate clients, then remove the old one in a later deploy.
Checklist
- The merged gateway spec is generated from per-service fragments and never edited by hand.
- Fragments contain only route definitions; backend URLs, issuers and hostnames live in per-environment files.
- The generator injects
x-google-backendwith an explicitpath_translation: APPEND_PATH_TO_ADDRESSon every operation. - Every operation declares
securityexplicitly, with[]for public routes. - Operation IDs are unique, path parameters are declared, and partial-segment templates are rejected.
- Output is deterministic and committed; CI fails when it is stale.
- Deploys create a new config with a unique ID, then update the gateway, authenticating through Workload Identity Federation.
- Backends require authentication and grant
roles/run.invokeronly to the gateway's service account. - Services read identity from
X-Apigateway-Api-Userinfoand never re-verify the client token. - Preflight responses set
Access-Control-Max-Age, and noOPTIONSoperation requires auth. - A smoke test runs after each gateway update.
When not to do this#
If your API is one Cloud Run service, you probably do not need API Gateway at all: the service can verify tokens itself, and a load balancer with a serverless network endpoint group gives you a custom domain and Cloud Armor if you need them. If every service already publishes its own OpenAPI document from code, generating the gateway spec from those documents is a better source of truth than hand-written fragments. And if you need request transformation, rate limiting per consumer or a developer portal, API Gateway is the wrong layer and Apigee or a self-managed Envoy is worth its extra cost. The generator pattern still applies there, since every one of them consumes a spec that is better built than typed.
Related articles
All articles →A dead-letter queue turns a stuck message into a ticket instead of an outage. How I set one up on Pub/Sub, alert on it, and replay safely after a fix.
15 min
Pub/Sub will deliver some messages twice, and that is by design. Here is how to build consumers whose side effects happen once anyway, with working code.
17 min
Every upstream call spends money and quota. A layered defence of validation, rate limits, caching, single-flight and leases keeps both under control.
19 min