gqlgate turns every root field of the target schema into an HTTP endpoint. This document explains how it decides, and what to do when it decides wrong.
Run gqlgate routes to see the answer for your own schema. Every decision
described here is visible in that table.
Classification is driven by type shape, not field names. A field returning
a Relay connection is a collection whether it is called allUsers or users;
a mutation returning a payload with clientMutationId is CRUD whether it is
called updateUserById or updateUser. That is why one route table serves
pdbq's default inflector and its simple-names plugin identically.
Path segments come from the GraphQL type name, not the field name:
User → /users, OrderLineItem → /order-line-items.
| Class | Method and path | Recognised by |
|---|---|---|
collection-get |
GET /users |
Returns a type with nodes and pageInfo |
by-id-get |
GET /users/{id} |
Returns a row type; all arguments are required scalars; key elected as the primary key |
by-unique-get |
GET /users/by-email/{email} |
The same, for a non-primary key |
node-get |
GET /node/{nodeId} |
A single nodeId: ID! argument, or id: ID! when the field returns an interface |
create |
POST /users |
Payload row is not a list; one input-object argument |
bulk-create |
POST /users/bulk |
Payload row is a list; the input argument is a list |
upsert-by-key |
PUT /users |
Shaped like a create, but the field name starts with upsert |
update-by-key |
PATCH /users/{id} |
Required scalar keys plus a patch argument |
bulk-update |
PATCH /users |
A filter argument plus a patch argument |
delete-by-key |
DELETE /users/{id} |
Required scalar keys, no patch, single-row payload |
bulk-delete |
DELETE /users |
A filter argument, no patch, list payload |
rpc |
GET/POST /rpc/<field> |
Everything else |
Rule order matters: the bulk rules run before the by-key rules, because a bulk
update carries both filter and patch and would otherwise be caught by the
"has a patch argument" test.
A type is a resource if some connection lists it. That single rule keeps function fields off resource paths.
pdbq generates functions that are shape-identical to CRUD:
createPost(input: PostCreateInput!): CreatePostPayload { post: Post }
publishPost(input: PublishPostInput!): PublishPostPayload { result: Post }Both take one input object and return a payload whose row is a Post. The
difference is the payload's row field name: generated CRUD names it after
the row type (post), while a function payload uses a generic name
(result). Since that name comes from the inflector rather than from the
operation, comparing it to the type name is a structural test.
Without it, publishPost would claim POST /posts and collide with the real
create — demoting both to their flat aliases.
PostGraphile and other Relay-classic servers wrap every mutation argument in a
single input object that carries clientMutationId alongside the real
arguments:
createPlace(input: { clientMutationId, place: PlaceInput! }): CreatePlacePayload
updatePlaceById(input: { clientMutationId, id: ID!, patch: ... }): UpdatePlacePayload
deletePlaceById(input: { clientMutationId, id: ID! }): DeletePlacePayloadWhen a mutation's only argument is such an envelope, classification looks
inside it: exactly one nested input object named after the row is a create;
required leaf keys plus one nested object is an update; keys alone are a
delete. Because a custom mutation can imitate all three shapes
(approvePlace(input: {placeId}) is delete-shaped), the field name must also
carry the matching verb prefix — routes.create_prefixes, update_prefixes
and delete_prefixes, defaulting to create/update/delete. A server that
spells creation addPlace needs create_prefixes: [add].
The gateway then declares one GraphQL variable per wrapped field and rebuilds
the envelope inline in the document, so the REST surface is identical to the
flat style: keys come from the path, the body is the row or the patch, and
clientMutationId is never sent. Payload back-links (query: Query) and
edge fields are recognised structurally and skipped when electing the
payload's row.
pdbq-style inputs also carry clientMutationId, but inside the row input
next to real columns; those match none of the envelope shapes and fall through
to the flat rules unchanged.
Every field also gets a heuristic-free address:
GET /q/<field> for queries
POST /m/<field> for mutations
This is what makes the 1:1 guarantee unconditional. If a heuristic gets
something wrong, the operation degrades to an ugly URL rather than becoming
unreachable. Set routes.flat_aliases: false to hide them — fields demoted by
a collision keep theirs regardless, since otherwise they would vanish.
At boot, gqlgate asserts every field is reachable by at least one route and refuses to start otherwise.
When two fields want the same method and path, both are demoted to their flat aliases and a warning is logged naming both. Keeping one would make the winner depend on ordering; a silently shadowed operation is worse than two ugly URLs.
To give one of them the pretty path back, pin it:
routes:
fields:
allUsers: "GET /users"The default selection is every argument-less scalar or enum field of the row type — one level deep, no relations. Relations are skipped deliberately: expanding one would silently multiply the upstream's cost.
- Collections select
nodes,pageInfoandtotalCount.edgesis never requested: it duplicatesnodesand doubles the response. - Payloads select the row and
affectedCount.clientMutationIdis never requested.
Clients override with ?select=:
?select=id,email
?select=id,author{name,email}
The expression names the row's columns; gqlgate wraps them in nodes or
the payload row field itself. Naming the wrapper explicitly opts out of
wrapping, so the full shape stays reachable.
Selections are validated against the schema before any upstream call, with
depth and field-count caps (routes.max_select_depth,
routes.max_select_fields). gqlgate deliberately does not do cost analysis —
the upstream already enforces its own limits.
Three decisions are heuristics. All are visible in gqlgate routes before you
deploy, and all are overridable.
Introspection carries no primary-key information. gqlgate elects one:
- A getter whose only argument is
idwins. - Otherwise, a resource with exactly one single-row getter uses it.
- Otherwise, the key tuple shared with the shortest-named update or delete
mutation wins —
simple-namesshortens only the primary-key variant (updateUser) and leaves unique-constraint ones verbose (updateUserByEmail), so the shortest name really does identify it.
A table with a composite primary key and a unique column can go either way. Pin it:
routes:
fields:
userByOrgIdAndSlug: "GET /users/{orgId}/{slug}"These are genuinely indistinguishable by shape — both are
(input: XCreateInput!): XPayload!. The field-name prefix is the only signal:
routes:
upsert_prefixes: ["upsert", "putOrCreate"]pdbq's inflector is "enough for common table names", and gqlgate re-pluralizes
a name pdbq already singularized. spatial_ref_sys becomes
/spatial-ref-sies; Series, Data and Analysis will also be wrong.
Expect to need this override. It is the most common one:
routes:
resources:
SpatialRefSy: "spatial-ref-sys"
Series: "series"routes:
ignore: ["internalOnlyField"] # never exposed
param_aliases: {limit: first} # ?limit=10 means ?first=10
strict_params: true # reject unknown query parameters
allow_unfiltered_bulk: false # refuse table-wide updates and deletes
delete_returns_row: false # 204 rather than 200 plus the row
default_page_size: 0 # applied when the client sends noneThe route table is rebuilt on SIGHUP, and on schema.poll_interval when
introspecting. A reload builds an entirely new plan and mux and swaps a
pointer, so in-flight requests keep using the snapshot they started with. If
the reload fails, the running table is kept — serving stale routes beats
serving none.
$ kill -HUP $(pidof gqlgate)Reload is skipped when the resulting surface is identical, so an unchanged schema does not churn the mux.