Skip to content

Repository files navigation

gqlgate

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"}]

Highlights

  • 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/bulk and /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 allUsers and users — 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 is 404, a serialization failure is 503 with Retry-After. The SQLSTATE mapping is configurable.
  • A transparent pipe for auth. Authorization is forwarded untouched; inbound X-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. SIGHUP or a poll interval rebuilds the route table in place, so a CREATE TABLE upstream 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>, and routes.fields pins any route explicitly.

Quickstart

$ docker compose up                # Postgres + pdbq + gqlgate + Scalar
$ open http://localhost:8082       # browse and call the API
$ curl localhost:8081/users        # or use it directly

Ports come from .env (see .env.example); the mappings live in compose.override.yaml, which is gitignored.

API reference

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 gateway

Point a client generator at it:

$ npx @openapitools/openapi-generator-cli generate \
    -i http://localhost:8081/openapi.json -g typescript-fetch -o ./client

Or against an existing GraphQL API:

$ gqlgate config init        # writes a starter gqlgate.yaml
$ gqlgate routes             # inspect the generated route table first
$ gqlgate serve

Always 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)

Using the API

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

Configuration layers defaults → gqlgate.yamlGQLGATE_* 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 works

gqlgate 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.

Documentation

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

Authentication

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 set X-Pdbq-Claim-Role and assume any database role.
  • Optional upstream.inject_claims lets gqlgate supply claims itself, off by default and refused over a public network unless explicitly allowed.

See docs/security.md.

Limits

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 routes and overridable — see docs/routing.md.

Development

$ 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 change

License

MIT — see LICENSE.

About

A thin GraphQL-to-REST gateway.

Topics

Resources

Security policy

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages