Lesson 23 of 30
GraphQL DataLoader, Errors, Limits, and Auth
A GraphQL server runs one resolver per requested field, and the client chooses the query shape. Two risks follow: nested resolvers issue one database query per parent, and a client can send an expensive query. This lesson extends the service from GraphQL basics with reviews, a DataLoader, error codes, limits, persisted queries, and authorization.
Project setup
The dataloader feature of async-graphql enables the loader types. Start Postgres and set the connection string:
docker run --name storefront-pg -e POSTGRES_PASSWORD=postgres -p 5432:5432 -d postgres:16
export DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgresCargo.toml
[package]
name = "catalog-graphql-reviews"
version = "0.1.0"
edition = "2021"
[dependencies]
async-graphql = { version = "7.0", features = ["dataloader"] }
async-graphql-axum = "7.0"
axum = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres"] }
thiserror = "2"
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
);
CREATE TABLE IF NOT EXISTS reviews (
id BIGSERIAL PRIMARY KEY,
product_id BIGINT NOT NULL REFERENCES products (id),
author_id BIGINT NOT NULL,
rating INTEGER NOT NULL CHECK (rating BETWEEN 1 AND 5),
body TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS reviews_product_id_idx ON reviews (product_id);
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);
INSERT INTO reviews (product_id, author_id, rating, body)
SELECT p.id, 1, v.rating, v.body
FROM (VALUES
(1, 'Notebook', 5, 'Great paper'),
(2, 'Notebook', 4, 'Good binding'),
(3, 'Pen', 3, 'Average')
) AS v(seq, name, rating, body)
JOIN products p ON p.name = v.name
WHERE NOT EXISTS (SELECT 1 FROM reviews)
ORDER BY v.seq;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)]
#[graphql(complex)]
pub struct Product {
pub id: ID,
pub name: String,
pub price_cents: i32,
pub status: ProductStatus,
pub description: Option<String>,
}
#[derive(SimpleObject, Clone)]
pub struct Review {
pub id: ID,
pub rating: i32,
pub body: String,
}
#[derive(InputObject)]
pub struct CreateReviewInput {
pub product_id: ID,
pub rating: i32,
pub body: String,
}
#[derive(FromRow)]
pub struct ProductRow {
pub id: i64,
pub name: String,
pub price_cents: i32,
pub status: String,
pub description: Option<String>,
}
#[derive(FromRow)]
pub struct ReviewRow {
pub id: i64,
pub product_id: i64,
pub rating: i32,
pub body: 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,
}
}
}
impl From<ReviewRow> for Review {
fn from(row: ReviewRow) -> Self {
Review {
id: ID::from(row.id),
rating: row.rating,
body: row.body,
}
}
}
#[derive(Clone)]
pub struct CurrentUser {
pub id: i64,
pub is_admin: bool,
}src/schema.rs
use async_graphql::{EmptyMutation, EmptySubscription, MergedObject, Schema};
use sqlx::PgPool;
use crate::product::ProductQuery;
#[derive(MergedObject, Default)]
pub struct QueryRoot(ProductQuery);
pub type StorefrontSchema = Schema<QueryRoot, EmptyMutation, EmptySubscription>;
pub fn build_schema(pool: PgPool) -> StorefrontSchema {
Schema::build(QueryRoot::default(), EmptyMutation, EmptySubscription)
.data(pool)
.finish()
}MergedObject combines several object types into one root, so each feature lives in its own file.
Example 1: The N+1 problem
The query below lists 20 products with their reviews.
{ products(first: 20) { name reviews { rating } } }The naive Product.reviews resolver queries the database for its own product. #[ComplexObject] adds resolver methods to a SimpleObject marked #[graphql(complex)].
src/product.rs
use async_graphql::{ComplexObject, Context, Object, Result, ID};
use sqlx::PgPool;
use crate::model::{Product, ProductRow, Review, ReviewRow};
#[derive(Default)]
pub struct ProductQuery;
#[Object]
impl ProductQuery {
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())
}
}
#[ComplexObject]
impl Product {
async fn reviews(&self, ctx: &Context<'_>) -> Result<Vec<Review>> {
let pool = ctx.data::<PgPool>()?;
let id: i64 = self.id.parse()?;
let rows = sqlx::query_as::<_, ReviewRow>(
"SELECT id, product_id, rating, body FROM reviews WHERE product_id = $1 ORDER BY id",
)
.bind(id)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(Review::from).collect())
}
}The executor calls Product.reviews once per product: one query for the list plus 20 for reviews, 21 round trips. The same problem exists for REST (database relationships), but there the developer picks the query shape. In GraphQL the client picks the nested fields, so a resolver cannot know which loading strategy fits.
Example 2: Batch with DataLoader
A DataLoader collects the keys requested within a short window and calls one batch function with all of them. Each caller receives its own value. The loader implements load, which takes a slice of keys and returns a map from key to value.
src/loaders.rs
use std::{collections::HashMap, sync::Arc};
use async_graphql::dataloader::Loader;
use sqlx::PgPool;
use crate::model::{Review, ReviewRow};
pub struct ReviewLoader {
pub pool: PgPool,
}
impl Loader<i64> for ReviewLoader {
type Value = Vec<Review>;
type Error = Arc<sqlx::Error>;
async fn load(&self, keys: &[i64]) -> Result<HashMap<i64, Self::Value>, Self::Error> {
let rows = sqlx::query_as::<_, ReviewRow>(
"SELECT id, product_id, rating, body FROM reviews
WHERE product_id = ANY($1) ORDER BY id",
)
.bind(keys)
.fetch_all(&self.pool)
.await
.map_err(Arc::new)?;
let mut grouped: HashMap<i64, Vec<Review>> = HashMap::new();
for row in rows {
grouped
.entry(row.product_id)
.or_default()
.push(Review::from(row));
}
Ok(grouped)
}
}ANY($1)sends the whole key list as one Postgres array, so 20 keys need one query.- The error type is
Arc<...>because one batch error goes to every waiting caller, so it must be cloneable. - A key with no rows has no map entry. The resolver handles that with
unwrap_or_default().
Product.reviews now asks the loader instead of the database. In src/product.rs, add dataloader::DataLoader to the async_graphql imports, loaders::ReviewLoader to the crate imports, and replace the Product impl with:
#[ComplexObject]
impl Product {
async fn reviews(&self, ctx: &Context<'_>) -> Result<Vec<Review>> {
let loader = ctx.data::<DataLoader<ReviewLoader>>()?;
let id: i64 = self.id.parse()?;
Ok(loader.load_one(id).await?.unwrap_or_default())
}
}load_one returns Result<Option<Vec<Review>>, Arc<sqlx::Error>>. The ? converts the Arc<sqlx::Error> into a GraphQL error because it implements Display.
DataLoader::new(loader, spawner) takes the loader and a function that spawns the batching task, here tokio::spawn. It waits 1 ms after the first key (the default delay) to collect keys. new does not cache results. DataLoader::with_cache(loader, spawner, HashMapCache::default()) adds a per-key cache for the loader's lifetime. A cache that outlives a request could serve stale data or leak one user's rows to another, so the server creates a fresh loader for each request and adds it as request data. In src/main.rs (full file in the Complete example), the with_context function does this:
fn with_context(request: Request, state: &AppState, headers: &HeaderMap) -> Request {
let loader = DataLoader::new(
ReviewLoader {
pool: state.pool.clone(),
},
tokio::spawn,
);
let mut request = request.data(loader);
if let Some(user) = user_from_headers(headers) {
request = request.data(user);
}
request
}The 20 resolver calls each ask for one key. The loader gathers the keys, runs one load, and answers each call: two queries instead of 21. One loader per (entity, key) pair covers other relations, such as the author of each review.
Example 3: Errors with codes
A response can hold both data and errors. A failed field becomes null, and if that field is non-null (!), the null moves up to the nearest nullable parent. Each error has message, locations, path, and an extensions map, the standard place for a machine-readable code. Validation errors (unknown field, wrong argument type) stop execution and return no data. Execution errors leave partial data. Clients read errors even when the HTTP status is 200, and switch on extensions.code, never on message text.
Raw database messages can reveal table names, so db_error logs the detail and returns a generic message.
src/errors.rs
use async_graphql::{Error, ErrorExtensions, Result, ID};
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("{0} {1} not found")]
NotFound(&'static str, i64),
#[error("{0}")]
BadInput(String),
#[error("authentication required")]
Unauthenticated,
#[error("not allowed")]
Forbidden,
}
impl ErrorExtensions for AppError {
fn extend(&self) -> Error {
let code = match self {
AppError::NotFound(..) => "NOT_FOUND",
AppError::BadInput(_) => "BAD_USER_INPUT",
AppError::Unauthenticated => "UNAUTHENTICATED",
AppError::Forbidden => "FORBIDDEN",
};
Error::new(self.to_string()).extend_with(|_, extensions| extensions.set("code", code))
}
}
pub fn db_error(error: sqlx::Error) -> Error {
eprintln!("database error: {error}");
Error::new("internal error").extend_with(|_, extensions| extensions.set("code", "INTERNAL"))
}
pub fn parse_id(id: &ID) -> Result<i64> {
id.parse()
.map_err(|_| AppError::BadInput(format!("invalid id {}", id.as_str())).extend())
}src/product.rs changes in three places (full file in the Complete example):
- The imports add
errors::{db_error, parse_id}. - The
productandproductsqueries callparse_id(&id)?and.map_err(db_error)on the SQLx calls. productsgets a complexity attribute, andreviewsmaps the loader error through a small helper:
#[graphql(complexity = "first.max(1) as usize * child_complexity")]
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
.map_err(db_error)?;
Ok(rows.into_iter().map(Product::from).collect())
}#[ComplexObject]
impl Product {
async fn reviews(&self, ctx: &Context<'_>) -> Result<Vec<Review>> {
let loader = ctx.data::<DataLoader<ReviewLoader>>()?;
let id = parse_id(&self.id)?;
Ok(loader
.load_one(id)
.await
.map_err(|error| db_error_from_arc(&error))?
.unwrap_or_default())
}
}
fn db_error_from_arc(error: &std::sync::Arc<sqlx::Error>) -> async_graphql::Error {
eprintln!("database error: {error}");
async_graphql::Error::new("internal error")
}The complexity attribute on products makes its cost the page size times the cost of the selected child fields, so first: 100 costs more than first: 5. Without it, each field costs 1. Expected failures that clients handle, such as out of stock, can also be modeled in the schema as a union (union CreateOrderResult = Order | OutOfStock), which documents them. Unexpected failures use errors.
Example 4: Depth, complexity, and persisted queries
A cyclic schema (Product.reviews and Review.product) allows unbounded nesting, and each level multiplies the work. Two limits apply before any resolver runs, so a rejected query costs almost nothing:
- The depth limit caps nesting levels.
- The complexity limit caps the sum of field costs, with list arguments as multipliers.
src/schema.rs
use async_graphql::{EmptyMutation, EmptySubscription, MergedObject, Schema};
use sqlx::PgPool;
use crate::product::ProductQuery;
#[derive(MergedObject, Default)]
pub struct QueryRoot(ProductQuery);
pub type StorefrontSchema = Schema<QueryRoot, EmptyMutation, EmptySubscription>;
pub fn build_schema(pool: PgPool) -> StorefrontSchema {
Schema::build(QueryRoot::default(), EmptyMutation, EmptySubscription)
.data(pool)
.limit_depth(5)
.limit_complexity(200)
.finish()
}Tune both numbers against the queries real clients send, and log rejections to see whether legitimate clients hit them. A body size limit and a timeout (Tower middleware) and per-client rate limits (resilience patterns) belong at the HTTP layer. disable_introspection() on the builder hides the schema from casual discovery but is not an access control.
A persisted query is stored on the server and referenced by an id, so the client sends the id instead of the text. Two variants exist:
- Automatic persisted queries send a hash, and the server asks for the text once on a miss. This saves bandwidth only, since any query is still accepted. async-graphql has an Apollo-style extension for it, with a pluggable cache.
- An allow list (trusted documents) accepts only ids registered at build time. It rejects unknown queries, and suits first-party clients.
src/main.rs adds an allow-list route, /graphql/persisted, that maps an id to query text before executing. The pieces below are added to main.rs (full file in the Complete example). with_context is the function from Example 2, and graphql_handler calls it.
#[derive(Clone)]
struct AppState {
schema: StorefrontSchema,
pool: PgPool,
allowed: Arc<HashMap<&'static str, &'static str>>,
}
#[derive(Deserialize)]
struct PersistedRequest {
id: String,
#[serde(default)]
variables: serde_json::Value,
}
async fn persisted_handler(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<PersistedRequest>,
) -> Response {
let Some(text) = state.allowed.get(body.id.as_str()) else {
return (StatusCode::BAD_REQUEST, "unknown query id").into_response();
};
let request = Request::new(*text).variables(Variables::from_json(body.variables));
let request = with_context(request, &state, &headers);
GraphQLResponse::from(state.schema.execute(request).await).into_response()
}The router gets .route("/graphql/persisted", post(persisted_handler)), and main fills the allow list with allowed.insert("products-with-reviews", "query ProductsWithReviews { products { name reviews { rating } } }").
Example 5: Authorization in resolvers
Authentication runs once per request in the handler, and the user goes into request data. Authorization runs per field, because one query can reach data with different rules (authentication and authorization). The helpers below return the user or an error with a stable code.
src/auth.rs
use async_graphql::{Context, ErrorExtensions, Result};
use crate::{errors::AppError, model::CurrentUser};
pub fn require_user<'a>(ctx: &Context<'a>) -> Result<&'a CurrentUser> {
ctx.data_opt::<CurrentUser>()
.ok_or_else(|| AppError::Unauthenticated.extend())
}
pub fn require_admin<'a>(ctx: &Context<'a>) -> Result<&'a CurrentUser> {
let user = require_user(ctx)?;
if user.is_admin {
Ok(user)
} else {
Err(AppError::Forbidden.extend())
}
}src/review.rs
use async_graphql::{Context, ErrorExtensions, Object, Result, ID};
use sqlx::PgPool;
use crate::{
auth::{require_admin, require_user},
errors::{db_error, parse_id, AppError},
model::{CreateReviewInput, Review, ReviewRow},
};
#[derive(Default)]
pub struct ReviewMutation;
#[Object]
impl ReviewMutation {
async fn create_review(&self, ctx: &Context<'_>, input: CreateReviewInput) -> Result<Review> {
let user = require_user(ctx)?;
if !(1..=5).contains(&input.rating) {
return Err(AppError::BadInput("rating must be 1 to 5".into()).extend());
}
let product_id = parse_id(&input.product_id)?;
let pool = ctx.data::<PgPool>()?;
let row = sqlx::query_as::<_, ReviewRow>(
"INSERT INTO reviews (product_id, author_id, rating, body)
SELECT $1::bigint, $2::bigint, $3::int, $4::text
WHERE EXISTS (SELECT 1 FROM products WHERE id = $1::bigint)
RETURNING id, product_id, rating, body",
)
.bind(product_id)
.bind(user.id)
.bind(input.rating)
.bind(&input.body)
.fetch_optional(pool)
.await
.map_err(db_error)?;
row.map(Review::from)
.ok_or_else(|| AppError::NotFound("product", product_id).extend())
}
async fn delete_review(&self, ctx: &Context<'_>, id: ID) -> Result<bool> {
require_admin(ctx)?;
let id = parse_id(&id)?;
let pool = ctx.data::<PgPool>()?;
let result = sqlx::query("DELETE FROM reviews WHERE id = $1")
.bind(id)
.execute(pool)
.await
.map_err(db_error)?;
Ok(result.rows_affected() > 0)
}
}src/schema.rs replaces EmptyMutation with MutationRoot, a MergedObject over ReviewMutation. main.rs adds mod auth;, mod errors;, and mod review;. Both files are in the Complete example.
Rules for field-level checks:
- Check on every path that returns the object. A query can reach an order through
me { orders },order(id:), or a nested field on another type. - Check before loading related data, so a denied caller causes no queries.
- A loader returns rows for any key. The resolver that uses a row still authorizes it.
async-graphql also has a Guard trait for checks that repeat on many fields. The helpers above cover the same need without depending on its trait signature.
Complete example
The final service after Example 5. Every file is complete.
catalog-graphql-reviews/
Cargo.toml
migrations/001_init.sql
src/main.rs
src/model.rs
src/schema.rs
src/product.rs
src/loaders.rs
src/errors.rs
src/auth.rs
src/review.rsCargo.toml
[package]
name = "catalog-graphql-reviews"
version = "0.1.0"
edition = "2021"
[dependencies]
async-graphql = { version = "7.0", features = ["dataloader"] }
async-graphql-axum = "7.0"
axum = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres"] }
thiserror = "2"
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
);
CREATE TABLE IF NOT EXISTS reviews (
id BIGSERIAL PRIMARY KEY,
product_id BIGINT NOT NULL REFERENCES products (id),
author_id BIGINT NOT NULL,
rating INTEGER NOT NULL CHECK (rating BETWEEN 1 AND 5),
body TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS reviews_product_id_idx ON reviews (product_id);
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);
INSERT INTO reviews (product_id, author_id, rating, body)
SELECT p.id, 1, v.rating, v.body
FROM (VALUES
(1, 'Notebook', 5, 'Great paper'),
(2, 'Notebook', 4, 'Good binding'),
(3, 'Pen', 3, 'Average')
) AS v(seq, name, rating, body)
JOIN products p ON p.name = v.name
WHERE NOT EXISTS (SELECT 1 FROM reviews)
ORDER BY v.seq;src/main.rs
mod auth;
mod errors;
mod loaders;
mod model;
mod product;
mod review;
mod schema;
use std::{collections::HashMap, sync::Arc};
use async_graphql::{dataloader::DataLoader, Request, Variables};
use async_graphql_axum::{GraphQLRequest, GraphQLResponse};
use axum::{
extract::State,
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
routing::post,
Json, Router,
};
use loaders::ReviewLoader;
use model::CurrentUser;
use schema::{build_schema, StorefrontSchema};
use serde::Deserialize;
use sqlx::PgPool;
#[derive(Clone)]
struct AppState {
schema: StorefrontSchema,
pool: PgPool,
allowed: Arc<HashMap<&'static str, &'static str>>,
}
#[derive(Deserialize)]
struct PersistedRequest {
id: String,
#[serde(default)]
variables: serde_json::Value,
}
fn user_from_headers(headers: &HeaderMap) -> Option<CurrentUser> {
let id = headers.get("x-user-id")?.to_str().ok()?.parse().ok()?;
let is_admin = headers
.get("x-role")
.and_then(|value| value.to_str().ok())
== Some("admin");
Some(CurrentUser { id, is_admin })
}
fn with_context(request: Request, state: &AppState, headers: &HeaderMap) -> Request {
let loader = DataLoader::new(
ReviewLoader {
pool: state.pool.clone(),
},
tokio::spawn,
);
let mut request = request.data(loader);
if let Some(user) = user_from_headers(headers) {
request = request.data(user);
}
request
}
async fn graphql_handler(
State(state): State<AppState>,
headers: HeaderMap,
request: GraphQLRequest,
) -> GraphQLResponse {
let request = with_context(request.into_inner(), &state, &headers);
state.schema.execute(request).await.into()
}
async fn persisted_handler(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<PersistedRequest>,
) -> Response {
let Some(text) = state.allowed.get(body.id.as_str()) else {
return (StatusCode::BAD_REQUEST, "unknown query id").into_response();
};
let request = Request::new(*text).variables(Variables::from_json(body.variables));
let request = with_context(request, &state, &headers);
GraphQLResponse::from(state.schema.execute(request).await).into_response()
}
#[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 mut allowed = HashMap::new();
allowed.insert(
"products-with-reviews",
"query ProductsWithReviews { products { name reviews { rating } } }",
);
let state = AppState {
schema: build_schema(pool.clone()),
pool,
allowed: Arc::new(allowed),
};
let app = Router::new()
.route("/graphql", post(graphql_handler))
.route("/graphql/persisted", post(persisted_handler))
.with_state(state);
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)]
#[graphql(complex)]
pub struct Product {
pub id: ID,
pub name: String,
pub price_cents: i32,
pub status: ProductStatus,
pub description: Option<String>,
}
#[derive(SimpleObject, Clone)]
pub struct Review {
pub id: ID,
pub rating: i32,
pub body: String,
}
#[derive(InputObject)]
pub struct CreateReviewInput {
pub product_id: ID,
pub rating: i32,
pub body: String,
}
#[derive(FromRow)]
pub struct ProductRow {
pub id: i64,
pub name: String,
pub price_cents: i32,
pub status: String,
pub description: Option<String>,
}
#[derive(FromRow)]
pub struct ReviewRow {
pub id: i64,
pub product_id: i64,
pub rating: i32,
pub body: 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,
}
}
}
impl From<ReviewRow> for Review {
fn from(row: ReviewRow) -> Self {
Review {
id: ID::from(row.id),
rating: row.rating,
body: row.body,
}
}
}
#[derive(Clone)]
pub struct CurrentUser {
pub id: i64,
pub is_admin: bool,
}src/schema.rs
use async_graphql::{EmptySubscription, MergedObject, Schema};
use sqlx::PgPool;
use crate::{product::ProductQuery, review::ReviewMutation};
#[derive(MergedObject, Default)]
pub struct QueryRoot(ProductQuery);
#[derive(MergedObject, Default)]
pub struct MutationRoot(ReviewMutation);
pub type StorefrontSchema = Schema<QueryRoot, MutationRoot, EmptySubscription>;
pub fn build_schema(pool: PgPool) -> StorefrontSchema {
Schema::build(QueryRoot::default(), MutationRoot::default(), EmptySubscription)
.data(pool)
.limit_depth(5)
.limit_complexity(200)
.finish()
}src/product.rs
use async_graphql::{dataloader::DataLoader, ComplexObject, Context, Object, Result, ID};
use sqlx::PgPool;
use crate::{
errors::{db_error, parse_id},
loaders::ReviewLoader,
model::{Product, ProductRow, Review},
};
#[derive(Default)]
pub struct ProductQuery;
#[Object]
impl ProductQuery {
async fn product(&self, ctx: &Context<'_>, id: ID) -> Result<Option<Product>> {
let pool = ctx.data::<PgPool>()?;
let id = parse_id(&id)?;
let row = sqlx::query_as::<_, ProductRow>(
"SELECT id, name, price_cents, status, description FROM products WHERE id = $1",
)
.bind(id)
.fetch_optional(pool)
.await
.map_err(db_error)?;
Ok(row.map(Product::from))
}
#[graphql(complexity = "first.max(1) as usize * child_complexity")]
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
.map_err(db_error)?;
Ok(rows.into_iter().map(Product::from).collect())
}
}
#[ComplexObject]
impl Product {
async fn reviews(&self, ctx: &Context<'_>) -> Result<Vec<Review>> {
let loader = ctx.data::<DataLoader<ReviewLoader>>()?;
let id = parse_id(&self.id)?;
Ok(loader
.load_one(id)
.await
.map_err(|error| db_error_from_arc(&error))?
.unwrap_or_default())
}
}
fn db_error_from_arc(error: &std::sync::Arc<sqlx::Error>) -> async_graphql::Error {
eprintln!("database error: {error}");
async_graphql::Error::new("internal error")
}src/loaders.rs
use std::{collections::HashMap, sync::Arc};
use async_graphql::dataloader::Loader;
use sqlx::PgPool;
use crate::model::{Review, ReviewRow};
pub struct ReviewLoader {
pub pool: PgPool,
}
impl Loader<i64> for ReviewLoader {
type Value = Vec<Review>;
type Error = Arc<sqlx::Error>;
async fn load(&self, keys: &[i64]) -> Result<HashMap<i64, Self::Value>, Self::Error> {
let rows = sqlx::query_as::<_, ReviewRow>(
"SELECT id, product_id, rating, body FROM reviews
WHERE product_id = ANY($1) ORDER BY id",
)
.bind(keys)
.fetch_all(&self.pool)
.await
.map_err(Arc::new)?;
let mut grouped: HashMap<i64, Vec<Review>> = HashMap::new();
for row in rows {
grouped
.entry(row.product_id)
.or_default()
.push(Review::from(row));
}
Ok(grouped)
}
}src/errors.rs
use async_graphql::{Error, ErrorExtensions, Result, ID};
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("{0} {1} not found")]
NotFound(&'static str, i64),
#[error("{0}")]
BadInput(String),
#[error("authentication required")]
Unauthenticated,
#[error("not allowed")]
Forbidden,
}
impl ErrorExtensions for AppError {
fn extend(&self) -> Error {
let code = match self {
AppError::NotFound(..) => "NOT_FOUND",
AppError::BadInput(_) => "BAD_USER_INPUT",
AppError::Unauthenticated => "UNAUTHENTICATED",
AppError::Forbidden => "FORBIDDEN",
};
Error::new(self.to_string()).extend_with(|_, extensions| extensions.set("code", code))
}
}
pub fn db_error(error: sqlx::Error) -> Error {
eprintln!("database error: {error}");
Error::new("internal error").extend_with(|_, extensions| extensions.set("code", "INTERNAL"))
}
pub fn parse_id(id: &ID) -> Result<i64> {
id.parse()
.map_err(|_| AppError::BadInput(format!("invalid id {}", id.as_str())).extend())
}src/auth.rs
use async_graphql::{Context, ErrorExtensions, Result};
use crate::{errors::AppError, model::CurrentUser};
pub fn require_user<'a>(ctx: &Context<'a>) -> Result<&'a CurrentUser> {
ctx.data_opt::<CurrentUser>()
.ok_or_else(|| AppError::Unauthenticated.extend())
}
pub fn require_admin<'a>(ctx: &Context<'a>) -> Result<&'a CurrentUser> {
let user = require_user(ctx)?;
if user.is_admin {
Ok(user)
} else {
Err(AppError::Forbidden.extend())
}
}src/review.rs
use async_graphql::{Context, ErrorExtensions, Object, Result, ID};
use sqlx::PgPool;
use crate::{
auth::{require_admin, require_user},
errors::{db_error, parse_id, AppError},
model::{CreateReviewInput, Review, ReviewRow},
};
#[derive(Default)]
pub struct ReviewMutation;
#[Object]
impl ReviewMutation {
async fn create_review(&self, ctx: &Context<'_>, input: CreateReviewInput) -> Result<Review> {
let user = require_user(ctx)?;
if !(1..=5).contains(&input.rating) {
return Err(AppError::BadInput("rating must be 1 to 5".into()).extend());
}
let product_id = parse_id(&input.product_id)?;
let pool = ctx.data::<PgPool>()?;
let row = sqlx::query_as::<_, ReviewRow>(
"INSERT INTO reviews (product_id, author_id, rating, body)
SELECT $1::bigint, $2::bigint, $3::int, $4::text
WHERE EXISTS (SELECT 1 FROM products WHERE id = $1::bigint)
RETURNING id, product_id, rating, body",
)
.bind(product_id)
.bind(user.id)
.bind(input.rating)
.bind(&input.body)
.fetch_optional(pool)
.await
.map_err(db_error)?;
row.map(Review::from)
.ok_or_else(|| AppError::NotFound("product", product_id).extend())
}
async fn delete_review(&self, ctx: &Context<'_>, id: ID) -> Result<bool> {
require_admin(ctx)?;
let id = parse_id(&id)?;
let pool = ctx.data::<PgPool>()?;
let result = sqlx::query("DELETE FROM reviews WHERE id = $1")
.bind(id)
.execute(pool)
.await
.map_err(db_error)?;
Ok(result.rows_affected() > 0)
}
}Run
Start Postgres and set DATABASE_URL as in Project setup, then run cargo run. On a fresh database the seed data has products 1 and 2, and reviews 1 to 3. Requests:
curl -s localhost:8080/graphql -H 'content-type: application/json' \
-d '{"query":"{ products { name reviews { rating } } }"}'{"data":{"products":[{"name":"Notebook","reviews":[{"rating":5},{"rating":4}]},{"name":"Pen","reviews":[{"rating":3}]}]}}curl -s localhost:8080/graphql/persisted -H 'content-type: application/json' \
-d '{"id":"products-with-reviews"}'{"data":{"products":[{"name":"Notebook","reviews":[{"rating":5},{"rating":4}]},{"name":"Pen","reviews":[{"rating":3}]}]}}curl -s localhost:8080/graphql/persisted -H 'content-type: application/json' \
-d '{"id":"unknown"}'unknown query idcurl -s localhost:8080/graphql -H 'content-type: application/json' \
-d '{"query":"{ product(id: \"abc\") { name } }"}'{"data":{"product":null},"errors":[{"message":"invalid id abc","locations":[{"line":1,"column":3}],"path":["product"],"extensions":{"code":"BAD_USER_INPUT"}}]}curl -s localhost:8080/graphql -H 'content-type: application/json' \
-d '{"query":"mutation { createReview(input: {productId: \"1\", rating: 5, body: \"ok\"}) { id } }"}'{"data":null,"errors":[{"message":"authentication required","locations":[{"line":1,"column":12}],"path":["createReview"],"extensions":{"code":"UNAUTHENTICATED"}}]}curl -s localhost:8080/graphql -H 'content-type: application/json' -H 'x-user-id: 7' \
-d '{"query":"mutation { createReview(input: {productId: \"1\", rating: 5, body: \"ok\"}) { id } }"}'{"data":{"createReview":{"id":"4"}}}curl -s localhost:8080/graphql -H 'content-type: application/json' -H 'x-user-id: 7' \
-d '{"query":"mutation { createReview(input: {productId: \"999\", rating: 5, body: \"ok\"}) { id } }"}'{"data":null,"errors":[{"message":"product 999 not found","locations":[{"line":1,"column":12}],"path":["createReview"],"extensions":{"code":"NOT_FOUND"}}]}curl -s localhost:8080/graphql -H 'content-type: application/json' -H 'x-user-id: 7' \
-d '{"query":"mutation { deleteReview(id: \"4\") }"}'{"data":null,"errors":[{"message":"not allowed","locations":[{"line":1,"column":12}],"path":["deleteReview"],"extensions":{"code":"FORBIDDEN"}}]}curl -s localhost:8080/graphql -H 'content-type: application/json' -H 'x-user-id: 1' -H 'x-role: admin' \
-d '{"query":"mutation { deleteReview(id: \"4\") }"}'{"data":{"deleteReview":true}}Summary
- Nested list resolvers issue one query per parent. A DataLoader replaces them with one batched query.
- The loader implements
load(&[K])returning aHashMap<K, V>.DataLoader::newdoes not cache. Create a new loader for each request. - Errors carry
message,path,locations, andextensions. Put a stablecodeinextensions. - Internal error text stays in logs. Clients get a generic message with an
INTERNALcode. limit_depthandlimit_complexityreject expensive queries before execution.- An allow list of persisted queries also rejects unknown queries. Automatic persisted queries only save bandwidth.
- Field-level checks read the user from request data and run on every path to the data.
