This version is in beta. Some features may change before release.

GraphQL

A real GraphQL API derived from your models — relations and all, batched, deny-by-default.

GraphQL

Code
rust
GraphqlPlugin::new()
.expose("post")
.expose("auth_user")
Code
graphql
{ post(id: "1") { title author { username } } }

The schema is derived from your model registry — the same source typegen, gen-client and OpenAPI already read.

It's a graph, not RPC

The cheap way to add GraphQL is to convert the OpenAPI spec. That gives you getPost / listPosts — GraphQL in name only. Nobody adopts GraphQL to make the same call with different syntax; they adopt it to traverse a graph.

umbral already has the graph. A ForeignKey<Author> field is an edge. Invert it and you have the reverse relation. You get both directions, and you declared neither:

Code
graphql
{ post(id: "1") { title author { username } } } # forward: post -> author
{ author(id: "1") { username posts { title } } } # reverse: author -> posts

Relations are batched

Code
graphql
{ posts(limit: 100) { title author { username } } }

The naive resolver runs 1 query for the posts and 100 more — one per post — for the authors. It's correct, it returns identical JSON, and it melts your database the first time someone asks for a page.

Warning

In GraphQL the client picks the query shape, which means the client picks your query count. An endpoint without batching is an N+1 generator that your caller aims.

Every relation goes through a DataLoader that coalesces ids into one WHERE id IN (...). 100 posts → 1 author query. A test asserts the actual read count, because "we batch" is otherwise just a sentence in a doc-comment.

Loaders are built per request, so one caller's cache can never serve another's rows — that would be a data leak wearing a performance costume.

Deny by default

Nothing is exposed until you name it:

Code
rust
GraphqlPlugin::new()
.expose("post")
.expose_if("order", |id| id.is_some_and(|i| i.is_staff))
Warning
This isn't politeness. A GraphQL endpoint is the most efficient exfiltration tool you can hand an attacker, *because they choose the query shape*. An auto-exposed schema would put every column of every model — password hashes, session tokens, internal costs — one query away, and the framework would have done it on their behalf.

A relation is only traversable when both ends are exposed. Otherwise post.author would be a side door into a model you deliberately withheld. An unexposed model doesn't appear in the schema at all — not as a query, not as a relation, not in introspection.

Hiding fields

Exposing a model exposes every column of it. That's the part people underestimate: .expose("product") puts your wholesale cost one query away, because the caller picks the fields.

Code
rust
GraphqlPlugin::new()
.expose("product")
.hide("product", "cost")
.hide("product", ["cost", "supplier_notes"]) // or several at once

A hidden column is absent from the schema, not present-and-null — a field that exists and always returns null still confirms the column to anyone reading introspection, and GraphiQL will autocomplete it. Hiding a foreign key also severs the relation in both directions, since otherwise product { category { id } } would hand back the id you just hid, one hop out.

Info

This list is separate from RestPlugin::hide on purpose. "Which fields this endpoint returns" is a property of the endpoint, and REST and GraphQL are swappable — so each owns its own surface. The corollary is that hiding a field in REST does not hide it in GraphQL. If you hide something in one, go hide it in the other.

Data that must never ship over any transport is a different tier, and it isn't your job to remember: password_hash is denied in core (umbral::orm::HARD_DENIED_FIELDS), so no combination of .expose(...) can bring it back. Secrecy there is a property of the data, not of the door you walk through to reach it.

Private columns

A #[umbral(private)] column (see field privacy) is absent from the schema unless you say who may read it:

Code
rust
GraphqlPlugin::new()
.expose("product")
.allow_private_if("product", "cost", |id| id.is_some_and(|i| i.is_staff))

The schema stays one shape for everybody — introspection is a single document, so it has to. The field exists and is nullable (even if the column is NOT NULL), and whether you get a value is decided per request. A caller without the unlock reads null.

The unlock covers reads. It does not cover writes, because private is a read policy: a caller who may write the model may set cost and will still read back null for it. That is a write-only column, and GraphQL models it natively — an input field need not exist on the object type. To stop a column being set from an untrusted body, mark it #[umbral(privileged)], which is the mass-assignment guard and a different question entirely.

Who is the caller

expose_if hands your closure the caller's Identity — but the plugin only has one if you tell it how to authenticate. Give it the same backends you give RestPlugin:

Code
rust
GraphqlPlugin::new()
.authenticate(ChainAuthentication::new(vec![
Arc::new(session_authentication()) as Arc<dyn Authentication>,
Arc::new(BearerAuthentication::default()) as Arc<dyn Authentication>,
]))
.expose("product")
.expose_if("order", |id| id.is_some_and(|i| i.is_staff))
Warning

Without .authenticate(...) every request is anonymous, so expose_if can only ever deny — a gate that can't be opened is a wall with a lock painted on it. The plugin warns at boot if you gate a model without configuring authentication.

Mounting it

GraphQL speaks POST for reads, so a browser-facing app with CSRF protection on will reject every query with a 403 until you exempt the endpoint:

Code
rust
SecurityPlugin::new().csrf_exempt(["/graphql"])

Exempting it is safe because the plugin replaces the token check with a defence CSRF middleware can't provide here: every POST must be Content-Type: application/json (see Mutations below). That's not a formality — it's precisely the property a cross-site <form> cannot satisfy.

What you get per model

post(id: ID!)one row
posts(limit: Int, offset: Int)a list, capped at 200
postsConnection(first:, after:, orderBy:, desc:)a cursor page — stable under writes
post.authorforward FK → the object
post.author_idthe raw key, if that's all you want
author.postsreverse FK → the list
createPost / updatePost / deletePostwrites — opt in with .mutable(...)
postChanged / postDeletedlive updates — opt in with .subscribable(...)

Ids cross the wire as String: GraphQL's Int is an i32, a BigInt doesn't fit, and silently truncating a primary key isn't an option — nor is assuming it's an integer at all, since String and Uuid keys are first-class.

GraphiQL

Served on GET /graphql in Dev, off otherwise — an interactive schema explorer on a production endpoint is a gift to whoever is enumerating your API. Force it either way with .graphiql(true|false).

If any model is subscribable, GraphiQL is pointed at the WebSocket endpoint too, so you can run a subscription { ... } in the IDE and watch rows arrive as you edit them in another tab.

Pagination

Every model gets a limit/offset list and a Relay-style cursor connection:

Code
graphql
{
productsConnection(first: 20, orderBy: "created_at", desc: true) {
edges { node { name price } cursor }
pageInfo { hasNextPage endCursor }
}
}

Feed endCursor back as after for the next page. Apollo, Relay and urql all recognise this shape and will page for you.

Why not just use offset

OFFSET is positional, and that's fine right up until somebody writes to the table. Read page 1 (rows 1–20), have a row deleted from behind you, then read page 2 (OFFSET 20) — it now starts one row late, and the row that moved into position 20 is never served to anyone. An insert does the mirror image: a row gets served twice. Nothing errors. The client quietly receives a list with a hole in it.

A cursor isn't a row number; it's the sort key of the last row you saw. The next page is "everything that sorts after this" — a WHERE, not a count — so a write elsewhere in the table can't move it. It's also faster: OFFSET 100000 makes the database walk and discard 100 000 rows before returning the page you wanted.

Info
`limit`/`offset` isn't going anywhere, and it's genuinely the right tool for a small, stable table. Nobody wants a cursor to fetch ten categories.

Three details that are easy to get wrong, and are handled:

  • Ties break on the primary key. Order by created_at alone and two rows sharing a timestamp straddle the page boundary — one served twice, one skipped. The key is always (sort_col, pk), so the ordering is total.
  • A cursor remembers its ordering. One minted under created_at ASC is meaningless under price DESC, so replaying it across orderings is an error rather than a guess.
  • hasNextPage costs one extra row. Fetch exactly first and you can't distinguish a full last page from one with more behind it.

Cursors are opaque base64 — not encryption, just a signal not to parse them, since a client that does is a client that breaks the next time paging changes. And you can't order by a column that isn't in the schema: a cursor built on a hidden column would carry its values back out inside the cursor.

Mutations

Writing is a second opt-in. expose makes a model readable; mutable makes it writable — because a read you got wrong leaks data, and a write you got wrong destroys it.

Code
rust
GraphqlPlugin::new()
.expose("product")
.expose("review")
.mutable("review") // anyone may post a review
.mutable_if("product", |id| id.is_some_and(|i| i.is_staff)) // staff only

You get three fields per writable model:

Code
graphql
mutation {
createReview(data: { product: "1", rating: 5, comment: "Great" }) { id rating }
updateReview(id: "7", data: { rating: 4 }) { id rating }
deleteReview(id: "7")
}

Input requires the columns your model requires (NOT NULL, no default); Patch makes everything optional, so changing one field doesn't mean re-sending the row. update reads the row back rather than echoing your request, since defaults, auto_now and cleaners mean the stored row isn't always the row you sent.

Mutations run through the ORM's dynamic write path — the same one REST and the admin use — so they inherit validators, cleaners, defaults, signals, and the mass-assignment guard for free.

Warning
A `#[umbral(privileged)]` column (`is_staff`, ownership FKs) isn't merely *ignored* in a mutation body — it isn't in the input type at all. An input field that silently does nothing is worse than no input field: the client sets `is_staff: true`, gets a `200`, and believes it worked.

Mutations change the CSRF story

/graphql has to be CSRF-exempt for queries to work at all (GraphQL reads are POSTs). That's harmless while the endpoint is read-only. The moment a mutation exists, that exemption is a hole: a hostile page can submit an HTML <form> at your endpoint and the browser attaches your user's session cookie.

So the plugin enforces its own defence: every POST must be Content-Type: application/json, or it's rejected with 415. An HTML form can only send three content types (application/x-www-form-urlencoded, multipart/form-data, text/plain) and none of them is JSON — while a cross-origin fetch() that does send JSON gets preflighted, and your app never answers that preflight permissively. The set of requests an attacker can forge is exactly the set that gets refused.

You don't configure this and you can't turn it off. It's the same defence Apollo Server ships, for the same reason.

Subscriptions

Live data, over WebSocket or SSE. Another opt-in:

Code
rust
GraphqlPlugin::new()
.expose("product")
.subscribable("product")
Code
graphql
subscription { productChanged(id: "1") { name price } }
subscription { productDeleted }

Two transports, mounted alongside the endpoint:

POST /graphql/sseServer-Sent Events. Plain HTTP, reconnects on its own, survives proxies that mangle upgrades. Server→client only — which is all a subscription needs.
GET /graphql/wsWebSocket. What Apollo and Relay reach for by default; bidirectional, so one connection carries many subscriptions.

Events come from the ORM, not from GraphQL

The write signals already exist and fire on every write path — a GraphQL mutation, a REST call, an admin form edit, a background task. Subscriptions just listen. That's the property that makes this trustworthy: no write path can forget to publish, because publishing was never the write path's job.

Warning

The row a subscriber receives is re-read through the ORM — not lifted out of the signal payload. That payload is a serde dump of your model and knows nothing about private, secret, Masked or hide. Forwarding it would ship every protected column down the socket, defeating the whole field policy merely because the data left over a WebSocket instead of a response body. A subscription is a read. The socket is a transport, not an exemption.

A deleted row can't be re-read, so productDeleted yields the ID, not an object — echoing the last-known row would be that same leak, and a "row" that no longer exists is a lie besides.

Two things to know

The gate is checked when the subscription is established, not per event. A caller demoted mid-stream keeps receiving until they reconnect. If that matters for your app, keep streams short-lived.

You subscribe before you read, not after. A client that fetches its initial state and then subscribes has a window where writes land in between and are lost. Subscribe first, then query — the same discipline any event stream demands.

Not yet

Filtered subscriptions beyond id (e.g. "every order over £100"), and batched delivery for a firehose table. Both are additive; say the word.

Design notes: planning/features.md #9.

graphqlapi