A thin GraphQL-to-REST gateway. Point it at a GraphQL API and it exposes a 1:1 REST surface over every query and mutation field — no configuration, no code generation, no schema annotations.
Built to pair with pdbq, which turns a PostgreSQL database into a GraphQL API. Together they give you a REST API over a database with two containers and no application code.
$ gqlgate serve --upstream.url http://pdbq:8080
time=... msg="planned routes" routes=60 aliases=60 types=139 schema=introspection
time=... msg=listening addr=:8080 upstream=http://pdbq:8080/graphql routes=120$ curl -s localhost:8080/users?first=2 -D-
HTTP/1.1 200 OK
X-Total-Count: 137
X-Has-Next-Page: true
X-End-Cursor: WyJVc2VyIiwgMl0=
[{"id":1,"email":"ada@example.com"},{"id":2,"email":"grace@example.com"}]- Every operation gets an endpoint. Coverage is asserted at boot: if a field somehow reached no route, gqlgate refuses to start rather than silently hiding it.
- RESTish paths, derived from types.
GET /users,GET /users/42,POST /users,PATCH /users/42,DELETE /users/42, plus/users/bulkand/users/by-email/{email}for the operations that do not fit the five. - Naming-independent. Classification is driven by type shape, not field
names, so
allUsersandusers— pdbq's two inflections — produce a byte identical REST surface. Proved by tests against both. - Bare-array collections. The body is the rows; pagination, cursors and totals go in response headers.
- Real REST status codes. A unique violation is
409, a missing row is404, a serialization failure is503withRetry-After. The SQLSTATE mapping is configurable. - A transparent pipe for auth.
Authorizationis forwarded untouched; inboundX-Pdbq-Claim-*headers are always stripped. - Column selection.
?select=id,email,author{name}when the default scalar projection is not what you want. - Hot reload.
SIGHUPor a poll interval rebuilds the route table in place, so aCREATE TABLEupstream becomes a new REST resource without a restart. - OpenAPI out of the box. An OpenAPI 3.1 document at
/openapi.json, generated from the same plan the server routes on, with Scalar bundled in the compose stack. - An escape hatch for everything. Every field also lives at
/q/<field>or/m/<field>, androutes.fieldspins any route explicitly.
$ docker compose up # Postgres + pdbq + gqlgate + Scalar
$ open http://localhost:8082 # browse and call the API
$ curl localhost:8081/users # or use it directlyPorts come from .env (see .env.example); the mappings live in
compose.override.yaml, which is gitignored.
gqlgate generates an OpenAPI 3.1 document from the same plan it routes on, so
the documentation can never describe an endpoint that does not exist. It is
served at /openapi.json and rendered by the bundled
scalarapi/api-reference container.
Scalar's built-in API client works against the live API: the document's server
URL points at gqlgate's published port, and server.cors_origins lets the
UI's origin call the data endpoints.
$ curl localhost:8081/openapi.json | jq '.paths | keys'
$ gqlgate openapi > openapi.json # or generate it without a running gatewayPoint a client generator at it:
$ npx @openapitools/openapi-generator-cli generate \
-i http://localhost:8081/openapi.json -g typescript-fetch -o ./clientOr against an existing GraphQL API:
$ gqlgate config init # writes a starter gqlgate.yaml
$ gqlgate routes # inspect the generated route table first
$ gqlgate serveAlways run gqlgate routes before deploying: it shows exactly which endpoint
maps to which GraphQL field, which is how you catch a heuristic getting
something wrong.
$ gqlgate routes --upstream.url http://localhost:8080
METHOD PATH FIELD CLASS SELECTION
GET /users allUsers collection-get nodes{id,email,...},pageInfo{...},totalCount
GET /users/{id} userById by-id-get id,email,...
GET /users/by-email/{email} userByEmail by-unique-get id,email,...
POST /users createUser create user{id,email,...}
POST /users/bulk createUsers bulk-create users{id,email,...},affectedCount
PATCH /users/{id} updateUserById update-by-key user{id,email,...}
PATCH /users updateUsers bulk-update users{...},affectedCount
DELETE /users/{id} deleteUserById delete-by-key user{...}
DELETE /users deleteUsers bulk-delete users{...},affectedCount
GET /rpc/searchPosts searchPosts rpc id,title,...
GET /q/allUsers allUsers collection-get (alias)| Request | Effect |
|---|---|
GET /users?first=10&after=<cursor> |
Page a collection; cursors come back in headers |
GET /users?orderBy=EMAIL_ASC,ID_DESC |
Sort; enum values are validated against the schema |
GET /users?filter={"id":{"greaterThan":5}} |
Filter; the JSON is passed through to the upstream |
GET /users?select=id,email |
Choose columns |
GET /users?select=id,author{email} |
Reach a relation the default projection omits |
POST /users {"email":"a@b.c"} |
Create; returns 201 and the new row |
POST /users/bulk [{...},{...}] |
Create many; X-Affected-Count in the response |
PATCH /users/42 {"email":"x@y.z"} |
Update one row |
PATCH /users {"filter":{...},"patch":{...}} |
Update many |
DELETE /users/42 |
Delete one row; 204 |
DELETE /users {"filter":{...}} |
Delete many |
POST /rpc/<field> {...} |
Call a custom query or function |
Unrecognised query parameters are rejected with a message listing the valid
ones, because silently ignoring ?limitt=10 is the failure mode that wastes an
afternoon. Set routes.strict_params: false to ignore them instead.
A bulk update or delete with an empty filter is refused: it would rewrite
every row in the table. Set routes.allow_unfiltered_bulk: true if you really
mean it.
Configuration layers defaults → gqlgate.yaml → GQLGATE_* environment →
flags. Flag names are the config paths, so --routes.strict_params and
GQLGATE_ROUTES_STRICT__PARAMS set the same key (a double underscore stands
for a literal _).
upstream:
url: "http://pdbq:8080"
path: "/graphql"
server:
addr: ":8080"
schema:
path: "" # empty introspects the upstream at boot
poll_interval: "0s" # SIGHUP always worksgqlgate config example prints the full annotated reference, generated from
the config structs so it cannot drift. See
docs/configuration.md and
examples/gqlgate.example.yaml.
| Doc | Contents |
|---|---|
| docs/routing.md | How fields become endpoints, and what to do when it guesses wrong |
| docs/configuration.md | Every config key |
| docs/security.md | Header forwarding, claim stripping, deployment posture |
gqlgate has no auth of its own — it forwards headers and lets the upstream decide.
- JWT (pdbq's default):
Authorization: Bearer …passes through untouched. - Trusted-gateway headers: pdbq trusts any
X-Pdbq-Claim-*header it receives, so gqlgate always strips them from inbound requests, with no option to disable it. Otherwise any client could setX-Pdbq-Claim-Roleand assume any database role. - Optional
upstream.inject_claimslets gqlgate supply claims itself, off by default and refused over a public network unless explicitly allowed.
See docs/security.md.
gqlgate is deliberately thin. It maps each request to exactly one GraphQL operation and does nothing clever:
- No caching, batching, or request coalescing.
- No query cost analysis — the upstream's limits apply, and pdbq has them.
- No schema stitching; one upstream per gateway.
- Default selections are scalars one level deep; relations are opt-in through
?select=. - Some naming decisions are heuristics. They are all visible in
gqlgate routesand overridable — see docs/routing.md.
$ make build # build ./bin/gqlgate
$ make test # hermetic unit and handler tests
$ make test-e2e # against a real pdbq in docker
$ make routes # print the route table for the committed fixture
$ make golden # regenerate the golden route tables after a changeMIT — see LICENSE.