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
[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.
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: Stringmay benull.[Product!]!is a non-null list of non-null products.inputtypes are for arguments. Object types cannot be arguments.first: Int = 20declares a default value.
A query names fields, and the response mirrors it:
{ product(id: "1") { name priceCents } }{ "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
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(())
}{"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:
docker run --name storefront-pg -e POSTGRES_PASSWORD=postgres -p 5432:5432 -d postgres:16
export DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgresmigrations/001_init.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
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
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: IDbecomesid: ID!. A resolver returningasync_graphql::Resultturns anErrinto an entry inerrors. The?operator converts any error that implementsDisplay, such as a SQLx or parse error. first.clamp(1, 100)caps a client-controlled page size. The schema alone does not limitproducts(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.
mereads per-request data that nothing sets yet. Example 4 sets it.
src/main.rs
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:
{
"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
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.
catalog-graphql/
Cargo.toml
migrations/001_init.sql
src/main.rs
src/model.rs
src/schema.rsCargo.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
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
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
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
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:
curl -s localhost:8080/graphql -H 'content-type: application/json' \
-d '{"query":"{ products(first: 2) { id name priceCents status } }"}'{"data":{"products":[{"id":"1","name":"Notebook","priceCents":450,"status":"ACTIVE"},{"id":"2","name":"Pen","priceCents":120,"status":"ACTIVE"}]}}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"}'{"data":{"createProduct":{"id":"3","name":"Eraser"}}}curl -s localhost:8080/graphql -H 'content-type: application/json' -H 'x-user-id: 42' \
-d '{"query":"{ me }"}'{"data":{"me":"42"}}curl -s localhost:8080/graphql -H 'content-type: application/json' \
-d '{"query":"{ me }"}'{"data":{"me":null}}curl -s localhost:8080/graphql -H 'content-type: application/json' \
-d '{"query":"{ product(id: \"abc\") { name } }"}'{"data":{"product":null},"errors":[{"message":"invalid digit found in string","locations":[{"line":1,"column":3}],"path":["product"]}]}Summary
- SDL describes types, enums, inputs,
Query, andMutation. - 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.
SimpleObjectmaps struct fields, and#[Object]methods are resolvers.GraphQLRequestandGraphQLResponseconnect a schema to an Axum handler.- Shared values go in schema data. Per-request values such as the user go in request data.
