Guide contents

Lesson 22 of 30

GraphQL Basics: Schema, Queries, and Mutations with async-graphql

GraphQL is a query language for APIs. The client names the fields it wants, and the server returns JSON with the same shape. This lesson builds a Catalog GraphQL service with async-graphql, Axum, and Postgres.

Project setup

Cargo.toml

TOML
[package]
name = "catalog-graphql"
version = "0.1.0"
edition = "2021"

[dependencies]
async-graphql = "7.0"
async-graphql-axum = "7.0"
axum = "0.8"
serde_json = "1"
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

async-graphql-axum 7.0.17 depends on Axum 0.8.1 or newer, so its extractors work with the axum = "0.8" above. Examples 1 and 2 need no database. Examples 3 and 4 need Postgres.

Example 1: The schema in SDL

A GraphQL API has one endpoint, usually POST /graphql. The schema is the typed description of everything a client can ask for, written in the Schema Definition Language (SDL). The Query type lists reads, and the Mutation type lists writes.

GRAPHQL
type Product {
  id: ID!
  name: String!
  priceCents: Int!
  status: ProductStatus!
  description: String
}

enum ProductStatus {
  ACTIVE
  DISCONTINUED
}

type Query {
  product(id: ID!): Product
  products(first: Int = 20): [Product!]!
}

type Mutation {
  createProduct(input: CreateProductInput!): Product!
}

input CreateProductInput {
  name: String!
  priceCents: Int!
  description: String
}
  • String! is non-null. description: String may be null.
  • [Product!]! is a non-null list of non-null products.
  • input types are for arguments. Object types cannot be arguments.
  • first: Int = 20 declares a default value.

A query names fields, and the response mirrors it:

GRAPHQL
{ product(id: "1") { name priceCents } }
JSON
{ "data": { "product": { "name": "Notebook", "priceCents": 450 } } }

Fields that are not requested are not resolved. The client avoids over-fetching (receiving unused data), and one query can reach related objects, which avoids under-fetching (needing several calls). Every response has data, and errors when something failed. The HTTP status is normally 200 either way.

Example 2: A schema in Rust, no server

async-graphql is code-first: Rust types are the source, and derive macros generate the SDL. SimpleObject maps struct fields to fields. A resolver is the function that produces one field's value, and #[Object] turns each async method of a root type into a resolver.

src/main.rs

Rust
use async_graphql::{EmptyMutation, EmptySubscription, Enum, Object, Schema, SimpleObject, ID};

#[derive(Enum, Copy, Clone, Eq, PartialEq)]
enum ProductStatus {
    Active,
    Discontinued,
}

#[derive(SimpleObject, Clone)]
struct Product {
    id: ID,
    name: String,
    price_cents: i32,
    status: ProductStatus,
    description: Option<String>,
}

fn catalog() -> Vec<Product> {
    vec![
        Product {
            id: ID::from(1),
            name: "Notebook".into(),
            price_cents: 450,
            status: ProductStatus::Active,
            description: Some("A5 ruled".into()),
        },
        Product {
            id: ID::from(2),
            name: "Pen".into(),
            price_cents: 120,
            status: ProductStatus::Active,
            description: None,
        },
    ]
}

struct QueryRoot;

#[Object]
impl QueryRoot {
    async fn product(&self, id: ID) -> Option<Product> {
        catalog().into_iter().find(|product| product.id == id)
    }

    async fn products(&self, #[graphql(default = 20)] first: i32) -> Vec<Product> {
        catalog().into_iter().take(first.max(0) as usize).collect()
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let schema = Schema::new(QueryRoot, EmptyMutation, EmptySubscription);
    let response = schema
        .execute(r#"{ product(id: "1") { name priceCents status } }"#)
        .await;
    println!("{}", serde_json::to_string(&response)?);
    Ok(())
}
Output
{"data":{"product":{"name":"Notebook","priceCents":450,"status":"ACTIVE"}}}
Rust GraphQL
String, i32, bool, f64 String!, Int!, Boolean!, Float!
Option<T> nullable T
Vec<T> [T!]!
ID ID!

Field names convert from snake_case to camelCase (price_cents becomes priceCents), and enum variants to upper snake case. GraphQL Int is a 32-bit signed integer, so a wider value needs a custom scalar or a string. schema.sdl() prints the generated SDL, and committing it turns schema changes into visible review diffs.

Example 3: Postgres, mutations, and variables

The service now reads and writes Postgres, and main.rs serves the schema over HTTP. Start the database:

Terminal
docker run --name storefront-pg -e POSTGRES_PASSWORD=postgres -p 5432:5432 -d postgres:16
export DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres

migrations/001_init.sql

SQL
CREATE TABLE IF NOT EXISTS products (
    id BIGSERIAL PRIMARY KEY,
    name TEXT NOT NULL,
    price_cents INTEGER NOT NULL CHECK (price_cents >= 0),
    status TEXT NOT NULL DEFAULT 'active',
    description TEXT
);

INSERT INTO products (name, price_cents, status, description)
SELECT * FROM (VALUES
    ('Notebook', 450, 'active', 'A5 ruled'),
    ('Pen', 120, 'active', NULL)
) AS seed(name, price_cents, status, description)
WHERE NOT EXISTS (SELECT 1 FROM products);

The GraphQL types stay separate from the database row type. A column change then does not change the public schema by accident.

src/model.rs

Rust
use async_graphql::{Enum, InputObject, SimpleObject, ID};
use sqlx::FromRow;

#[derive(Enum, Copy, Clone, Eq, PartialEq)]
pub enum ProductStatus {
    Active,
    Discontinued,
}

#[derive(SimpleObject, Clone)]
pub struct Product {
    pub id: ID,
    pub name: String,
    pub price_cents: i32,
    pub status: ProductStatus,
    pub description: Option<String>,
}

#[derive(InputObject)]
pub struct CreateProductInput {
    pub name: String,
    pub price_cents: i32,
    pub description: Option<String>,
}

#[derive(FromRow)]
pub struct ProductRow {
    pub id: i64,
    pub name: String,
    pub price_cents: i32,
    pub status: String,
    pub description: Option<String>,
}

impl From<ProductRow> for Product {
    fn from(row: ProductRow) -> Self {
        Product {
            id: ID::from(row.id),
            name: row.name,
            price_cents: row.price_cents,
            status: if row.status == "discontinued" {
                ProductStatus::Discontinued
            } else {
                ProductStatus::Active
            },
            description: row.description,
        }
    }
}

#[derive(Clone)]
pub struct CurrentUser {
    pub id: i64,
}

src/schema.rs

Rust
use async_graphql::{Context, EmptySubscription, Object, Result, Schema, ID};
use sqlx::PgPool;

use crate::model::{CreateProductInput, CurrentUser, Product, ProductRow};

pub type StorefrontSchema = Schema<QueryRoot, MutationRoot, EmptySubscription>;

pub fn build_schema(pool: PgPool) -> StorefrontSchema {
    Schema::build(QueryRoot, MutationRoot, EmptySubscription)
        .data(pool)
        .finish()
}

pub struct QueryRoot;

#[Object]
impl QueryRoot {
    async fn product(&self, ctx: &Context<'_>, id: ID) -> Result<Option<Product>> {
        let pool = ctx.data::<PgPool>()?;
        let id: i64 = id.parse()?;
        let row = sqlx::query_as::<_, ProductRow>(
            "SELECT id, name, price_cents, status, description FROM products WHERE id = $1",
        )
        .bind(id)
        .fetch_optional(pool)
        .await?;
        Ok(row.map(Product::from))
    }

    async fn products(
        &self,
        ctx: &Context<'_>,
        #[graphql(default = 20)] first: i32,
    ) -> Result<Vec<Product>> {
        let pool = ctx.data::<PgPool>()?;
        let rows = sqlx::query_as::<_, ProductRow>(
            "SELECT id, name, price_cents, status, description FROM products ORDER BY id LIMIT $1",
        )
        .bind(i64::from(first.clamp(1, 100)))
        .fetch_all(pool)
        .await?;
        Ok(rows.into_iter().map(Product::from).collect())
    }

    async fn me(&self, ctx: &Context<'_>) -> Option<ID> {
        ctx.data_opt::<CurrentUser>().map(|user| ID::from(user.id))
    }
}

pub struct MutationRoot;

#[Object]
impl MutationRoot {
    async fn create_product(
        &self,
        ctx: &Context<'_>,
        input: CreateProductInput,
    ) -> Result<Product> {
        let pool = ctx.data::<PgPool>()?;
        if input.name.trim().is_empty() {
            return Err("name must not be empty".into());
        }
        let row = sqlx::query_as::<_, ProductRow>(
            "INSERT INTO products (name, price_cents, status, description)
             VALUES ($1, $2, 'active', $3)
             RETURNING id, name, price_cents, status, description",
        )
        .bind(&input.name)
        .bind(input.price_cents)
        .bind(&input.description)
        .fetch_one(pool)
        .await?;
        Ok(Product::from(row))
    }
}
  • The resolver argument id: ID becomes id: ID!. A resolver returning async_graphql::Result turns an Err into an entry in errors. The ? operator converts any error that implements Display, such as a SQLx or parse error.
  • first.clamp(1, 100) caps a client-controlled page size. The schema alone does not limit products(first: 1000000).
  • ctx.data::<PgPool>() reads a value stored on the schema with .data(...), and returns an error if the type was never registered.
  • Top-level mutation fields run one after another in the order written. Query fields may run concurrently.
  • me reads per-request data that nothing sets yet. Example 4 sets it.

src/main.rs

Rust
mod model;
mod schema;

use async_graphql_axum::{GraphQLRequest, GraphQLResponse};
use axum::{extract::State, routing::post, Router};
use schema::{build_schema, StorefrontSchema};
use sqlx::PgPool;

async fn graphql_handler(
    State(schema): State<StorefrontSchema>,
    request: GraphQLRequest,
) -> GraphQLResponse {
    schema.execute(request.into_inner()).await.into()
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let pool = PgPool::connect(&std::env::var("DATABASE_URL")?).await?;
    sqlx::raw_sql(include_str!("../migrations/001_init.sql"))
        .execute(&pool)
        .await?;

    let app = Router::new()
        .route("/graphql", post(graphql_handler))
        .with_state(build_schema(pool));

    let listener = tokio::net::TcpListener::bind("127.0.0.1:8080").await?;
    axum::serve(listener, app).await?;
    Ok(())
}

Schema is cheap to clone, so it works as Axum state. The route sits behind the same middleware as any Axum route (Tower middleware). The SQLx setup follows SQLx and Postgres, and mutations that clients may retry need the idempotency approach from SQL transactions.

Variables are typed parameters sent beside the query text. The text stays constant, so servers can cache and log it, and no string building (and no injection) is involved:

JSON
{
  "query": "mutation Create($input: CreateProductInput!) { createProduct(input: $input) { id name } }",
  "variables": { "input": { "name": "Eraser", "priceCents": 80 } },
  "operationName": "Create"
}

Example 4: Per-request context

Schema data holds values shared by all requests, such as the pool. Values that differ per request, such as the user, are added to the Request before execution. This handler reads an x-user-id header. A real service validates a token instead (authentication and authorization), and authorization inside resolvers is covered in DataLoader, errors and limits.

src/main.rs

Rust
mod model;
mod schema;

use async_graphql_axum::{GraphQLRequest, GraphQLResponse};
use axum::{extract::State, http::HeaderMap, routing::post, Router};
use model::CurrentUser;
use schema::{build_schema, StorefrontSchema};
use sqlx::PgPool;

fn user_from_headers(headers: &HeaderMap) -> Option<CurrentUser> {
    let id = headers.get("x-user-id")?.to_str().ok()?.parse().ok()?;
    Some(CurrentUser { id })
}

async fn graphql_handler(
    State(schema): State<StorefrontSchema>,
    headers: HeaderMap,
    request: GraphQLRequest,
) -> GraphQLResponse {
    let mut request = request.into_inner();
    if let Some(user) = user_from_headers(&headers) {
        request = request.data(user);
    }
    schema.execute(request).await.into()
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let pool = PgPool::connect(&std::env::var("DATABASE_URL")?).await?;
    sqlx::raw_sql(include_str!("../migrations/001_init.sql"))
        .execute(&pool)
        .await?;

    let app = Router::new()
        .route("/graphql", post(graphql_handler))
        .with_state(build_schema(pool));

    let listener = tokio::net::TcpListener::bind("127.0.0.1:8080").await?;
    axum::serve(listener, app).await?;
    Ok(())
}

A resolver reads it with ctx.data_opt::<CurrentUser>(), which returns None for anonymous requests, or ctx.data::<CurrentUser>()?, which fails when the value is absent.

Context source Set with Lifetime
Schema data Schema::build(...).data(x) The process
Request data request.data(x) One request

Complete example

The service from Examples 3 and 4 with its migration.

Text
catalog-graphql/
  Cargo.toml
  migrations/001_init.sql
  src/main.rs
  src/model.rs
  src/schema.rs

Cargo.toml

TOML
[package]
name = "catalog-graphql"
version = "0.1.0"
edition = "2021"

[dependencies]
async-graphql = "7.0"
async-graphql-axum = "7.0"
axum = "0.8"
serde_json = "1"
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

migrations/001_init.sql

SQL
CREATE TABLE IF NOT EXISTS products (
    id BIGSERIAL PRIMARY KEY,
    name TEXT NOT NULL,
    price_cents INTEGER NOT NULL CHECK (price_cents >= 0),
    status TEXT NOT NULL DEFAULT 'active',
    description TEXT
);

INSERT INTO products (name, price_cents, status, description)
SELECT * FROM (VALUES
    ('Notebook', 450, 'active', 'A5 ruled'),
    ('Pen', 120, 'active', NULL)
) AS seed(name, price_cents, status, description)
WHERE NOT EXISTS (SELECT 1 FROM products);

src/main.rs

Rust
mod model;
mod schema;

use async_graphql_axum::{GraphQLRequest, GraphQLResponse};
use axum::{extract::State, http::HeaderMap, routing::post, Router};
use model::CurrentUser;
use schema::{build_schema, StorefrontSchema};
use sqlx::PgPool;

fn user_from_headers(headers: &HeaderMap) -> Option<CurrentUser> {
    let id = headers.get("x-user-id")?.to_str().ok()?.parse().ok()?;
    Some(CurrentUser { id })
}

async fn graphql_handler(
    State(schema): State<StorefrontSchema>,
    headers: HeaderMap,
    request: GraphQLRequest,
) -> GraphQLResponse {
    let mut request = request.into_inner();
    if let Some(user) = user_from_headers(&headers) {
        request = request.data(user);
    }
    schema.execute(request).await.into()
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let pool = PgPool::connect(&std::env::var("DATABASE_URL")?).await?;
    sqlx::raw_sql(include_str!("../migrations/001_init.sql"))
        .execute(&pool)
        .await?;

    let app = Router::new()
        .route("/graphql", post(graphql_handler))
        .with_state(build_schema(pool));

    let listener = tokio::net::TcpListener::bind("127.0.0.1:8080").await?;
    axum::serve(listener, app).await?;
    Ok(())
}

src/model.rs

Rust
use async_graphql::{Enum, InputObject, SimpleObject, ID};
use sqlx::FromRow;

#[derive(Enum, Copy, Clone, Eq, PartialEq)]
pub enum ProductStatus {
    Active,
    Discontinued,
}

#[derive(SimpleObject, Clone)]
pub struct Product {
    pub id: ID,
    pub name: String,
    pub price_cents: i32,
    pub status: ProductStatus,
    pub description: Option<String>,
}

#[derive(InputObject)]
pub struct CreateProductInput {
    pub name: String,
    pub price_cents: i32,
    pub description: Option<String>,
}

#[derive(FromRow)]
pub struct ProductRow {
    pub id: i64,
    pub name: String,
    pub price_cents: i32,
    pub status: String,
    pub description: Option<String>,
}

impl From<ProductRow> for Product {
    fn from(row: ProductRow) -> Self {
        Product {
            id: ID::from(row.id),
            name: row.name,
            price_cents: row.price_cents,
            status: if row.status == "discontinued" {
                ProductStatus::Discontinued
            } else {
                ProductStatus::Active
            },
            description: row.description,
        }
    }
}

#[derive(Clone)]
pub struct CurrentUser {
    pub id: i64,
}

src/schema.rs

Rust
use async_graphql::{Context, EmptySubscription, Object, Result, Schema, ID};
use sqlx::PgPool;

use crate::model::{CreateProductInput, CurrentUser, Product, ProductRow};

pub type StorefrontSchema = Schema<QueryRoot, MutationRoot, EmptySubscription>;

pub fn build_schema(pool: PgPool) -> StorefrontSchema {
    Schema::build(QueryRoot, MutationRoot, EmptySubscription)
        .data(pool)
        .finish()
}

pub struct QueryRoot;

#[Object]
impl QueryRoot {
    async fn product(&self, ctx: &Context<'_>, id: ID) -> Result<Option<Product>> {
        let pool = ctx.data::<PgPool>()?;
        let id: i64 = id.parse()?;
        let row = sqlx::query_as::<_, ProductRow>(
            "SELECT id, name, price_cents, status, description FROM products WHERE id = $1",
        )
        .bind(id)
        .fetch_optional(pool)
        .await?;
        Ok(row.map(Product::from))
    }

    async fn products(
        &self,
        ctx: &Context<'_>,
        #[graphql(default = 20)] first: i32,
    ) -> Result<Vec<Product>> {
        let pool = ctx.data::<PgPool>()?;
        let rows = sqlx::query_as::<_, ProductRow>(
            "SELECT id, name, price_cents, status, description FROM products ORDER BY id LIMIT $1",
        )
        .bind(i64::from(first.clamp(1, 100)))
        .fetch_all(pool)
        .await?;
        Ok(rows.into_iter().map(Product::from).collect())
    }

    async fn me(&self, ctx: &Context<'_>) -> Option<ID> {
        ctx.data_opt::<CurrentUser>().map(|user| ID::from(user.id))
    }
}

pub struct MutationRoot;

#[Object]
impl MutationRoot {
    async fn create_product(
        &self,
        ctx: &Context<'_>,
        input: CreateProductInput,
    ) -> Result<Product> {
        let pool = ctx.data::<PgPool>()?;
        if input.name.trim().is_empty() {
            return Err("name must not be empty".into());
        }
        let row = sqlx::query_as::<_, ProductRow>(
            "INSERT INTO products (name, price_cents, status, description)
             VALUES ($1, $2, 'active', $3)
             RETURNING id, name, price_cents, status, description",
        )
        .bind(&input.name)
        .bind(input.price_cents)
        .bind(&input.description)
        .fetch_one(pool)
        .await?;
        Ok(Product::from(row))
    }
}

Run

Start Postgres and set DATABASE_URL as in Example 3, then run cargo run. On a fresh database the seed rows get ids 1 and 2. Requests:

Terminal
curl -s localhost:8080/graphql -H 'content-type: application/json' \
  -d '{"query":"{ products(first: 2) { id name priceCents status } }"}'
Output
{"data":{"products":[{"id":"1","name":"Notebook","priceCents":450,"status":"ACTIVE"},{"id":"2","name":"Pen","priceCents":120,"status":"ACTIVE"}]}}
Terminal
curl -s localhost:8080/graphql -H 'content-type: application/json' \
  -d '{"query":"mutation Create($input: CreateProductInput!) { createProduct(input: $input) { id name } }","variables":{"input":{"name":"Eraser","priceCents":80}},"operationName":"Create"}'
Output
{"data":{"createProduct":{"id":"3","name":"Eraser"}}}
Terminal
curl -s localhost:8080/graphql -H 'content-type: application/json' -H 'x-user-id: 42' \
  -d '{"query":"{ me }"}'
Output
{"data":{"me":"42"}}
Terminal
curl -s localhost:8080/graphql -H 'content-type: application/json' \
  -d '{"query":"{ me }"}'
Output
{"data":{"me":null}}
Terminal
curl -s localhost:8080/graphql -H 'content-type: application/json' \
  -d '{"query":"{ product(id: \"abc\") { name } }"}'
Output
{"data":{"product":null},"errors":[{"message":"invalid digit found in string","locations":[{"line":1,"column":3}],"path":["product"]}]}

Summary

  • SDL describes types, enums, inputs, Query, and Mutation.
  • Queries return only the requested fields, and related objects are reachable in one request.
  • ! marks non-null, and [T!]! is a non-null list of non-null items.
  • Mutations change state, run serially at the top level, and return the new object.
  • Variables carry typed parameters outside the query text.
  • SimpleObject maps struct fields, and #[Object] methods are resolvers.
  • GraphQLRequest and GraphQLResponse connect a schema to an Axum handler.
  • Shared values go in schema data. Per-request values such as the user go in request data.