Skip to content

Latest commit

 

History

History
228 lines (172 loc) · 8.73 KB

File metadata and controls

228 lines (172 loc) · 8.73 KB

Routing

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.

The short version

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.

The classes

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.

What makes something a resource

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.

Relay envelopes

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! }):             DeletePlacePayload

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

Flat aliases

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.

Collisions

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"

Selections

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, pageInfo and totalCount. edges is never requested: it duplicates nodes and doubles the response.
  • Payloads select the row and affectedCount. clientMutationId is 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.

When it guesses wrong

Three decisions are heuristics. All are visible in gqlgate routes before you deploy, and all are overridable.

1. Which key is the primary key

Introspection carries no primary-key information. gqlgate elects one:

  1. A getter whose only argument is id wins.
  2. Otherwise, a resource with exactly one single-row getter uses it.
  3. Otherwise, the key tuple shared with the shortest-named update or delete mutation wins — simple-names shortens 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}"

2. Upsert versus create

These are genuinely indistinguishable by shape — both are (input: XCreateInput!): XPayload!. The field-name prefix is the only signal:

routes:
  upsert_prefixes: ["upsert", "putOrCreate"]

3. Pluralization

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"

Other configuration

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 none

Reloading

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