GraphQL
A real GraphQL API derived from your models — relations and all, batched, deny-by-default.
GraphQL
GraphqlPlugin::new() .expose("post") .expose("auth_user"){ 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:
{ post(id: "1") { title author { username } } } # forward: post -> author{ author(id: "1") { username posts { title } } } # reverse: author -> postsRelations are batched
{ 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.
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:
GraphqlPlugin::new() .expose("post") .expose_if("order", |id| id.is_some_and(|i| i.is_staff))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.
GraphqlPlugin::new() .expose("product") .hide("product", "cost") .hide("product", ["cost", "supplier_notes"]) // or several at onceA 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.
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:
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:
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))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:
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.author | forward FK → the object |
post.author_id | the raw key, if that's all you want |
author.posts | reverse FK → the list |
createPost / updatePost / deletePost | writes — opt in with .mutable(...) |
postChanged / postDeleted | live 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:
{ 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.
Three details that are easy to get wrong, and are handled:
- Ties break on the primary key. Order by
created_atalone 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 ASCis meaningless underprice DESC, so replaying it across orderings is an error rather than a guess. hasNextPagecosts one extra row. Fetch exactlyfirstand 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.
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 onlyYou get three fields per writable model:
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.
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:
GraphqlPlugin::new() .expose("product") .subscribable("product")subscription { productChanged(id: "1") { name price } }subscription { productDeleted }Two transports, mounted alongside the endpoint:
POST /graphql/sse | Server-Sent Events. Plain HTTP, reconnects on its own, survives proxies that mangle upgrades. Server→client only — which is all a subscription needs. |
GET /graphql/ws | WebSocket. 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.
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.