A schema gateway for Go services. Ingest gRPC, OpenAPI, or GraphQL; emit all three as typed client surfaces. Tier-versioned with a CI gate on breaking changes; client teams keep their existing codegen tools.
Two configurations:
gat— embed in a Go binary. Your huma OpenAPI service serves REST as before; gat adds GraphQL and gRPC client surfaces from the same handlers. No second schema, no second process.gwag— out-of-process gateway. The seam when a monolith outgrows its singleton: register multiple services together, clustered via embedded NATS, while pieces migrate out at their own pace.
Start on gat. Graduate to gwag when a piece of the monolith needs to split.
Beyond the lead — the pieces that need their own mechanics paragraph:
-
Tier-based versioning with a CI gate.
unstable/stable/vN; oldervNis auto-@deprecated; CI fails the build on a breaking schema diff. The live registry rebuilds on every register / deregister, so codegen picks up changes without a server restart. The same diff applies to the proto FDS and OpenAPI exports — one breaking change fails the build for every client team that consumes any of the three. Seedocs/lifecycle.md. -
Transforms and middleware. Auth, header injection, quota enforcement, field reshaping — one declaration applied across every protocol edge. ~15–20 µs per active rule on the hot path.
InjectType[T]/InjectPath/InjectHeaderfor the "fill from context, hide from external schema" pattern. -
Typed pub/sub on the embedded NATS. The cluster already runs NATS + JetStream;
ps.pub/ps.subexpose typed multi-listener channels with HMAC auth. No separate broker to run.
Four shapes, same wire surface — pick the one that matches your deployment:
- Standalone gwag — API gateway, aggregator, translator. One binary fronting many services. Runtime registration over the gRPC control plane; HA via embedded NATS; admin UI; subscriptions. Start here if you have a fleet. Jump to Quickstart.
- Embedded gat — in-process GraphQL API translator. One Go
binary serving its own huma operations as
REST + GraphQL + gRPC on one port. No NATS, no cluster, no admin.
Start here if you have one binary and want typed clients without
standing up a separate gateway process. Jump to
Embedded mode (
gat). - MCP exposure — let LLM agents query the gateway directly.
gw.MountMCP(mux)wraps the gateway as four MCP tools (schema_list/schema_search/schema_expand/query) on/mcp. Operator-curated allowlist; AdminMiddleware-gated. Seedocs/mcp.md. - CLI shortcut — typed surfaces + metrics over one upstream.
gwag serve --openapi spec.yaml --to URL,gwag serve --proto file.proto --to HOST:PORT, orgwag serve --graphql URLexposes any single upstream as all three typed surfaces.--graphqlalways routes through the full gateway (metrics, backpressure, subscription proxy);--openapi/--protodefault to the lite gat path and promote to the full gateway when--mcpis set, so/mcpshares dispatch and metrics with every other ingress.
With Docker, from a checkout (the first run builds the image):
bin/gwag deploy up --platform docker-compose # add --replicas 3 for a cluster
bin/gwag deploy login --platform docker-compose # saves the admin token to .gw/
curl -s -X POST -H 'content-type: application/json' localhost:8080/api/graphql \
-d '{"query":"{ __schema { queryType { name } } }"}'bin/gwag services list and bin/gwag peer list now work. Without Docker,
bin/gwag up runs one node as a process. Kubernetes, minikube and ECS
files, replica rules and what is untested: docs/deploy.md.
One registration, three typed-client surfaces, off one live registry. Clone and run the multi-service example:
git clone https://github.com/iodesystems/gwag && cd gwag
cd examples/multi && ./run.sh # gateway + greeter + library (both proto)One consolidated GraphQL surface for the browser:
curl -s -X POST localhost:8080/api/graphql \
-d '{"query":"{ greeter { hello(name:\"world\") { greeting } } }"}'
# → {"data":{"greeter":{"hello":{"greeting":"Hello, world!"}}}}Same greeter service, registered once as a .proto, re-emitted
as three typed-client surfaces. Pick whichever codegen tool your
client team already uses:
# GraphQL SDL → graphql-codegen (TS / React / Apollo Client / urql / …)
curl 'localhost:8080/api/schema/graphql?service=greeter' > greeter.graphql
# proto FileDescriptorSet (binary) → protoc / ts-proto / grpc-python / grpc-go / grpc-java / …
curl 'localhost:8080/api/schema/proto?service=greeter' > greeter.fds
protoc --descriptor_set_in=greeter.fds --go_out=gen --go-grpc_out=gen greeter.proto
# OpenAPI 3.x → openapi-typescript, openapi-generator (40+ language targets).
# Synthesized for proto-origin services: the gateway round-trips the
# IR through all three formats, so OpenAPI consumers get a valid spec
# even when the upstream speaks gRPC. The response is
# {"<ns>": {"<version>": <spec>}}; unwrap one spec before generating.
curl 'localhost:8080/api/schema/openapi?service=greeter' |
node -e 'const w=JSON.parse(require("fs").readFileSync(0,"utf8"));
process.stdout.write(JSON.stringify(Object.values(w)[0].v1))' > greeter.json
pnpm exec openapi-typescript greeter.json -o greeter.tsOne registration; three client ecosystems; no duplicated schema.
Edit or add a service — the gateway updates over the control plane
without a restart. The next pnpm run gen / protoc run picks it
up.
Worked walk-through (three services, three client languages,
edit-redeploy-codegen cycle): docs/walkthrough.md.
Runnable Go and TypeScript clients: examples/clients/,
docs/clients.md.
gw := gateway.New()
gw.AddProto("./protos/auth.proto", gateway.To("authsvc:50051"))
gw.AddProto("./protos/user.proto", gateway.To("usersvc:50051"))
gw.AddOpenAPI("./billing-openapi.json",
gateway.As("billing"),
gateway.To("https://billing.internal"))
http.ListenAndServe(":8080", gw.Handler())Services can also self-register over the gRPC control plane — no
gateway restart, no static config edit. Same /api/schema/*
endpoints reflect it immediately.
One Go binary or library import. Cluster is opt-in. Default dispatch is reflection-based — any registered service works without a build step; codegen and plugin paths layer on for extra throughput.
Per-request overhead at 1 k rps × 15 s, loopback (gateway adds on top
of a direct dial in the matching wire format; measured May 2026,
not regenerated by bin/bench perf):
| Ingress | Source | Δp50 | Δp95 |
|---|---|---|---|
| gRPC | proto upstream | +283 µs | +336 µs |
| HTTP/JSON | OpenAPI upstream | +208 µs | +245 µs |
| GraphQL | GraphQL upstream | +344 µs | +505 µs |
Each active middleware rule on the hot path adds ~15–20 µs at p50.
Full numbers + reproduce recipe: docs/perf.md.
Head-to-head vs Hive Gateway, graphql-mesh, Apollo Router, Cosmo and
Grafbase on the same backends:
compare/comparison.md (harness + reproduce
recipe: compare/).
Embedded (gat), in-process — same huma handler, same 25-row
payload, each surface against the tool you'd otherwise hand-write it
with (no network; ratios travel, absolutes don't):
| Surface | gat | hand-written | Ratio |
|---|---|---|---|
| REST | 15.4µs (huma, unmodified) | 49.8µs (grpc-gateway) | 0.31× |
| gRPC | 16.9µs (binary codec) | 13.1µs (connect-go) | 1.29× |
| GraphQL | 102.0µs | 142.3µs (gqlgen) | 0.72× |
gat's REST path is huma — gat.Register wraps huma.Register
and adds nothing to it. GraphQL comes out ahead of gqlgen: the
executor walks a cached plan and appends response JSON straight
into a pooled buffer, where gqlgen assembles an intermediate
first. gRPC is the surface translation costs — gat allocates a
dynamicpb message per element and walks it by reflection, where
connect-go has generated structs. The row shows the binary
codec; the ratio moves with the caller's wire format and the
host. Per-caller numbers and method:
compare/gatbench/.
gat (GraphQL API Translator) is gwag's in-process variant
for the single-binary case. GraphQL + gRPC typed surfaces on top
of a huma service, no NATS, no cluster.
REST + GraphQL + gRPC on one port:
import "github.com/iodesystems/gwag/v2/gw/gat"
g, _ := gat.New()
gat.Register(api, g, huma.Operation{ /* ... */ }, listProjects) // drop-in for huma.Register
gat.Register(api, g, huma.Operation{ /* ... */ }, getProject)
gat.RegisterHuma(api, g, "/api") // /api/graphql + /api/schema/*
gat.RegisterGRPC(mux, g, "/api/grpc") // connect-go handlersUI consumers use graphql-codegen off /api/schema/graphql;
service-to-service clients use protoc / buf / ts-proto off
/api/schema/proto; legacy REST integrations stay on huma's own
/openapi.json. No second schema, no second server, no extra
ports.
⚠ One trap worth knowing before you write your first input struct: huma skips unexported fields when it discovers request parameters, and an embedded field takes its name from its type — so embedding an unexported type (
scope) silently drops everyquery/path/header/cookieparameter inside it. Nothing errors; the handler just reads zero values and the OpenAPI document omits the parameter. Export the type (Scope) and it works. gat refuses to mount and names each lost parameter; plain huma gives you no warning at all. Details:docs/gat.md— Trap: parameters on an unexported field.
Concept doc: docs/gat.md. Runnable end-to-end
demo with React + Vite + graphql-codegen: examples/gat/.
LLM agents speak Model Context Protocol. gw.MountMCP(mux) exposes
the gateway as four tools (schema_list / schema_search /
schema_expand / query) on /mcp, bearer-gated, backed by the
same in-process executor every other ingress hits. Seed which
operations agents see at construction:
gw := gateway.New(gateway.WithMCPInclude("greeter.**", "library.**"))
gw.MountMCP(mux)Full surface — tool shapes, allowlist semantics, cluster behavior,
runtime control: docs/mcp.md. Worked example
with a client driver: examples/multi/cmd/mcp-demo.
- Apollo Federation — entity-merging across services that share
entity identity. gwag stitches by namespace; if you actually need
entity-merging, use Federation. See
docs/federation.md. - Hasura — wraps databases. gwag wraps services. Same shape, opposite end of the stack.
- Kong / Envoy / service meshes — route bytes; don't read schemas or emit clients.
- Hive Gateway / graphql-mesh — closest peers on multi-format
ingest. Apollo Router / Cosmo / Grafbase (single-subgraph mode)
— GraphQL-in, GraphQL-out only. Head-to-head numbers:
compare/comparison.md. - gqlgen — Go GraphQL
server framework. You write SDL, gqlgen generates resolver stubs,
you implement them. Different layer: gqlgen builds one Go service's
GraphQL surface; gwag composes existing services and runs on top
of graphql-go. Directive support is narrow —
@deprecatedonly; the runtime side of cross-cutting concerns lives in transforms and providers. Seedocs/directives.md.
Deeper breakdown: docs/comparison.md.
Throughput ceiling is the GraphQL executor itself. The gateway uses a graphql-go fork with plan-cache and subscription primitives; an append-mode executor that emits JSON straight to a buffer is the next perf lever (in flight, not gating any release).
Self-measurement: docs/perf.md. Head-to-head vs
peers: compare/comparison.md (harness:
compare/).
Operations — wiring the gateway into a real deployment:
docs/lifecycle.md— register / version / deprecate / retire; tier model; CI gatedocs/directives.md—@deprecatedrendering; rationale for not carrying custom directivesdocs/operations.md— health, drain, backpressure, metricsdocs/tracing.md— OpenTelemetry wiring + span referencedocs/admin-auth.md— admin boot token + AdminAuthorizer delegate + outbound HTTP transportdocs/caller-identity.md— per-caller ID extractor + quotadocs/services.md— registering a service:controlclient, versions, start gate, drain; in-processAddProto/AddOpenAPI/AddGraphQL/AddMCPdocs/cluster.md— 3-node setup, load balancer probe, node loss, rolling upgrade, forgetting a peerdocs/middleware.md—Transform/InjectType/InjectPath/InjectHeaderdocs/registration-clients.md— TypeScript and Python registration clients (announce, wait for ok, heartbeat, deregister); control-plane schema served by the Schema Registrydocs/clients.md— proto → Go and OpenAPI → TypeScript clients from the schema exports; regenerate loop; pointer togwag genfor event codedocs/mcp.md— exposing the gateway to LLM agents (tools, allowlist, mounting, cluster semantics)
Performance:
docs/perf.md— throughput sweep on your hardwaredocs/comparison.md— gwag vs service discovery / mesh / Kong / Federationcompare/comparison.md— head-to-head numbers vs gat, Hive Gateway, graphql-mesh, Apollo Router, Cosmo, Grafbasecompare/gatbench/— whatgatcosts vs gqlgen / connect-go / grpc-gatewaycompare/— both harnesses, with the headline matrices inline
Pub/Sub & subscriptions — ps.pub / ps.sub primitives with
per-pattern auth (ChannelAuthOpen / HMAC / Delegate) and a
channel→type binding registry. Service-declared stream Resp
methods stay as per-subscriber gRPC streams. See
docs/pubsub.md.
Typed event channels — gwag gen writes typed publish / listen
code for Go, TypeScript and Python from proto annotations, over NATS
JetStream, memory or Kafka. See docs/events.md; Kafka and Schema Registry in docs/events-kafka.md.
Stability + release:
docs/stability.md— SemVer contractCHANGELOG.md— public-surface deltaRELEASE.md— cut a release
Maintainer:
docs/architecture.md— codebase layout, design notes, HTTP routing surface
v1 surface is locked in. SemVer contract:
docs/stability.md. Public-surface delta:
CHANGELOG.md.
Pending rename of wire-level identifiers:
- Prometheus metrics. Prefix is
go_api_gateway_*by default;WithMetricPrefix("gwag")opts in togwag_*now (v2.5). v3 makesgwag_*the default. Proto packages staygateway.*(exceptgwag.ps.v1); unchanged. - GraphQL error codes. Since v2.11,
extensionsis gat's{status, code: "not_found"}by default.WithLegacyErrorCodes()keeps the upper-caseCodename (NOT_FOUND) until v3 removes it.
After 1.0:
- Append-mode executor wiring. The graphql-go fork
exposes
ExecutePlanAppend; gateway-side swap is a single-function change projected at ~3-4× end-to-end on the hot path. - Static codegen + plugin supervisor. Opt-in native-speed dispatch on top of reflection. Layers on; default stays reflection.
Open to (pulled in by a real use case):
- AddMCP ingest — register a downstream MCP server as a fourth
ingest kind. Each upstream tool becomes an IR operation, surfacing
through GraphQL / proto / OpenAPI clients alongside everything
else. Two frictions to size first: most MCP tools don't ship an
outputSchema(falls back to aJSONscalar passthrough), andtools/listChangednotifications need a refresh story. - WSDL / SOAP ingest. Corporate legacy services that can't be rewritten.
- Service-account / OAuth-JWT outbound auth helpers. Composable today; first-class when an adopter pulls.
Not planned: Apollo Federation entity-merging (stitching covers the common case); AsyncAPI export.
MIT. See LICENSE.
