Tutorial
In this tutorial, you will define a GraphQL schema, implement its generated Rust resolvers, and run a web server. You will send queries and mutations using GraphiQL and HTTP. The example introduces enums, input defaults, nullable values, OneOf inputs, recursive input types, and shared in-memory state.
The completed Axum project is available in
examples/tutorial.
The source checkout supports built-in and application-owned custom scalars, enum results, ordinary and OneOf input objects, owned Object results, Interface and Union results, nullable and list forms, recursive input and Object relationships, and generated query, mutation, and subscription routing. Borrowed Object results and subscription transports remain unsupported. These examples describe this checkout; published package versions may differ. NecrassRs uses Reed for GraphQL parsing, models, and validation.
For subscription sources and runtime response streams, see Subscriptions. The HTTP server in this tutorial serves queries and mutations.
1. Create a project
Section titled “1. Create a project”Follow the installation guide to install the CLI, then create a project:
necrass init my-apicd my-apiKeep the generated Cargo manifest, build script, and src/main.rs. The starter
already registers a GraphQL HTTP endpoint and a GraphiQL page. You will replace
its schema and resolver implementation while keeping that server setup.
2. Define the schema
Section titled “2. Define the schema”Replace schema/schema.graphql with this SDL, then run cargo build:
enum Status { OPEN CLOSED}
input Filter { status: Status = OPEN limit: Int next: Filter children: [Filter!]}
input Locator @oneOf { id: ID name: String}
type Query { statuses(filter: Filter = {}): [Status!]! label(locator: Locator!): String!}3. Implement the resolvers
Section titled “3. Implement the resolvers”After cargo build, open src/resolvers.rs. Cargo synchronizes the resolver
declarations with your schema and creates an empty root marker plus one field
resolver stub for each query field:
pub struct Query;
impl<C: Sync> crate::generated::resolvers::QueryResolver<C> for Query {}
impl<C: Sync> necrassrs::Resolver<crate::generated::fields::Query::label, C> for Query { type Output = String;
async fn resolve( &self, _context: &C, _args: crate::generated::types::Query::label::Args, ) -> Result<Self::Output, necrassrs::ResolverError> { ::core::unimplemented!() }}
impl<C: Sync> necrassrs::Resolver<crate::generated::fields::Query::statuses, C> for Query{ type Output = Vec<crate::generated::types::Status>;
async fn resolve( &self, _context: &C, _args: crate::generated::types::Query::statuses::Args, ) -> Result<Self::Output, necrassrs::ResolverError> { ::core::unimplemented!() }}The marker identifies the Rust root type and Context used by dispatch. It stays
empty. Each field is a separate Resolver<Field, C> implementation with its
Rust result in type Output, so each field has an independent implementation
block. Each new resolve body starts with
::core::unimplemented!(). Rename _args to args, add the GraphQLInput import,
and replace the stub bodies with your resolver logic. Keep the generated
signatures and any unrelated application code.
For this example, the completed implementation is:
use necrassrs::GraphQLInput;
pub struct Query;
impl<C: Sync> crate::generated::resolvers::QueryResolver<C> for Query {}
impl<C: Sync> necrassrs::Resolver<crate::generated::fields::Query::label, C> for Query { type Output = String;
async fn resolve( &self, _context: &C, args: crate::generated::types::Query::label::Args, ) -> Result<Self::Output, necrassrs::ResolverError> { match args.locator { crate::generated::types::Locator::id(id) => Ok(id.as_str().to_owned()), crate::generated::types::Locator::name(name) => Ok(name), } }}
impl<C: Sync> necrassrs::Resolver<crate::generated::fields::Query::statuses, C> for Query{ type Output = Vec<crate::generated::types::Status>;
async fn resolve( &self, _context: &C, args: crate::generated::types::Query::statuses::Args, ) -> Result<Self::Output, necrassrs::ResolverError> { let GraphQLInput::Value(filter) = args.filter else { return Ok(Vec::new()); }; match filter.status { GraphQLInput::Value(status) => Ok(vec![status]), GraphQLInput::Undefined | GraphQLInput::Null => Ok(Vec::new()), } }}4. Run the web server
Section titled “4. Run the web server”Start the generated server:
cargo runLeave the server running in this terminal. The CLI starter listens on 127.0.0.1:3000 and serves
GraphQL requests through POST /graphql. GET /graphql opens GraphiQL, an
interactive interface for exploring the schema and sending queries.
Query with GraphiQL
Section titled “Query with GraphiQL”Open http://127.0.0.1:3000/graphql in your browser. Paste this query into the editor and click the execute button:
{ defaults: statuses nullFilter: statuses(filter: null) closed: statuses(filter: { status: CLOSED }) label(locator: { id: 42 })}The response panel shows:
{ "data": { "defaults": ["OPEN"], "nullFilter": [], "closed": ["CLOSED"], "label": "42" }}Use GraphiQL’s documentation explorer to browse the generated schema and its arguments. The built-in page loads browser assets from a CDN and requires network access. See Introspection and GraphiQL for UI configuration and deployment settings.
Query over HTTP
Section titled “Query over HTTP”You can also send the same query from another terminal:
curl http://127.0.0.1:3000/graphql \ -H 'Content-Type: application/json' \ -H 'Accept: application/graphql-response+json' \ --data '{"query":"{ defaults: statuses nullFilter: statuses(filter: null) closed: statuses(filter: {status: CLOSED}) label(locator: {id: 42}) }"}'The endpoint returns the same JSON response. See Types for Rust
type mappings, GraphQLInput, defaults, OneOf inputs, and recursive inputs. See
Custom Scalars when an SDL scalar needs
application-defined coercion and serialization.
5. Add an in-memory mutation
Section titled “5. Add an in-memory mutation”This example stores a counter in the application Context shared by every
request. The Query and Mutation roots remain stateless. Stop the server with
Ctrl+C, append this definition to schema/schema.graphql, and run cargo build:
type Mutation { increment(by: Int!): Int!}Cargo adds a Mutation root marker and a resolver stub for the new field. This
first build then reports Rust errors because main.rs still constructs a
query-only dispatcher; the changes below complete the new contract.
In src/main.rs, import Mutex, define the application Context, and store it
with the schema and dispatcher:
use std::sync::Mutex;
pub struct Context { counter: Mutex<i32>,}
struct AppState { schema: Valid<Schema>, dispatcher: generated::dispatch::SchemaDispatcher< resolvers::Query, resolvers::Mutation, >, context: Context,}In src/resolvers.rs, leave both root types empty and make their marker
implementations use the application Context:
pub struct Query;pub struct Mutation;
impl crate::generated::resolvers::QueryResolver<crate::Context> for Query {}impl crate::generated::resolvers::MutationResolver<crate::Context> for Mutation {}Update the two existing Query field implementations in the same way: replace
their generic C with crate::Context, and change _context: &C to
_context: &crate::Context. This gives every generated root the same Context
contract.
Replace the new resolver stub body with this implementation:
impl necrassrs::Resolver< crate::generated::fields::Mutation::increment, crate::Context,> for Mutation{ type Output = i32;
async fn resolve( &self, context: &crate::Context, args: crate::generated::types::Mutation::increment::Args, ) -> Result<Self::Output, necrassrs::ResolverError> { let mut counter = context .counter .lock() .map_err(|_| necrassrs::ResolverError::new("Counter is unavailable"))?; *counter = counter .checked_add(args.by) .ok_or_else(|| necrassrs::ResolverError::new("Counter overflow"))?; Ok(*counter) }}Pass the shared Context to execute instead of creating a unit Context for each
request:
necrassrs::execute( &state.schema, &request, &state.dispatcher, &state.context,).awaitFor the Axum starter, create both stateless roots and initialize the Context
inside the existing Arc<AppState>:
let dispatcher = generated::dispatch::SchemaDispatcher::new( resolvers::Query, resolvers::Mutation,);let state = Arc::new(AppState { schema, dispatcher, context: Context { counter: Mutex::new(0), },});Axum shares this Arc<AppState> across requests. Actix calls its server factory
once per worker, so create one web::Data<AppState> before HttpServer::new
and clone that handle into every worker. Leave configure responsible only for
routes:
fn configure(cfg: &mut web::ServiceConfig) { cfg.service( web::resource("/graphql") .route(web::post().to(graphql)) .route(web::get().to(|| async { graphiql_html("/graphql") })), );}
#[actix_web::main]async fn main() -> std::io::Result<()> { let schema = Schema::parse_and_validate(generated::SDL, "embedded.graphql").unwrap(); let state = web::Data::new(AppState { schema, dispatcher: generated::dispatch::SchemaDispatcher::new( resolvers::Query, resolvers::Mutation, ), context: Context { counter: Mutex::new(0), }, });
HttpServer::new(move || { App::new() .app_data(state.clone()) .configure(configure) }) .bind(("127.0.0.1", 3000))? .run() .await}All requests and Actix workers now share the same Context. A production server can use the same structure with a database pool in place of the counter. Restart the server and run this mutation in GraphiQL:
mutation { first: increment(by: 2) second: increment(by: 3)}Top-level mutation fields complete serially in document order, so the response shows each successive counter value:
{ "data": { "first": 2, "second": 5 }}Running the mutation again continues from five. The counter returns to zero when
the server restarts. A real application can replace the Mutex<i32> with its
database or service state without changing the generated resolver contract.
6. Change the schema and rebuild
Section titled “6. Change the schema and rebuild”Stop the server with Ctrl+C, edit schema/schema.graphql, and run cargo build
again. Rebuilding adds new resolver stubs and retains existing resolver bodies.
Argument changes update generated Args; bodies that depend on removed or changed
fields need manual edits. Direct access to a removed argument stops synchronization
before resolver files are changed. For a retained field, Cargo updates type Output when
its result category, list wrapper, or nullability changes while preserving the
resolve body and parameter bindings. The body must still return the new Rust
type. New enum results use crate::generated::types::<Enum>. Deleting or
renaming a field removes an untouched generated stub; a user-edited resolver must
be removed or migrated explicitly. See Organizing Resolver Files
to split implementations across modules and review synchronization safety.
Implement new resolver stubs, then restart the server with cargo run. Refresh
GraphiQL to explore the updated schema and execute your new queries. Continue with
the Personnel Management System to add Object,
Interface, and Union results.