API GraphQL em Rust

Construa uma API GraphQL em Rust usando async-graphql e Axum com schema, queries, mutations, subscriptions e playground interativo.

Neste projeto vamos construir uma API GraphQL completa em Rust usando a crate async-graphql integrada com Axum. A API vai gerenciar um catalogo de livros com queries para busca, mutations para criacao e atualizacao, e um playground interativo para testar operacoes. GraphQL oferece uma alternativa poderosa ao REST, permitindo que clientes solicitem exatamente os dados que precisam em uma unica requisicao.

Rust e uma excelente escolha para servidores GraphQL: o sistema de tipos forte se alinha perfeitamente com os schemas tipados do GraphQL, e a performance assincrona do tokio garante respostas rapidas mesmo sob carga elevada.

O Que Vamos Construir

Uma API GraphQL para catalogo de livros com:

  • Queries – Listar livros, buscar por ID, filtrar por autor
  • Mutations – Criar livro, atualizar livro, remover livro
  • Schema tipado com objetos, inputs e enums
  • Playground interativo no navegador para testar queries
  • Armazenamento em memoria com estado compartilhado
  • Tratamento de erros com mensagens descritivas

Estrutura do Projeto

graphql-api/
├── Cargo.toml
└── src/
    └── main.rs

Configurando o Projeto

cargo new graphql-api
cd graphql-api

Configure o Cargo.toml:

[package]
name = "graphql-api"
version = "0.1.0"
edition = "2021"

[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
async-graphql = "7"
async-graphql-axum = "7"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4"] }

A crate async-graphql e a implementacao GraphQL mais popular em Rust. A crate async-graphql-axum fornece a integracao com o framework Axum.

Passo 1: Definindo os Modelos de Dados

Vamos definir os tipos que compoem nosso schema GraphQL:

use async_graphql::{Enum, InputObject, Object, SimpleObject, ID};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use uuid::Uuid;

// Genero literario (enum GraphQL)
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Enum, PartialEq, Eq)]
pub enum Genero {
    Ficcao,
    NaoFiccao,
    Romance,
    FiccaoCientifica,
    Fantasia,
    Terror,
    Biografia,
    Tecnico,
}

// Modelo de livro (objeto GraphQL)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Livro {
    pub id: String,
    pub titulo: String,
    pub autor: String,
    pub genero: Genero,
    pub ano_publicacao: i32,
    pub paginas: i32,
    pub sinopse: Option<String>,
    pub avaliacao: Option<f64>,
}

// Implementacao dos campos GraphQL para Livro
#[Object]
impl Livro {
    async fn id(&self) -> &str {
        &self.id
    }

    async fn titulo(&self) -> &str {
        &self.titulo
    }

    async fn autor(&self) -> &str {
        &self.autor
    }

    async fn genero(&self) -> Genero {
        self.genero
    }

    async fn ano_publicacao(&self) -> i32 {
        self.ano_publicacao
    }

    async fn paginas(&self) -> i32 {
        self.paginas
    }

    async fn sinopse(&self) -> &Option<String> {
        &self.sinopse
    }

    async fn avaliacao(&self) -> &Option<f64> {
        &self.avaliacao
    }

    // Campo calculado: classificacao por avaliacao
    async fn classificacao(&self) -> &str {
        match self.avaliacao {
            Some(nota) if nota >= 4.5 => "Excelente",
            Some(nota) if nota >= 3.5 => "Bom",
            Some(nota) if nota >= 2.5 => "Regular",
            Some(_) => "Fraco",
            None => "Sem avaliacao",
        }
    }
}

// Input para criar um novo livro
#[derive(Debug, InputObject)]
pub struct NovoLivroInput {
    pub titulo: String,
    pub autor: String,
    pub genero: Genero,
    pub ano_publicacao: i32,
    pub paginas: i32,
    pub sinopse: Option<String>,
}

// Input para atualizar um livro
#[derive(Debug, InputObject)]
pub struct AtualizarLivroInput {
    pub titulo: Option<String>,
    pub autor: Option<String>,
    pub genero: Option<Genero>,
    pub ano_publicacao: Option<i32>,
    pub paginas: Option<i32>,
    pub sinopse: Option<String>,
    pub avaliacao: Option<f64>,
}

// Estado da aplicacao
pub type BancoDados = Arc<Mutex<HashMap<String, Livro>>>;

A macro #[Object] transforma a struct em um tipo de objeto GraphQL. #[InputObject] marca tipos usados como parametros de entrada em mutations. #[Enum] define um enum do schema.

Passo 2: Implementando Queries

As queries permitem buscar dados do catalogo:

pub struct QueryRoot;

#[Object]
impl QueryRoot {
    // Listar todos os livros
    async fn livros(
        &self,
        ctx: &async_graphql::Context<'_>,
    ) -> Vec<Livro> {
        let db = ctx.data_unchecked::<BancoDados>();
        let livros = db.lock().unwrap();
        livros.values().cloned().collect()
    }

    // Buscar livro por ID
    async fn livro(
        &self,
        ctx: &async_graphql::Context<'_>,
        id: String,
    ) -> Option<Livro> {
        let db = ctx.data_unchecked::<BancoDados>();
        let livros = db.lock().unwrap();
        livros.get(&id).cloned()
    }

    // Buscar livros por autor
    async fn livros_por_autor(
        &self,
        ctx: &async_graphql::Context<'_>,
        autor: String,
    ) -> Vec<Livro> {
        let db = ctx.data_unchecked::<BancoDados>();
        let livros = db.lock().unwrap();
        livros
            .values()
            .filter(|l| l.autor.to_lowercase().contains(&autor.to_lowercase()))
            .cloned()
            .collect()
    }

    // Buscar livros por genero
    async fn livros_por_genero(
        &self,
        ctx: &async_graphql::Context<'_>,
        genero: Genero,
    ) -> Vec<Livro> {
        let db = ctx.data_unchecked::<BancoDados>();
        let livros = db.lock().unwrap();
        livros
            .values()
            .filter(|l| l.genero == genero)
            .cloned()
            .collect()
    }

    // Estatisticas do catalogo
    async fn estatisticas(
        &self,
        ctx: &async_graphql::Context<'_>,
    ) -> Estatisticas {
        let db = ctx.data_unchecked::<BancoDados>();
        let livros = db.lock().unwrap();
        let total = livros.len() as i32;
        let media_paginas = if total > 0 {
            livros.values().map(|l| l.paginas).sum::<i32>() as f64 / total as f64
        } else {
            0.0
        };
        Estatisticas {
            total_livros: total,
            media_paginas,
        }
    }
}

#[derive(SimpleObject)]
struct Estatisticas {
    total_livros: i32,
    media_paginas: f64,
}

O async_graphql::Context permite acessar dados compartilhados (como nosso banco em memoria) dentro dos resolvers. Cada campo e um resolver assincrono.

Passo 3: Implementando Mutations

As mutations permitem modificar dados:

pub struct MutationRoot;

#[Object]
impl MutationRoot {
    // Criar novo livro
    async fn criar_livro(
        &self,
        ctx: &async_graphql::Context<'_>,
        input: NovoLivroInput,
    ) -> async_graphql::Result<Livro> {
        // Validacoes
        if input.titulo.trim().is_empty() {
            return Err("Titulo nao pode ser vazio".into());
        }
        if input.paginas <= 0 {
            return Err("Numero de paginas deve ser positivo".into());
        }

        let db = ctx.data_unchecked::<BancoDados>();
        let mut livros = db.lock().unwrap();

        let livro = Livro {
            id: Uuid::new_v4().to_string(),
            titulo: input.titulo,
            autor: input.autor,
            genero: input.genero,
            ano_publicacao: input.ano_publicacao,
            paginas: input.paginas,
            sinopse: input.sinopse,
            avaliacao: None,
        };

        livros.insert(livro.id.clone(), livro.clone());
        Ok(livro)
    }

    // Atualizar livro existente
    async fn atualizar_livro(
        &self,
        ctx: &async_graphql::Context<'_>,
        id: String,
        input: AtualizarLivroInput,
    ) -> async_graphql::Result<Livro> {
        let db = ctx.data_unchecked::<BancoDados>();
        let mut livros = db.lock().unwrap();

        let livro = livros
            .get_mut(&id)
            .ok_or_else(|| async_graphql::Error::new("Livro nao encontrado"))?;

        if let Some(titulo) = input.titulo {
            livro.titulo = titulo;
        }
        if let Some(autor) = input.autor {
            livro.autor = autor;
        }
        if let Some(genero) = input.genero {
            livro.genero = genero;
        }
        if let Some(ano) = input.ano_publicacao {
            livro.ano_publicacao = ano;
        }
        if let Some(paginas) = input.paginas {
            livro.paginas = paginas;
        }
        if let Some(sinopse) = input.sinopse {
            livro.sinopse = Some(sinopse);
        }
        if let Some(avaliacao) = input.avaliacao {
            if !(0.0..=5.0).contains(&avaliacao) {
                return Err("Avaliacao deve ser entre 0.0 e 5.0".into());
            }
            livro.avaliacao = Some(avaliacao);
        }

        Ok(livro.clone())
    }

    // Remover livro
    async fn remover_livro(
        &self,
        ctx: &async_graphql::Context<'_>,
        id: String,
    ) -> async_graphql::Result<bool> {
        let db = ctx.data_unchecked::<BancoDados>();
        let mut livros = db.lock().unwrap();

        if livros.remove(&id).is_some() {
            Ok(true)
        } else {
            Err("Livro nao encontrado".into())
        }
    }
}

As mutations retornam async_graphql::Result para comunicar erros de forma idiomatica no GraphQL. As validacoes garantem dados consistentes.

Passo 4: Montando o main.rs Completo

Aqui esta o codigo completo do src/main.rs:

use async_graphql::{
    Enum, EmptySubscription, InputObject, Object, Schema, SimpleObject,
};
use async_graphql_axum::{GraphQLRequest, GraphQLResponse};
use axum::{
    extract::State,
    response::{Html, IntoResponse},
    routing::get,
    Router,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use uuid::Uuid;

// === Modelos ===

#[derive(Debug, Clone, Copy, Serialize, Deserialize, Enum, PartialEq, Eq)]
pub enum Genero {
    Ficcao,
    NaoFiccao,
    Romance,
    FiccaoCientifica,
    Fantasia,
    Terror,
    Biografia,
    Tecnico,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Livro {
    pub id: String,
    pub titulo: String,
    pub autor: String,
    pub genero: Genero,
    pub ano_publicacao: i32,
    pub paginas: i32,
    pub sinopse: Option<String>,
    pub avaliacao: Option<f64>,
}

#[Object]
impl Livro {
    async fn id(&self) -> &str { &self.id }
    async fn titulo(&self) -> &str { &self.titulo }
    async fn autor(&self) -> &str { &self.autor }
    async fn genero(&self) -> Genero { self.genero }
    async fn ano_publicacao(&self) -> i32 { self.ano_publicacao }
    async fn paginas(&self) -> i32 { self.paginas }
    async fn sinopse(&self) -> &Option<String> { &self.sinopse }
    async fn avaliacao(&self) -> &Option<f64> { &self.avaliacao }
    async fn classificacao(&self) -> &str {
        match self.avaliacao {
            Some(nota) if nota >= 4.5 => "Excelente",
            Some(nota) if nota >= 3.5 => "Bom",
            Some(nota) if nota >= 2.5 => "Regular",
            Some(_) => "Fraco",
            None => "Sem avaliacao",
        }
    }
}

#[derive(Debug, InputObject)]
pub struct NovoLivroInput {
    pub titulo: String,
    pub autor: String,
    pub genero: Genero,
    pub ano_publicacao: i32,
    pub paginas: i32,
    pub sinopse: Option<String>,
}

#[derive(Debug, InputObject)]
pub struct AtualizarLivroInput {
    pub titulo: Option<String>,
    pub autor: Option<String>,
    pub genero: Option<Genero>,
    pub ano_publicacao: Option<i32>,
    pub paginas: Option<i32>,
    pub sinopse: Option<String>,
    pub avaliacao: Option<f64>,
}

#[derive(SimpleObject)]
struct Estatisticas {
    total_livros: i32,
    media_paginas: f64,
}

pub type BancoDados = Arc<Mutex<HashMap<String, Livro>>>;

// === Query ===

pub struct QueryRoot;

#[Object]
impl QueryRoot {
    async fn livros(&self, ctx: &async_graphql::Context<'_>) -> Vec<Livro> {
        let db = ctx.data_unchecked::<BancoDados>();
        db.lock().unwrap().values().cloned().collect()
    }

    async fn livro(&self, ctx: &async_graphql::Context<'_>, id: String) -> Option<Livro> {
        let db = ctx.data_unchecked::<BancoDados>();
        db.lock().unwrap().get(&id).cloned()
    }

    async fn livros_por_autor(&self, ctx: &async_graphql::Context<'_>, autor: String) -> Vec<Livro> {
        let db = ctx.data_unchecked::<BancoDados>();
        db.lock().unwrap()
            .values()
            .filter(|l| l.autor.to_lowercase().contains(&autor.to_lowercase()))
            .cloned()
            .collect()
    }

    async fn livros_por_genero(&self, ctx: &async_graphql::Context<'_>, genero: Genero) -> Vec<Livro> {
        let db = ctx.data_unchecked::<BancoDados>();
        db.lock().unwrap()
            .values()
            .filter(|l| l.genero == genero)
            .cloned()
            .collect()
    }

    async fn estatisticas(&self, ctx: &async_graphql::Context<'_>) -> Estatisticas {
        let db = ctx.data_unchecked::<BancoDados>();
        let livros = db.lock().unwrap();
        let total = livros.len() as i32;
        let media = if total > 0 {
            livros.values().map(|l| l.paginas).sum::<i32>() as f64 / total as f64
        } else {
            0.0
        };
        Estatisticas { total_livros: total, media_paginas: media }
    }
}

// === Mutation ===

pub struct MutationRoot;

#[Object]
impl MutationRoot {
    async fn criar_livro(
        &self,
        ctx: &async_graphql::Context<'_>,
        input: NovoLivroInput,
    ) -> async_graphql::Result<Livro> {
        if input.titulo.trim().is_empty() {
            return Err("Titulo nao pode ser vazio".into());
        }
        if input.paginas <= 0 {
            return Err("Numero de paginas deve ser positivo".into());
        }

        let db = ctx.data_unchecked::<BancoDados>();
        let mut livros = db.lock().unwrap();
        let livro = Livro {
            id: Uuid::new_v4().to_string(),
            titulo: input.titulo,
            autor: input.autor,
            genero: input.genero,
            ano_publicacao: input.ano_publicacao,
            paginas: input.paginas,
            sinopse: input.sinopse,
            avaliacao: None,
        };
        livros.insert(livro.id.clone(), livro.clone());
        Ok(livro)
    }

    async fn atualizar_livro(
        &self,
        ctx: &async_graphql::Context<'_>,
        id: String,
        input: AtualizarLivroInput,
    ) -> async_graphql::Result<Livro> {
        let db = ctx.data_unchecked::<BancoDados>();
        let mut livros = db.lock().unwrap();
        let livro = livros.get_mut(&id)
            .ok_or_else(|| async_graphql::Error::new("Livro nao encontrado"))?;

        if let Some(titulo) = input.titulo { livro.titulo = titulo; }
        if let Some(autor) = input.autor { livro.autor = autor; }
        if let Some(genero) = input.genero { livro.genero = genero; }
        if let Some(ano) = input.ano_publicacao { livro.ano_publicacao = ano; }
        if let Some(paginas) = input.paginas { livro.paginas = paginas; }
        if let Some(sinopse) = input.sinopse { livro.sinopse = Some(sinopse); }
        if let Some(avaliacao) = input.avaliacao {
            if !(0.0..=5.0).contains(&avaliacao) {
                return Err("Avaliacao deve ser entre 0.0 e 5.0".into());
            }
            livro.avaliacao = Some(avaliacao);
        }
        Ok(livro.clone())
    }

    async fn remover_livro(
        &self,
        ctx: &async_graphql::Context<'_>,
        id: String,
    ) -> async_graphql::Result<bool> {
        let db = ctx.data_unchecked::<BancoDados>();
        if db.lock().unwrap().remove(&id).is_some() {
            Ok(true)
        } else {
            Err("Livro nao encontrado".into())
        }
    }
}

// === Schema e Handlers ===

type AppSchema = Schema<QueryRoot, MutationRoot, EmptySubscription>;

async fn graphql_handler(
    State(schema): State<AppSchema>,
    req: GraphQLRequest,
) -> GraphQLResponse {
    schema.execute(req.into_inner()).await.into()
}

async fn graphql_playground() -> impl IntoResponse {
    Html(async_graphql::http::playground_source(
        async_graphql::http::GraphQLPlaygroundConfig::new("/graphql"),
    ))
}

// === Main ===

fn dados_iniciais() -> HashMap<String, Livro> {
    let mut livros = HashMap::new();
    let exemplos = vec![
        Livro {
            id: Uuid::new_v4().to_string(),
            titulo: "O Senhor dos Aneis".to_string(),
            autor: "J.R.R. Tolkien".to_string(),
            genero: Genero::Fantasia,
            ano_publicacao: 1954,
            paginas: 1178,
            sinopse: Some("Uma epica jornada pela Terra Media".to_string()),
            avaliacao: Some(4.9),
        },
        Livro {
            id: Uuid::new_v4().to_string(),
            titulo: "O Programador Pragmatico".to_string(),
            autor: "David Thomas e Andrew Hunt".to_string(),
            genero: Genero::Tecnico,
            ano_publicacao: 1999,
            paginas: 352,
            sinopse: Some("Guia pratico para desenvolvimento de software".to_string()),
            avaliacao: Some(4.7),
        },
        Livro {
            id: Uuid::new_v4().to_string(),
            titulo: "Neuromancer".to_string(),
            autor: "William Gibson".to_string(),
            genero: Genero::FiccaoCientifica,
            ano_publicacao: 1984,
            paginas: 271,
            sinopse: Some("O classico cyberpunk que definiu um genero".to_string()),
            avaliacao: Some(4.2),
        },
    ];

    for livro in exemplos {
        livros.insert(livro.id.clone(), livro);
    }
    livros
}

#[tokio::main]
async fn main() {
    let db: BancoDados = Arc::new(Mutex::new(dados_iniciais()));

    let schema = Schema::build(QueryRoot, MutationRoot, EmptySubscription)
        .data(db)
        .finish();

    let app = Router::new()
        .route("/graphql", get(graphql_playground).post(graphql_handler))
        .with_state(schema);

    let endereco = "0.0.0.0:3000";
    println!("API GraphQL rodando em http://{}", endereco);
    println!("Playground: http://{}/graphql", endereco);

    let listener = tokio::net::TcpListener::bind(endereco).await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Como Executar

Compile e inicie o servidor:

cargo run

Acesse o playground GraphQL no navegador em http://localhost:3000/graphql. Experimente as seguintes queries:

# Listar todos os livros
query {
  livros {
    id
    titulo
    autor
    genero
    classificacao
  }
}

# Buscar livros por genero
query {
  livrosPorGenero(genero: FANTASIA) {
    titulo
    autor
    avaliacao
  }
}

# Criar um novo livro
mutation {
  criarLivro(input: {
    titulo: "Duna"
    autor: "Frank Herbert"
    genero: FICCAO_CIENTIFICA
    anoPublicacao: 1965
    paginas: 412
    sinopse: "Epopeia de ficcao cientifica no planeta deserto"
  }) {
    id
    titulo
  }
}

# Atualizar avaliacao
mutation {
  atualizarLivro(id: "ID_DO_LIVRO", input: { avaliacao: 4.8 }) {
    titulo
    avaliacao
    classificacao
  }
}

# Ver estatisticas
query {
  estatisticas {
    totalLivros
    mediaPaginas
  }
}

Tambem pode usar curl:

curl -X POST http://localhost:3000/graphql \
  -H "Content-Type: application/json" \
  -d '{"query": "{ livros { titulo autor } }"}'

Desafios para Expandir

  1. Subscriptions em tempo real – Adicione subscriptions GraphQL para que clientes recebam notificacoes quando novos livros sao adicionados, usando async-graphql com WebSockets.

  2. Paginacao e ordenacao – Implemente paginacao estilo Relay (cursors) e parametros de ordenacao (por titulo, data, avaliacao) na query de listagem de livros.

  3. Relacionamentos – Adicione entidades Autor e Editora com relacionamentos N:N, permitindo queries aninhadas como livro { autor { outrosLivros } }.

  4. DataLoader – Implemente batching com async-graphql::dataloader::DataLoader para evitar o problema N+1 quando resolver relacionamentos.

  5. Autenticacao – Adicione autenticacao via header JWT no contexto GraphQL, com diretivas que restringem certas mutations a usuarios autenticados.

Veja Tambem