GraphQL Guide

This guide explains how to use GraphQL with the Armature framework, inspired by NestJS's @nestjs/graphql.

Overview

Armature provides optional GraphQL support through the armature-graphql module, built on top of async-graphql. This integration enables you to build type-safe GraphQL APIs with full dependency injection support and programmatic schema generation.

Features

โœ… Type-Safe Schema - Compile-time verified GraphQL types โœ… Programmatic Schema - Build schemas programmatically like NestJS โœ… Queries & Mutations - Full CRUD operations support โœ… Subscriptions - Real-time GraphQL subscriptions โœ… DI Integration - Services injected into resolvers โœ… Decorator-Style - Rust procedural macros for clean syntax โœ… GraphiQL/Playground - Built-in query interface โœ… Schema Introspection - Automatic API documentation

Installation

Add the GraphQL feature to your Cargo.toml:

[dependencies]
armature-framework = { version = "0.1", features = ["graphql"] }
armature-graphql = "0.1"

Quick Start

1. Define Your Types

use armature_graphql::{SimpleObject, ID};

#[derive(SimpleObject)]
struct Book {
    id: ID,
    title: String,
    author: String,
    year: i32,
}

2. Create Query Root

use armature_graphql::Object;

struct QueryRoot {
    book_service: BookService,
}

#[Object]
impl QueryRoot {
    async fn books(&self) -> Vec<Book> {
        self.book_service.get_all_books()
    }

    async fn book(&self, id: ID) -> Option<Book> {
        self.book_service.get_book_by_id(&id)
    }
}

3. Create Mutation Root

struct MutationRoot {
    book_service: BookService,
}

#[Object]
impl MutationRoot {
    async fn create_book(&self, title: String, author: String) -> Book {
        self.book_service.create_book(title, author)
    }
}

4. Build Schema Programmatically (NestJS-style)

use armature_graphql::{ProgrammaticSchemaBuilder, EmptySubscription};

// Using the programmatic builder
let schema = ProgrammaticSchemaBuilder::new()
    .query(QueryRoot { book_service: book_service.clone() })
    .mutation(MutationRoot { book_service: book_service.clone() })
    .subscription(EmptySubscription)
    .add_service(book_service)  // Add to schema context
    .build();

// Or use the standard Schema::build
let schema = Schema::build(
    QueryRoot { book_service },
    MutationRoot { book_service },
    EmptySubscription
).finish();

5. Create GraphQL Endpoint

router.add_route(Route {
    method: HttpMethod::POST,
    path: "/graphql".to_string(),
    handler: Arc::new(move |req| {
        let schema = schema.clone();
        Box::pin(async move {
            // Handle GraphQL request
            let gql_req: GraphQLRequest = req.json()?;
            let request = async_graphql::Request::new(gql_req.query);
            let response = schema.execute(request).await;

            let json = serde_json::to_value(&response)?;
            HttpResponse::ok().with_json(&json)
        })
    }),
});

Type System

Simple Objects

For simple data types:

#[derive(SimpleObject)]
struct User {
    id: ID,
    name: String,
    email: String,
    age: i32,
}

Complex Objects with Resolvers

For types with computed fields:

struct User {
    id: ID,
    name: String,
}

#[Object]
impl User {
    // Simple field
    async fn id(&self) -> &ID {
        &self.id
    }

    // Computed field
    async fn full_name(&self, format: Option<String>) -> String {
        match format.as_deref() {
            Some("upper") => self.name.to_uppercase(),
            Some("lower") => self.name.to_lowercase(),
            _ => self.name.clone(),
        }
    }

    // Field with service injection (via context)
    async fn posts(&self, ctx: &Context<'_>) -> Vec<Post> {
        let service = ctx.data::<PostService>().unwrap();
        service.get_user_posts(&self.id)
    }
}

Enums

#[derive(Enum, Copy, Clone, Eq, PartialEq)]
enum Role {
    Admin,
    User,
    Guest,
}

Unions

#[derive(Union)]
enum SearchResult {
    User(User),
    Post(Post),
    Comment(Comment),
}

Input Objects

For mutation arguments:

#[derive(InputObject)]
struct CreateUserInput {
    name: String,
    email: String,
    age: Option<i32>,
}

#[Object]
impl MutationRoot {
    async fn create_user(&self, input: CreateUserInput) -> User {
        // Create user from input
    }
}

Queries

Basic Query

#[Object]
impl QueryRoot {
    async fn hello(&self) -> &str {
        "Hello, World!"
    }

    async fn users(&self) -> Vec<User> {
        self.user_service.get_all()
    }
}

GraphQL:

query {
    hello
    users {
        id
        name
    }
}

Query with Arguments

#[Object]
impl QueryRoot {
    async fn user(&self, id: ID) -> Result<User> {
        self.user_service
            .get_by_id(&id)
            .ok_or("User not found".into())
    }

    async fn search_users(
        &self,
        query: String,
        limit: Option<i32>,
    ) -> Vec<User> {
        self.user_service.search(&query, limit.unwrap_or(10))
    }
}

GraphQL:

query {
    user(id: "123") {
        id
        name
    }

    searchUsers(query: "john", limit: 5) {
        id
        name
    }
}

Nested Queries

struct User {
    id: ID,
    name: String,
}

#[Object]
impl User {
    async fn id(&self) -> &ID { &self.id }
    async fn name(&self) -> &str { &self.name }

    async fn posts(&self, ctx: &Context<'_>) -> Vec<Post> {
        ctx.data::<PostService>()
            .unwrap()
            .get_user_posts(&self.id)
    }
}

struct Post {
    id: ID,
    title: String,
}

#[Object]
impl Post {
    async fn id(&self) -> &ID { &self.id }
    async fn title(&self) -> &str { &self.title }

    async fn author(&self, ctx: &Context<'_>) -> User {
        ctx.data::<UserService>()
            .unwrap()
            .get_by_id(&self.author_id)
    }
}

GraphQL:

query {
    user(id: "123") {
        name
        posts {
            title
            author {
                name
            }
        }
    }
}

Mutations

Basic Mutations

#[Object]
impl MutationRoot {
    async fn create_user(&self, name: String, email: String) -> User {
        self.user_service.create(name, email)
    }

    async fn update_user(&self, id: ID, name: String) -> Result<User> {
        self.user_service
            .update(&id, name)
            .ok_or("User not found".into())
    }

    async fn delete_user(&self, id: ID) -> bool {
        self.user_service.delete(&id)
    }
}

GraphQL:

mutation {
    createUser(name: "John", email: "john@example.com") {
        id
        name
    }

    updateUser(id: "123", name: "Jane") {
        id
        name
    }

    deleteUser(id: "123")
}

Mutations with Input Objects

#[derive(InputObject)]
struct CreatePostInput {
    title: String,
    content: String,
    author_id: ID,
    tags: Vec<String>,
}

#[Object]
impl MutationRoot {
    async fn create_post(&self, input: CreatePostInput) -> Post {
        self.post_service.create(input)
    }
}

GraphQL:

mutation {
    createPost(input: {
        title: "My Post"
        content: "Post content"
        authorId: "123"
        tags: ["rust", "graphql"]
    }) {
        id
        title
    }
}

Subscriptions

Real-time Updates

use armature_graphql::Subscription;
use futures_util::Stream;

struct SubscriptionRoot;

#[Subscription]
impl SubscriptionRoot {
    async fn books(&self) -> impl Stream<Item = Book> {
        // Return a stream of books
        async_stream::stream! {
            loop {
                tokio::time::sleep(Duration::from_secs(1)).await;
                yield Book {
                    id: ID::from("1"),
                    title: "New Book".to_string(),
                    author: "Author".to_string(),
                    year: 2024,
                };
            }
        }
    }
}

GraphQL:

subscription {
    books {
        id
        title
    }
}

Programmatic Schema Building (NestJS-style)

Armature provides a ProgrammaticSchemaBuilder for building GraphQL schemas programmatically, similar to NestJS's approach:

use armature_graphql::ProgrammaticSchemaBuilder;

// Create services
let user_service = UserService::default();
let post_service = PostService::default();

// Create resolvers with injected services
let query = QueryRoot {
    user_service: user_service.clone(),
    post_service: post_service.clone(),
};

let mutation = MutationRoot {
    user_service: user_service.clone(),
    post_service: post_service.clone(),
};

// Build schema programmatically
let schema = ProgrammaticSchemaBuilder::new()
    .query(query)
    .mutation(mutation)
    .subscription(EmptySubscription)
    .add_service(user_service)    // Add to context
    .add_service(post_service)    // Add to context
    .build();

Comparison with NestJS

NestJS (@nestjs/graphql):

@Module({
  imports: [
    GraphQLModule.forRoot({
      autoSchemaFile: true,
    }),
  ],
  providers: [UserService, UserResolver],
})
export class AppModule {}

@Resolver(() => User)
export class UserResolver {
  constructor(private userService: UserService) {}

  @Query(() => [User])
  users() {
    return this.userService.findAll();
  }
}

Armature (armature-graphql):

#[injectable]
#[derive(Clone)]
struct UserService { }

struct QueryRoot {
    user_service: UserService,
}

#[Object]
impl QueryRoot {
    async fn users(&self) -> Vec<User> {
        self.user_service.find_all()
    }
}

let schema = ProgrammaticSchemaBuilder::new()
    .query(QueryRoot { user_service })
    .mutation(EmptyMutation)
    .subscription(EmptySubscription)
    .build();

Dependency Injection with GraphQL

Inject Services into Resolvers

// Define your service
#[injectable]
#[derive(Clone)]
struct UserService {
    database: DatabaseService,
}

// Method 1: Constructor injection (NestJS-style)
struct QueryRoot {
    user_service: UserService,
}

#[Object]
impl QueryRoot {
    async fn users(&self) -> Vec<User> {
        self.user_service.get_all()
    }
}

// Method 2: Context injection
// Add service to GraphQL context
let schema = ProgrammaticSchemaBuilder::new()
    .query(query)
    .add_service(user_service)  // Available in context
    .build();

// Use in resolver
#[Object]
impl QueryRoot {
    async fn users(&self, ctx: &Context<'_>) -> Vec<User> {
        let service = ctx.data::<UserService>().unwrap();
        service.get_all()
    }
}

Error Handling

Custom Errors

use armature_graphql::Error;

#[Object]
impl QueryRoot {
    async fn user(&self, id: ID) -> Result<User> {
        self.user_service
            .get_by_id(&id)
            .ok_or_else(|| Error::new("User not found"))
    }

    async fn validate_user(&self, email: String) -> Result<bool> {
        if !email.contains('@') {
            return Err(Error::new("Invalid email format"));
        }
        Ok(true)
    }
}

Field Errors

#[Object]
impl User {
    async fn sensitive_data(&self, ctx: &Context<'_>) -> Result<String> {
        let auth = ctx.data::<AuthService>().unwrap();

        if !auth.is_authorized(&self.id) {
            return Err(Error::new("Unauthorized"));
        }

        Ok(self.sensitive_data.clone())
    }
}

GraphQL Playground

Built-in Playground

Armature provides two playground options:

GraphiQL (Lightweight)

use armature_graphql::graphiql_html;

router.add_route(Route {
    method: HttpMethod::GET,
    path: "/playground".to_string(),
    handler: Arc::new(move |_req| {
        Box::pin(async move {
            let html = graphiql_html("/graphql");
            Ok(HttpResponse::ok()
                .with_header("Content-Type".into(), "text/html".into())
                .with_body(html.into_bytes()))
        })
    }),
});

GraphQL Playground

use armature_graphql::graphql_playground_html;

let html = graphql_playground_html("/graphql");

Best Practices

1. Use Input Objects for Complex Mutations

Good:

#[derive(InputObject)]
struct CreateUserInput {
    name: String,
    email: String,
    role: Role,
}

async fn create_user(&self, input: CreateUserInput) -> User

Avoid:

async fn create_user(&self, name: String, email: String, role: Role) -> User

2. Implement Pagination

#[derive(SimpleObject)]
struct UserConnection {
    edges: Vec<UserEdge>,
    page_info: PageInfo,
}

#[derive(SimpleObject)]
struct UserEdge {
    node: User,
    cursor: String,
}

#[derive(SimpleObject)]
struct PageInfo {
    has_next_page: bool,
    has_previous_page: bool,
}

#[Object]
impl QueryRoot {
    async fn users(&self, first: i32, after: Option<String>) -> UserConnection {
        self.user_service.paginate(first, after)
    }
}

3. Use DataLoader for N+1 Queries

use async_graphql::dataloader::*;

struct UserLoader {
    user_service: UserService,
}

#[async_trait::async_trait]
impl Loader<ID> for UserLoader {
    type Value = User;
    type Error = Arc<Error>;

    async fn load(&self, keys: &[ID]) -> Result<HashMap<ID, User>, Self::Error> {
        Ok(self.user_service.get_by_ids(keys))
    }
}

4. Add Field Descriptions

#[Object]
impl QueryRoot {
    /// Get all users in the system
    #[graphql(desc = "Retrieve a list of all users")]
    async fn users(&self) -> Vec<User> {
        self.user_service.get_all()
    }
}

5. Use Guards for Authorization

use async_graphql::Guard;

struct RoleGuard {
    role: Role,
}

#[async_trait::async_trait]
impl Guard for RoleGuard {
    async fn check(&self, ctx: &Context<'_>) -> Result<()> {
        let user = ctx.data::<CurrentUser>()?;
        if user.role == self.role {
            Ok(())
        } else {
            Err("Unauthorized".into())
        }
    }
}

#[Object]
impl MutationRoot {
    #[graphql(guard = "RoleGuard { role: Role::Admin }")]
    async fn delete_user(&self, id: ID) -> bool {
        self.user_service.delete(&id)
    }
}

Testing

Unit Testing Resolvers

#[tokio::test]
async fn test_query_users() {
    let service = UserService::default();
    let query = QueryRoot { user_service: service };

    let users = query.users().await;
    assert!(!users.is_empty());
}

Integration Testing Schema

#[tokio::test]
async fn test_graphql_query() {
    let schema = create_schema();

    let query = r#"
        query {
            users {
                id
                name
            }
        }
    "#;

    let req = async_graphql::Request::new(query);
    let res = schema.execute(req).await;

    assert!(res.errors.is_empty());
    assert!(res.data.is_object());
}

Performance Tips

  1. Use DataLoader - Batch database queries
  2. Limit Query Depth - Prevent deeply nested queries
  3. Add Query Complexity - Limit computational cost
  4. Cache Results - Use Redis or in-memory cache
  5. Optimize N+1 - Use DataLoader or JOIN queries

Common Patterns

Relay-Style Pagination

#[derive(SimpleObject)]
struct Connection<T> {
    edges: Vec<Edge<T>>,
    page_info: PageInfo,
}

Error Union Pattern

#[derive(Union)]
enum UserResult {
    Success(User),
    Error(UserError),
}

Batch Mutations

async fn batch_create_users(&self, inputs: Vec<CreateUserInput>) -> Vec<User>

Summary

Armature's GraphQL support provides:

โœ… Type-Safe - Compile-time schema validation โœ… DI Integration - Services injected into resolvers โœ… Full Featured - Queries, mutations, subscriptions โœ… Developer Friendly - Built-in playground โœ… Production Ready - Error handling, pagination, guards โœ… Performant - DataLoader, caching support

For more examples, see examples/graphql_api.rs in the repository.