navree
Documentation

GraphQL

Serve a GraphQL API from your modules with #[resolver] on async-graphql: access rules on fields, validated inputs, per-request dataloaders, subscriptions over WebSockets and GraphiQL in development.

On this page

Introduction

Featuregraphql GraphQL lets a front end ask for exactly the fields it needs, across entities, in one request. navree-graphql serves a GraphQL API from your modules, built on async-graphql.

Resolvers are providers. An #[injectable] struct with a #[resolver] impl block contributes query, mutation and subscription fields; every module can have its own, and Navree merges them into one schema when the app boots. Each field declares its access like a route does, takes validated input, runs mutations in a unit of work, and reports errors with the same status and messages your REST routes use, inside the GraphQL response.

You get:

  • one endpoint, /graphql, with GraphiQL on it in development;
  • subscriptions over WebSockets, fed by your app's events;
  • dataloaders created per request, so batching never shares data between users;
  • depth and complexity limits, and introspection off in release builds;
  • graphql:schema to print the schema as SDL and check it in CI.

Installation

navree-graphql is the graphql feature of the facade. async-graphql's derives name their own crate, so depend on it directly too, at the version navree-graphql uses:

Cargo.toml

The graphql feature isn't in the default full set: turn it on yourself. It brings navree::graphql (the crate, plus the #[resolver] macro) and puts GraphqlModule and ModuleBuilderGraphqlExt in the prelude. Add GraphqlModule to the app root:

src/app.rs

Fields that declare #[roles], #[permissions] or #[can] need AccessModule, and fields that need a signed-in user need an authenticator (AuthModule); the boot fails, naming the field, when one is missing. The app also needs DatabaseModule for units of work and EventsModule for subscriptions.

Configuration

from_config(), and a plain .module(GraphqlModule), read the graphql section. Every key has a default:

config/app.toml
KeyDefaultMeaning
graphql.path/graphqlThe endpoint. Absolute: http.api_prefix doesn't apply.
graphql.graphiqltrue in debug buildsServe GraphiQL to browsers that open the endpoint.
graphql.introspectiontrue in debug buildsAnswer introspection queries. When it's off, __schema and __type resolve to null.
graphql.depth_limit16The deepest selection a query may nest.
graphql.complexity_limit1000The highest complexity score a query may have (each field counts 1 unless it says otherwise).
graphql.batchingfalseAccept a JSON array of operations in one request.
graphql.subscriptionstrueServe subscriptions over WebSockets at <path>/ws.
graphql.connection_init_timeout_secs10How long a WebSocket may wait before sending connection_init.
graphql.keep_alive_secs15How often the server pings an idle WebSocket (0 turns the pings off).

As environment variables: APP_GRAPHQL__INTROSPECTION=false, APP_GRAPHQL__COMPLEXITY_LIMIT=300. In code, GraphqlModule::for_root(GraphqlOptions { depth_limit: 12, ..GraphqlOptions::default() }). Invalid options (a relative path, a zero limit or timeout) fail the boot with the key's name.

Types

Object types are async-graphql types. Derive SimpleObject for plain data, and add complex when some fields need a resolver of their own:

src/deals/graphql/types.rs

Field names become camelCase in the schema (amountCents), and enum values SCREAMING_CASE (NEGOTIATION), as async-graphql does by default. remote generates the conversions between the GraphQL enum and the contract's Stage both ways. company is resolved by a dataloader below.

Resolvers

A resolver is an #[injectable] struct whose #[resolver] impl block holds root fields. Mark each method #[query], #[mutation] or #[subscription]:

src/deals/graphql/resolver.rs

A field method is a non-generic async fn taking &self, then optionally ctx: &Context<'_>, then its GraphQL arguments as plain names, with async-graphql's argument attributes (#[graphql(default = ..)], validators). It returns a value or a Result. Doc comments become field descriptions, and a #[graphql(..)] attribute on the method (name, deprecation, complexity, ...) applies to the field. Methods without #[query], #[mutation] or #[subscription] stay ordinary methods.

Navree's request state comes from the context through ContextExt:

MethodGives you
ctx.uow()The unit of work of this field.
ctx.principal()The authenticated viewer (&Principal); an UNAUTHENTICATED error for guests.
ctx.current_user().awaitThe viewer's user record (AuthModule).
ctx.abilities().awaitThe viewer's abilities.
ctx.accessible::<E, A>().awaitA SQL filter for the records of E the viewer may A.
ctx.authorized::<E, A>()The record a #[can(.., from = "arg")] check loaded.
ctx.workspace().awaitThe current workspace, resolved and checked like the CurrentWorkspace extractor. A read-only demo workspace answers queries and subscriptions and refuses mutations.
ctx.loader::<L>()This request's dataloader of type L.
ctx.app()?The AppContext, to resolve any provider.

navree::graphql::Result<T> is async-graphql's result. ? works on AppError, database errors and every other error, and Navree renders each with the status it has on HTTP (see errors).

Registering resolvers

Register resolvers in a module's register hook, and dataloaders next to them:

src/deals/mod.rs

m.resolver::<R>() registers R as a provider of the module (with the module's visibility for its dependencies) and adds its fields to the schema. At boot, Navree builds one schema from every registered resolver. Root fields from different modules sit side by side (deal from the deals module, company from the companies module). Two resolvers that define the same root field fail the boot, naming both and their modules.

A resolver's fields can call anything the module can see, so a GraphQL field and a REST route that share a service share its rules too. In a split system, the resolvers of the API service call the other services with ServiceClient or gRPC, and GraphQL becomes the one API the front end talks to.

Access

Root fields are deny by default, like routes. Each needs #[public], #[authenticated], #[roles(..)], #[permissions(..)] or #[can(..)], on the method or on the impl block (below #[resolver]), or the resolver doesn't compile. #[can(.., from = "id")] reads the record's id from the field's id argument (naming an argument the field doesn't have is a compile error), loads it in the field's unit of work and checks it; ctx.authorized::<E, A>() hands it over. The checks are the ones routes get: navree-access's route authorizer runs them when the field resolves.

The endpoint itself is open to everyone; the checks run per field. A guest's query for deal gets an UNAUTHENTICATED error for that field, while a #[public] field in the same query still resolves.

Fields of object types (the company of a Deal) run after their parent resolved, so whoever may read the deal reaches them. When one nested field needs a check of its own, make it in the field:

Rust

Refusals are logged on target navree::security and, with an audit trail, recorded as access.denied with the field's path in the entry's field metadata (next to method and route), as on routes.

Validation

Wrap an input object in Valid<T>, the same Valid routes use, and its garde rules run before the field does. Derive InputObject and Validate on the same struct:

src/deals/graphql/types.rs

In the schema the argument has T's type (input: MoveDealInput!). A broken rule fails the field before the resolver runs, after its access check, with every message and the field errors in extensions, under the argument's name:

200 OK

The messages are the ones validation gives everywhere else, with your app's message overrides. The keys and messages name the Rust fields (input.lost_reason), as on routes; several messages are joined with ; in message. Valid<T> works on the arguments of #[resolver] fields. async-graphql's own validators (#[graphql(validator(..))]) work too, for single arguments.

Errors

A GraphQL response carries errors next to the data, so a failed field doesn't fail the whole query. Navree renders each error with its message and an extensions object that holds the rest of the JSON error shape:

extensions memberValue
codeThe status's reason phrase in upper snake case: BAD_REQUEST, UNAUTHENTICATED, FORBIDDEN, NOT_FOUND, CONFLICT, TOO_MANY_REQUESTS, INTERNAL_SERVER_ERROR.
statusCodeThe HTTP status the same error has on a route.
errorThe status's reason phrase.
errorsField errors, for validation failures.

How an error gets its status:

  • an AppError keeps its own (AppError::conflict(..) is a CONFLICT);
  • a database error maps as on routes: a missing record is NOT_FOUND with "statusCode": 404, a unique or foreign-key violation CONFLICT;
  • an error type a module maps with m.map_error::<E>(..) gets that mapping;
  • a plain message (async_graphql::Error::new(..), a String) and async-graphql's own errors are BAD_REQUEST;
  • anything else is a server error: the message becomes "Internal server error" while the real error is logged on target navree::error with the request id.

A code your resolver sets itself (with async-graphql's extend_with) is kept.

The HTTP status of the response is 200 whenever the query ran, with or without field errors. A document that doesn't parse, breaks the schema, or goes over the depth or complexity limit is a 400 with the errors and no data. Invalid credentials are a 401 before the query runs, as on every route. A body that isn't application/json is a 415: queries always come as JSON, which a form on another site can't send without a CORS preflight.

Dataloaders

Listing 50 deals with their company would run 50 company queries, one per deal. A dataloader collects the ids that the fields of one request ask for and loads them in one query.

A loader implements navree::graphql::Loader and is an injectable provider:

src/companies/graphql/loader.rs

Register it with m.dataloader::<CompanyLoader>() and use it from any field:

Rust

Navree creates a new dataloader for every request (RequestLoader<L>, around the app's one L), so its cache lives as long as one query: results are never shared between two viewers, and a mutation never reads a value cached before it. Loads requested in the same tick are batched into one call to load, with each key once; load_many takes several keys. A batch that fails gives its error to every field waiting on it, and neither errors nor missing keys are cached. Loaders read through the pool, outside the field's unit of work, and don't check access: load only what the parent field was allowed to show, or filter with ctx.accessible before you call the loader. Asking for a loader that wasn't registered is a programming error: ctx.loader panics, naming it.

The unit of work

Queries read: the query fields of one operation share a unit of work that commits nothing (it's rolled back when the operation ends). Each mutation field runs in a unit of work of its own, because GraphQL runs a document's mutations one after another and each must succeed or fail on its own. The unit of work commits when the field returns Ok and rolls back when it returns an error, and then the next mutation runs. While a field's method runs, its unit of work is Uow::current(), so jobs, notifications and events emitted in a mutation join its unit of work, as they would in a route.

Subscriptions

Subscriptions stream events to the client over a WebSocket at <path>/ws (/graphql/ws). Navree speaks the graphql-transport-ws protocol, and the older graphql-ws one for clients that still use it.

A #[subscription] field returns a stream. EventStream::<E> streams every E emitted on the app's event bus from the moment the client subscribed:

Rust
  • Authentication. Clients send their token in the connection_init payload, { "authorization": "Bearer <token>" } (the key in any case), and Navree authenticates it with the app's authenticator; a token it refuses closes the socket with code 4403. Without a token in the payload, the upgrade request's own authentication counts: an Authorization header from a non-browser client, or, for browsers on the app's own origin, the session cookie, but only when the WebSocket's Origin is one of http.cors.allowed_origins.
  • Access is checked when the subscription starts: a guest or a refused viewer gets one next message with the error, then complete. When the token's expiry (its exp claim) passes, Navree closes the socket with code 4401, and the client reconnects with a fresh token.
  • Timeouts. A client that sends no connection_init within graphql.connection_init_timeout_secs is closed with code 4408. The server sends a ping (ka on graphql-ws) every graphql.keep_alive_secs.
  • Several instances. An instance's event bus only sees events emitted in that instance. When the API runs on several instances, add m.receive_event::<DealUpdated>() from navree-micro, and events published by any instance or service reach every subscriber.

A subscriber that falls more than 256 events behind skips the oldest, like any subscriber of the bus.

GraphiQL

In development, open http://localhost:8080/graphql in a browser to get GraphiQL, with the schema's documentation, autocompletion and a subscriptions client. Put your token in its headers tab:

JSON

GraphiQL sends it with every query, and in the connection_init payload of its subscriptions. The page loads GraphiQL from unpkg.

GraphiQL is served only when graphql.graphiql is on (debug builds by default) and only to requests that accept HTML. Any other GET on the endpoint answers 405, and so does every GET in release builds: queries are always POSTs with a JSON body, so a link or an image tag on another site can't run one, and the CSRF protection of cookie sessions applies to them like any other POST.

Batching

With graphql.batching on, the endpoint accepts a JSON array of operations and answers with an array of responses, in order. The operations run one after another, each with its own units of work. Without it, an array is a 400.

The schema as a file

The schema is code-first: your types and resolvers define it. graphql:schema prints it as SDL, so you can review it, publish it, and generate client types from it:

Shell

The path defaults to schema.graphql. Commit the file, and check in CI that it's up to date:

Shell

--check exits with status 1 and prints the difference when the code's schema and the file disagree (a missing file counts as empty; line endings don't count). Teams that design the schema first write the SDL, then implement resolvers until --check passes. A change that removes a field or an argument shows up in the diff before any client breaks.

Testing

Test GraphQL through the endpoint with TestClient, as you test routes. assert_json_path reads into data and errors:

tests/graphql_deals.rs

To test without HTTP, app.get::<GraphqlSchema>().await.execute(request) runs an async_graphql::Request against the app's schema and renders its errors as the endpoint does; add the viewer with .data(principal(rep)). execute_batch and execute_stream (for subscriptions) work the same way. schema.sdl() returns the SDL, for a test that compares it with schema.graphql.