GraphQL APIs

GraphQL with async-graphql

advanced
graphqlasync-graphqljuniper
🎮 Interactive Playground

What is 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.

The Problem

GraphQL in Rust requires:

  • Schema definition: Types, queries, mutations
  • Resolvers: Functions that fetch data
  • N+1 problem: Efficient data loading
  • Authentication: Context passing to resolvers

Example Code

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");
}

Why This Works

  1. SimpleObject: Auto-generates GraphQL type from struct
  2. Object: Custom resolvers for computed fields
  3. InputObject: Type-safe mutation inputs
  4. Context: Shares data and auth across resolvers

GraphQL vs REST

| 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) |

N+1 Problem Solution

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)
    }
}

⚠️ Anti-patterns

// 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
}

Exercises

  1. Add subscriptions for real-time post updates
  2. Implement cursor-based pagination
  3. Add field-level authorization
  4. Create a GraphQL playground endpoint

🎮 Try it Yourself

🎮

GraphQL APIs - Playground

Run this code in the official Rust Playground