GraphQL with async-graphql
GraphQL is a query language for APIs that lets clients request exactly the data they need. Unlike REST, a single endpoint serves all queries, and the schema is strongly typed.
GraphQL in Rust requires:
use async_graphql::{
Context, EmptySubscription, Object, Schema, SimpleObject,
InputObject, Result, Error, ID,
};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
// Domain types
#[derive(Debug, Clone, SimpleObject)]
pub struct User {
pub id: ID,
pub email: String,
pub name: String,
#[graphql(skip)] // Don't expose password
pub password_hash: String,
pub created_at: String,
}
#[derive(Debug, Clone, SimpleObject)]
pub struct Post {
pub id: ID,
pub title: String,
pub content: String,
pub author_id: ID,
pub published: bool,
pub created_at: String,
}
// Extended User with posts (computed field)
struct UserWithPosts(User);
#[Object]
impl UserWithPosts {
async fn id(&self) -> &ID {
&self.0.id
}
async fn email(&self) -> &str {
&self.0.email
}
async fn name(&self) -> &str {
&self.0.name
}
async fn created_at(&self) -> &str {
&self.0.created_at
}
// Fetch related posts
async fn posts(&self, ctx: &Context<'_>) -> Result<Vec<Post>> {
let data = ctx.data::<AppData>()?;
let posts = data.posts.read().unwrap();
Ok(posts
.values()
.filter(|p| p.author_id == self.0.id)
.cloned()
.collect())
}
// Computed field: post count
async fn post_count(&self, ctx: &Context<'_>) -> Result<usize> {
let data = ctx.data::<AppData>()?;
let posts = data.posts.read().unwrap();
Ok(posts
.values()
.filter(|p| p.author_id == self.0.id)
.count())
}
}
// Input types
#[derive(Debug, InputObject)]
pub struct CreateUserInput {
pub email: String,
pub name: String,
pub password: String,
}
#[derive(Debug, InputObject)]
pub struct UpdateUserInput {
pub email: Option<String>,
pub name: Option<String>,
}
#[derive(Debug, InputObject)]
pub struct CreatePostInput {
pub title: String,
pub content: String,
pub published: Option<bool>,
}
#[derive(Debug, InputObject)]
pub struct PostsFilter {
pub author_id: Option<ID>,
pub published: Option<bool>,
pub search: Option<String>,
}
// Application data
#[derive(Clone)]
pub struct AppData {
pub users: Arc<RwLock<HashMap<String, User>>>,
pub posts: Arc<RwLock<HashMap<String, Post>>>,
next_id: Arc<RwLock<u64>>,
}
impl AppData {
pub fn new() -> Self {
AppData {
users: Arc::new(RwLock::new(HashMap::new())),
posts: Arc::new(RwLock::new(HashMap::new())),
next_id: Arc::new(RwLock::new(1)),
}
}
fn generate_id(&self) -> String {
let mut id = self.next_id.write().unwrap();
let current = *id;
*id += 1;
current.to_string()
}
}
impl Default for AppData {
fn default() -> Self {
Self::new()
}
}
// Authentication context
pub struct AuthContext {
pub user_id: Option<String>,
}
// Query root
pub struct QueryRoot;
#[Object]
impl QueryRoot {
// Get single user
async fn user(&self, ctx: &Context<'_>, id: ID) -> Result<Option<UserWithPosts>> {
let data = ctx.data::<AppData>()?;
let users = data.users.read().unwrap();
Ok(users.get(id.as_str()).cloned().map(UserWithPosts))
}
// List users with pagination
async fn users(
&self,
ctx: &Context<'_>,
limit: Option<i32>,
offset: Option<i32>,
) -> Result<Vec<UserWithPosts>> {
let data = ctx.data::<AppData>()?;
let users = data.users.read().unwrap();
let limit = limit.unwrap_or(10) as usize;
let offset = offset.unwrap_or(0) as usize;
Ok(users
.values()
.skip(offset)
.take(limit)
.cloned()
.map(UserWithPosts)
.collect())
}
// Get single post
async fn post(&self, ctx: &Context<'_>, id: ID) -> Result<Option<Post>> {
let data = ctx.data::<AppData>()?;
let posts = data.posts.read().unwrap();
Ok(posts.get(id.as_str()).cloned())
}
// List posts with filtering
async fn posts(
&self,
ctx: &Context<'_>,
filter: Option<PostsFilter>,
limit: Option<i32>,
offset: Option<i32>,
) -> Result<Vec<Post>> {
let data = ctx.data::<AppData>()?;
let posts = data.posts.read().unwrap();
let limit = limit.unwrap_or(10) as usize;
let offset = offset.unwrap_or(0) as usize;
let filtered: Vec<Post> = posts
.values()
.filter(|p| {
if let Some(ref f) = filter {
if let Some(ref author_id) = f.author_id {
if &p.author_id != author_id {
return false;
}
}
if let Some(published) = f.published {
if p.published != published {
return false;
}
}
if let Some(ref search) = f.search {
if !p.title.to_lowercase().contains(&search.to_lowercase())
&& !p.content.to_lowercase().contains(&search.to_lowercase())
{
return false;
}
}
}
true
})
.skip(offset)
.take(limit)
.cloned()
.collect();
Ok(filtered)
}
// Get current authenticated user
async fn me(&self, ctx: &Context<'_>) -> Result<Option<UserWithPosts>> {
let auth = ctx.data::<AuthContext>()?;
let data = ctx.data::<AppData>()?;
if let Some(ref user_id) = auth.user_id {
let users = data.users.read().unwrap();
Ok(users.get(user_id).cloned().map(UserWithPosts))
} else {
Ok(None)
}
}
}
// Mutation root
pub struct MutationRoot;
#[Object]
impl MutationRoot {
// Create a new user
async fn create_user(
&self,
ctx: &Context<'_>,
input: CreateUserInput,
) -> Result<UserWithPosts> {
let data = ctx.data::<AppData>()?;
// Validate email
if !input.email.contains('@') {
return Err(Error::new("Invalid email format"));
}
// Check duplicate email
{
let users = data.users.read().unwrap();
if users.values().any(|u| u.email == input.email) {
return Err(Error::new("Email already registered"));
}
}
let user = User {
id: ID(data.generate_id()),
email: input.email,
name: input.name,
password_hash: format!("hash_{}", input.password),
created_at: "2024-01-01T00:00:00Z".to_string(),
};
data.users.write().unwrap().insert(user.id.to_string(), user.clone());
Ok(UserWithPosts(user))
}
// Update user (requires authentication)
async fn update_user(
&self,
ctx: &Context<'_>,
id: ID,
input: UpdateUserInput,
) -> Result<UserWithPosts> {
let auth = ctx.data::<AuthContext>()?;
let data = ctx.data::<AppData>()?;
// Check authentication
let user_id = auth.user_id.as_ref()
.ok_or_else(|| Error::new("Not authenticated"))?;
// Only allow updating own profile
if user_id != id.as_str() {
return Err(Error::new("Not authorized"));
}
let mut users = data.users.write().unwrap();
let user = users.get_mut(id.as_str())
.ok_or_else(|| Error::new("User not found"))?;
if let Some(email) = input.email {
user.email = email;
}
if let Some(name) = input.name {
user.name = name;
}
Ok(UserWithPosts(user.clone()))
}
// Create a post (requires authentication)
async fn create_post(
&self,
ctx: &Context<'_>,
input: CreatePostInput,
) -> Result<Post> {
let auth = ctx.data::<AuthContext>()?;
let data = ctx.data::<AppData>()?;
let author_id = auth.user_id.as_ref()
.ok_or_else(|| Error::new("Not authenticated"))?;
let post = Post {
id: ID(data.generate_id()),
title: input.title,
content: input.content,
author_id: ID(author_id.clone()),
published: input.published.unwrap_or(false),
created_at: "2024-01-01T00:00:00Z".to_string(),
};
data.posts.write().unwrap().insert(post.id.to_string(), post.clone());
Ok(post)
}
// Delete a post
async fn delete_post(&self, ctx: &Context<'_>, id: ID) -> Result<bool> {
let auth = ctx.data::<AuthContext>()?;
let data = ctx.data::<AppData>()?;
let user_id = auth.user_id.as_ref()
.ok_or_else(|| Error::new("Not authenticated"))?;
let mut posts = data.posts.write().unwrap();
// Check ownership
let post = posts.get(id.as_str())
.ok_or_else(|| Error::new("Post not found"))?;
if post.author_id.as_str() != user_id {
return Err(Error::new("Not authorized"));
}
posts.remove(id.as_str());
Ok(true)
}
}
// Schema builder
pub type AppSchema = Schema<QueryRoot, MutationRoot, EmptySubscription>;
pub fn create_schema(data: AppData) -> AppSchema {
Schema::build(QueryRoot, MutationRoot, EmptySubscription)
.data(data)
.data(AuthContext { user_id: None }) // Default unauthenticated
.finish()
}
fn main() {
println!("GraphQL API example");
println!("Schema provides: users, posts, me queries");
println!("And createUser, createPost, updateUser, deletePost mutations");
}
SimpleObject: Auto-generates GraphQL type from structObject: Custom resolvers for computed fieldsInputObject: Type-safe mutation inputsContext: Shares data and auth across resolvers| Aspect | GraphQL | REST |
|--------|---------|------|
| Endpoints | Single /graphql | Multiple /resources |
| Data fetching | Client specifies fields | Server determines response |
| Over-fetching | Never | Common |
| Under-fetching | Never | Requires multiple requests |
| Schema | Required, typed | Optional (OpenAPI) |
use async_graphql::dataloader::{DataLoader, Loader};
// DataLoader prevents N+1 queries
struct PostLoader(AppData);
impl Loader<String> for PostLoader {
type Value = Vec<Post>;
type Error = Error;
async fn load(&self, author_ids: &[String]) -> Result<HashMap<String, Vec<Post>>> {
let posts = self.0.posts.read().unwrap();
let mut result: HashMap<String, Vec<Post>> = HashMap::new();
for post in posts.values() {
if author_ids.contains(&post.author_id.to_string()) {
result
.entry(post.author_id.to_string())
.or_default()
.push(post.clone());
}
}
Ok(result)
}
}
// DON'T: Expose internal errors
async fn create_user(&self, ctx: &Context<'_>, input: CreateUserInput) -> Result<User> {
// This leaks internal details
data.insert(user).map_err(|e| Error::new(format!("{:?}", e)))?;
}
// DON'T: N+1 queries without dataloader
async fn posts(&self, ctx: &Context<'_>) -> Result<Vec<Post>> {
for user in users {
// This queries DB once per user!
fetch_posts_for_user(user.id);
}
}
// DO: Use dataloader
async fn posts(&self, ctx: &Context<'_>) -> Result<Vec<Post>> {
let loader = ctx.data::<DataLoader<PostLoader>>()?;
loader.load_one(self.id.clone()).await
}
Run this code in the official Rust Playground