autumn-plugin-graphql
Production-grade GraphQL for Autumn. Mount any async-graphql schema on an Autumn app with one line, and get the operational surface a real API needs: hardening, persisted operations, subscriptions, error hygiene, observability, and configuration that operators can change without a deploy.
use Schema;
use GraphqlPlugin;
async
That mounts:
| Method | Path | What |
|---|---|---|
POST |
/graphql |
JSON, application/graphql, batched (opt-in) and multipart upload (opt-in) requests |
GET |
/graphql?query=… |
Queries only; a mutation over GET is a 405 |
GET |
/graphql/sdl |
The schema as SDL (dev by default) |
GET |
/graphql/ws |
WebSocket: graphql-transport-ws and legacy graphql-ws (when the schema has subscriptions) |
POST/GET with Accept: text/event-stream |
/graphql |
GraphQL over SSE (when the schema has subscriptions) |
examples/notes.rs is a complete app in one file — cargo run --example notes.
Contents
- Why a plugin
- Features
- Installation
- Configuration
- Resolvers: context, errors, auth
- Hardening
- Persisted operations
- Subscriptions
- Observability
- Testing your API
- Autumn versions
- Migrating from the react-graphql example
- Development
Why a plugin
A GraphQL endpoint is one POST handler — until it is public. Then it needs
query-cost limits, a story for introspection in production, errors that do not
leak database detail through an HTTP 200, subscriptions that do not bypass
the checks HTTP gets, and metrics. This crate packages those, built on
Autumn's own seams (AppState, AutumnError, the metrics registry, the
config loader, route declaration) so a GraphQL door into an app behaves like
every other door.
Every transport runs through one pipeline, so no path reaches the schema without the same checks:
flowchart LR
POST[POST /graphql] --> P
GET[GET /graphql] --> P
SSE[SSE] --> P
WS[WebSocket /graphql/ws] --> P
subgraph P[Pipeline]
direction TB
A[persisted query resolution] --> B[size + lexical nesting]
B --> C[parse once]
C --> D[transport policy]
D --> E[depth / aliases / fields / directives]
E --> F[introspection policy]
end
P --> X[async-graphql executor]
X --> G[error codes + masking]
G --> H[metrics, tracing, slow log]
See docs/architecture.md for the full design and
docs/adr/ for the decisions behind it.
Features
| Concern | What you get |
|---|---|
| GraphQL over HTTP | application/graphql-response+json with meaningful status codes (400 for request errors, 504 for timeouts), application/json for legacy clients, 406/415 negotiation, Vary/Cache-Control from @cacheControl on GET |
| Hardening | Limits on document size, lexical nesting (checked before the parser), depth, aliases, root fields, total fields (fragment-bomb safe), directives; optional async-graphql complexity; mutations refused over GET; introspection and SDL off outside dev/test; CSRF preflight for multipart; execution timeouts; stream connection caps; body-size limits |
| Persisted operations | Apollo Automatic Persisted Queries with a pluggable store (in-memory LRU by default); trusted documents (safelisting) from an Apollo persisted-query-manifest.json; documentId requests |
| Errors | A stable extensions.code on every error; AutumnError → GraphQL mapping that keeps 4xx messages, redacts 5xx, and carries extensions.status and validation extensions.fields; masking of uncoded resolver errors in production |
| Context | AppState and GraphqlRequestInfo in every resolver; context hooks that run any FromRequestParts extractor (current user, tenant, DataLoaders); WebSocket connection_init auth |
| Subscriptions | WebSocket (two protocols) and SSE, through the same pipeline; connection_init timeout (4408); refused init (4403); keep-alive; graceful drain (1001) on shutdown or on demand |
| Operations | [graphql] in autumn.toml with profile layering and AUTUMN_GRAPHQL__* overrides; kill switch; Prometheus metrics at /actuator/prometheus; tracing spans; slow-operation log |
| Framework fit | Routes declared for autumn routes and the route audit; a guard(layer) seam for the nested router; PluginContract (see Autumn versions); passes Autumn's plugin conformance harness |
| Schemas | Static Schema<Q, M, S>, SchemaBuilder (so the plugin can apply schema-level limits), or any Executor such as async_graphql::dynamic::Schema |
Installation
[]
= "0.1"
= { = "7", = false }
The crate re-exports async_graphql, so the two can never disagree.
Autumn needs at least one typed route.
AppBuilder::runrefuses to boot when noroutes![…]were registered, and routers a plugin nests do not count. A GraphQL-only service registers something — a landing page, a version route — asexamples/notes.rsdoes.
Configuration
Everything has a production-safe default; a zero-config mount is already
hardened. Settings come from the [graphql] section of autumn.toml,
layered exactly like Autumn's own configuration:
autumn.toml[graphql][profile.<name>.graphql]inlineautumn-<profile>.toml[graphql]AUTUMN_GRAPHQL__*environment variables (and.envfiles)
Fluent setters in code are applied on top, so code can pin what
operations must not change. .config(GraphqlConfig) supplies the whole
configuration in code and reads no files.
[]
= true # kill switch: false mounts nothing
= "/graphql"
= "auto" # auto = on in dev/test, off elsewhere
= "auto" # auto = follows introspection
= "auto" # auto = off in dev/test, on elsewhere
= true
= 1048576
= 30000 # 0 = none; subscriptions are never timed out
= 1000 # 0 = never log
= true # multipart needs a preflight-forcing header
[] # 0 = unlimited, for every limit
= 32768
= 64 # lexical { ( [ nesting, before parsing
= 15
= 30
= 30
= 1000
= 50
= 0 # async-graphql cost model; needs from_builder
[]
= false
= 10
[]
= "auto" # auto = on when the schema has subscriptions
= "auto"
= 10000 # per endpoint; 0 = unlimited
= 30
= 10
[]
= "disabled" # disabled | automatic | trusted
= 1000
= "" # path, relative to autumn.toml
[]
= false
= 10
= 10485760
[]
= "trusted"
= "persisted-query-manifest.json"
Any leaf can be overridden from the environment:
AUTUMN_GRAPHQL__LIMITS__MAX_DEPTH=10, AUTUMN_GRAPHQL__ENABLED=false. An
override that does not type-check is logged and ignored (as core does); a bad
file value refuses boot, naming the key — a typo'd introspection = flase
must never silently leave introspection on in production.
In code:
new
.path
.introspection
.timeout
.batching
.configure
Two endpoints with different settings read different sections:
.config_section("graphql_admin") reads [graphql_admin].
Resolvers: context, errors, auth
Every resolver can read:
let state = ctx.?; // pool, extensions, config…
let info = ctx.?; // method, headers, request id, transport
Context hooks run per HTTP operation and per WebSocket upgrade, with the
request's Parts, so any Autumn/axum extractor works:
new
.context
.on_ws_init
Returning Err(AutumnError) from a hook refuses the request with that status
(a 401 stays a 401), GraphQL-shaped.
Errors. Convert framework errors with .gql():
use GraphqlResultExt as _;
async
AutumnError |
message |
extensions |
|---|---|---|
not_found_msg("note 9 not found") |
note 9 not found |
code: NOT_FOUND, status: 404 |
validation({title: ["too short"]}) |
title: too short |
code: VALIDATION_FAILED, status: 422, fields: {title: [...]} |
internal_server_error_msg("pg: …") |
Internal server error (detail logged) |
code: INTERNAL_SERVER_ERROR, status: 500 |
Errors a resolver raises without a code — a bare ? on a library error,
ctx.data::<T>()? — are unexpected. With mask_unexpected_errors on (the
production default) their message becomes Internal server error and the
original is logged. Give an error a code (graphql_error(codes::…, msg, status)
or .extend_with) to show it to clients. Parse and validation errors are
coded GRAPHQL_PARSE_FAILED / GRAPHQL_VALIDATION_FAILED; every code the
plugin emits is in autumn_plugin_graphql::codes.
Guarding the endpoint. AppBuilder::scoped wraps only the routes![] it is
given, never a router a plugin nests, so guard the mount itself:
new
.guard
The layer runs before every handler (SDL and the WebSocket upgrade included),
and the declared routes show as Gated in autumn routes. Under prod,
Autumn's CSRF layer also covers POST /graphql: browser clients send the
token (see the react-graphql example); a bearer-only mount can exempt its
path with [security.csrf] exempt_paths = ["/graphql"].
Hardening
Before anything executes, each operation is checked against
[graphql.limits]; a violation is refused with QUERY_LIMIT_EXCEEDED and
extensions: { limit, max, actual }:
| Limit | Guards against |
|---|---|
max_query_bytes |
huge documents |
max_nesting |
parser stack exhaustion — checked on raw text, before parsing |
max_depth |
deep recursion through cyclic types (user { friends { friends { … } } }) |
max_aliases |
alias amplification (a1: expensive a2: expensive …) |
max_root_fields |
wide root fan-out |
max_fields |
fragment bombs — counted with fragments expanded, analysed in linear time |
max_directives |
directive overloading |
max_complexity |
async-graphql's cost model (use GraphqlPlugin::from_builder) |
The analyzer's invariants — totality, linear cost, faithfulness to naive
expansion, cycle safety — are documented on limits and property-tested in
tests/limits_properties.rs.
Also: mutations and subscriptions over GET are refused (405), since a
GET is what caches, prefetchers and cross-site links replay; introspection
is refused with INTROSPECTION_DISABLED outside dev/test; multipart
requests must carry apollo-require-preflight (or
graphql-require-preflight) so a browser cannot send them cross-site without
a CORS preflight; operations time out (OPERATION_TIMEOUT).
Persisted operations
Automatic Persisted Queries (mode = "automatic") implement Apollo's
protocol: a client sends extensions.persistedQuery.sha256Hash; on
PERSISTED_QUERY_NOT_FOUND it re-sends with the document, which is verified
and cached. A fleet shares registrations by implementing
PersistedQueryStore over Redis and passing it to
.persisted_query_store(..).
Trusted documents (mode = "trusted") are a safelist: only operations
in the manifest run, whether the client sends the hash, a documentId, or the
full text. Generate the manifest at client build time (Apollo's
generate-persisted-query-manifest, GraphQL Code Generator's
persisted-documents preset, or a flat {"<sha256>": "<document>"} map) and
point manifest at it — or add documents in code with
.trusted_documents(..). Boot is refused in trusted mode with no documents.
Limits still apply to persisted documents.
Subscriptions
With a subscription root, GET /graphql/ws speaks graphql-transport-ws
(the graphql-ws library) and the legacy graphql-ws
(subscriptions-transport-ws), and Accept: text/event-stream switches
POST/GET to GraphQL over SSE. Both run the same pipeline as HTTP.
| Behaviour | Result |
|---|---|
no connection_init within init_timeout_secs |
close 4408 |
on_ws_init returns Err |
close 4403 |
over max_connections |
503 TOO_MANY_CONNECTIONS before the upgrade |
app shutdown, or plugin.drain_handle().drain() |
sockets close 1001, SSE streams end with complete |
SSE passes through proxies, CSRF protection and the guard like any other request; prefer it behind infrastructure that is unkind to WebSockets.
Observability
Metrics go through Autumn's registry and appear at /actuator/prometheus:
| Family | Labels |
|---|---|
graphql_operations_total |
endpoint, transport, operation, outcome |
graphql_operation_duration_seconds |
endpoint, transport, operation |
graphql_errors_total |
endpoint, code |
graphql_active_streams |
endpoint, transport |
Operation names are deliberately not a label (client-controlled,
unbounded). Each operation runs in a graphql.operation span carrying its
name, type and a document hash, and operations slower than
slow_operation_ms are logged at WARN.
Testing your API
With the test-support feature:
use GraphqlTestExt as _;
let client = new.plugin.build;
let res = client.graphql.send.await;
res.assert_no_errors;
client.graphql.send.await.assert_error_code;
Keep a committed schema.graphql honest (regenerate with
AUTUMN_GRAPHQL_BLESS=1 cargo test):
Autumn versions
| This crate | autumn-web |
Notes |
|---|---|---|
0.1 |
0.8 (crates.io) |
declares Plugin::contract for 0.8 |
The plugin declares its supported autumn-web range
(SUPPORTED_AUTUMN_WEB) through Plugin::contract, so autumn plugin-check
and Autumn's startup compatibility gate refuse a mismatched pairing with an
actionable message instead of failing later. CI also runs the suite against
Autumn main as an early warning.
If your app turns on async-graphql's boxed-trait feature, turn on this
crate's boxed-trait feature too (async-graphql changes the shape of its
Executor trait under it).
Migrating from the react-graphql example
The plugin started life as examples/react-graphql/src/graphql_plugin.rs in
the Autumn repository. The builder surface is source-compatible —
new, path, guard, without_sdl, route_infos — with two changes:
- The type parameter is the executor:
GraphqlPlugin<Schema<Query, Mutation, Subscription>>instead ofGraphqlPlugin<Query, Mutation, Subscription>. PLUGIN_NAMEis"autumn-plugin-graphql".
Replacing the in-tree module with
pub use ;
passes that example's own test suite unchanged (see
docs/verification.md).
Development
See CLAUDE.md for conventions and CHANGELOG.md
for release notes.
License
Apache-2.0