utoipa + Axum: OpenAPI e Swagger UI em Rust | Rust Brasil

Documente APIs Axum com utoipa: ToSchema, paths tipados, Swagger UI, JWT, versionamento do contrato OpenAPI e checklist de portfólio para vagas Rust em 2026.

Para documentar uma API Axum em Rust com OpenAPI, use utoipa para gerar o contrato a partir dos tipos e, se quiser exploração interativa, sirva o Swagger UI com utoipa-swagger-ui. Na prática: derive ToSchema nas structs de entrada e saída, anote os handlers com #[utoipa::path], monte um #[derive(OpenApi)] com as tags e paths da API e exponha /api-docs/openapi.json junto com a UI. Assim a documentação deixa de ser um Markdown esquecido e passa a acompanhar o código que realmente compila.

Esse padrão responde à pergunta que aparece em portfólios, code reviews e vagas Rust: “onde está o contrato da API?”. Times que já usam Axum, Serde, SQLx e Tower ganham onboarding mais rápido, clientes tipados e uma forma clara de discutir breaking changes. Se você ainda está montando a base HTTP, comece pelo tutorial de API REST com Axum e pelo guia de serviços resilientes com Tower e Axum.

Resposta rápida: quando vale documentar com utoipa?

SituaçãoRecomendação
API interna consumida por um único frontend no mesmo repoOpenAPI ainda ajuda, mas pode começar enxuto
API pública, BFF ou integração com outros timesDocumente cedo com utoipa
Portfólio para vaga backend/plataformaInclua OpenAPI + exemplos de erro
Microsserviço gRPCPrefira .proto; use OpenAPI só nas bordas REST
Protótipo de um fim de semanaAdie a UI; mantenha ao menos schemas básicos
Contrato com versionamento e clientes externosTrate o JSON OpenAPI como artefato de release

A decisão não é “Swagger para tudo”. É escolher uma fonte de verdade próxima do compilador para request, response, status codes e autenticação.

Por que OpenAPI importa em APIs Rust

Rust já oferece muita segurança de tipos dentro do processo. O buraco aparece na fronteira HTTP: status codes, headers, formatos de erro, paginação e autenticação precisam ser comunicados a pessoas e a outras stacks. OpenAPI (também conhecido pelo legado “Swagger”) descreve esse contrato em JSON ou YAML de forma independente de linguagem.

Em times brasileiros de fintech, logística, healthtech e plataformas B2B, a API costuma ser consumida por frontend TypeScript, mobile, parceiros e jobs internos. Sem contrato explícito, cada mudança vira conversa de Slack e regressão silenciosa. Com contrato versionado, o review passa a discutir:

  • campos obrigatórios versus opcionais;
  • enums estáveis;
  • erros previsíveis;
  • autenticação Bearer ou API key;
  • compatibilidade entre versões.

Isso conversa diretamente com carreira. Em entrevistas de backend Rust e no nicho web/backend, saber explicar como o time evita drift entre código e documentação pesa tanto quanto citar o framework.

O que é utoipa

utoipa gera documentação OpenAPI a partir de anotações e derives Rust. Em vez de manter um arquivo YAML paralelo, você descreve schemas com #[derive(ToSchema)], documenta handlers com #[utoipa::path(...)] e agrega tudo em um tipo OpenApi. O resultado é um documento que pode ser servido como JSON, renderizado no Swagger UI ou consumido por geradores de cliente.

Pontos fortes para APIs Axum:

  • schemas reutilizam as mesmas structs Serde da aplicação;
  • paths ficam perto dos handlers;
  • tags organizam recursos (users, orders, health);
  • security schemes documentam JWT e API keys;
  • a UI facilita exploração local sem Postman no primeiro dia.

Limitações honestas:

  • macros aumentam a superfície de aprendizado;
  • handlers muito dinâmicos exigem cuidado na modelagem;
  • a documentação não substitui testes nem observabilidade;
  • upgrades de Axum/utoipa pedem leitura das notas de versão.

Uma alternativa citada no ecossistema é Aide, com outra ergonomia de extratores e roteamento. Este guia aprofunda utoipa porque ele é uma resposta direta e pesquisável para “OpenAPI no Axum” e encaixa bem no estilo derive-first que a maioria dos times Rust já usa com Serde.

Dependências de partida

Considere um serviço Axum 0.8 alinhado aos exemplos do site:

[dependencies]
axum = { version = "0.8", features = ["macros"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
utoipa = { version = "5", features = ["axum_extras"] }
utoipa-swagger-ui = { version = "8", features = ["axum"] }
tower-http = { version = "0.6", features = ["trace", "cors"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1", features = ["serde", "v4"] }
thiserror = "2"

Fixe versões no lockfile do projeto real e confira os features exatos da release que você adotar. A linha axum_extras ajuda a mapear extratores comuns do Axum para a documentação. Se a API autentica usuários, você vai acrescentar crates de JWT depois; o contrato OpenAPI já pode declarar o security scheme desde o início.

Modele os schemas com ToSchema

Comece pelas structs que já existem na borda HTTP. O ideal é que request, response e erro compartilhem as mesmas definições usadas pelos handlers.

use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;

#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct CriarTarefaRequest {
    /// Título curto exibido na lista
    #[schema(example = "Estudar utoipa")]
    pub titulo: String,
    /// Detalhe opcional da tarefa
    pub descricao: Option<String>,
}

#[derive(Debug, Serialize, ToSchema)]
pub struct TarefaResponse {
    pub id: Uuid,
    pub titulo: String,
    pub descricao: Option<String>,
    pub concluida: bool,
}

#[derive(Debug, Serialize, ToSchema)]
pub struct ErroApi {
    /// Código estável para o cliente tratar o caso
    pub codigo: String,
    /// Mensagem legível para humanos
    pub mensagem: String,
}

Boas práticas de schema:

  • documente campos com comentários /// — eles viram description no OpenAPI;
  • use Option de propósito; não transforme tudo em string vazia;
  • prefira enums Serde para status em vez de strings soltas;
  • evite expor structs de persistência internas se elas carregam colunas sensíveis;
  • dê exemplos realistas; a UI fica muito mais útil.

Se a API pagina resultados, modele explicitamente items, next_cursor e limit. Contratos vagos de “objeto livre” empurram o problema para o consumidor.

Documente handlers com utoipa::path

O próximo passo é amarrar método HTTP, path, parâmetros, body e respostas ao handler.

use axum::{extract::Path, http::StatusCode, Json};
use uuid::Uuid;

/// Cria uma nova tarefa
#[utoipa::path(
    post,
    path = "/tarefas",
    tag = "tarefas",
    request_body = CriarTarefaRequest,
    responses(
        (status = 201, description = "Tarefa criada", body = TarefaResponse),
        (status = 400, description = "Payload inválido", body = ErroApi),
        (status = 401, description = "Não autenticado", body = ErroApi)
    ),
    security(
        ("bearer_auth" = [])
    )
)]
pub async fn criar_tarefa(
    Json(payload): Json<CriarTarefaRequest>,
) -> Result<(StatusCode, Json<TarefaResponse>), (StatusCode, Json<ErroApi>)> {
    if payload.titulo.trim().is_empty() {
        return Err((
            StatusCode::BAD_REQUEST,
            Json(ErroApi {
                codigo: "titulo_obrigatorio".into(),
                mensagem: "Informe um título".into(),
            }),
        ));
    }

    Ok((
        StatusCode::CREATED,
        Json(TarefaResponse {
            id: Uuid::new_v4(),
            titulo: payload.titulo,
            descricao: payload.descricao,
            concluida: false,
        }),
    ))
}

/// Busca uma tarefa pelo id
#[utoipa::path(
    get,
    path = "/tarefas/{id}",
    tag = "tarefas",
    params(
        ("id" = Uuid, Path, description = "Identificador da tarefa")
    ),
    responses(
        (status = 200, description = "Tarefa encontrada", body = TarefaResponse),
        (status = 404, description = "Tarefa não encontrada", body = ErroApi)
    )
)]
pub async fn obter_tarefa(
    Path(id): Path<Uuid>,
) -> Result<Json<TarefaResponse>, (StatusCode, Json<ErroApi>)> {
    let _ = id;
    Err((
        StatusCode::NOT_FOUND,
        Json(ErroApi {
            codigo: "nao_encontrada".into(),
            mensagem: "Tarefa não encontrada".into(),
        }),
    ))
}

Observe o alinhamento: o que a macro declara precisa corresponder ao que o handler realmente devolve. Documentar 201 e devolver 200 é exatamente o tipo de drift que OpenAPI deveria evitar. Em APIs maiores, extraia padrões de erro com thiserror e converta para ErroApi em um único lugar — o guia de tratamento de erros com thiserror e anyhow ajuda a manter essa camada limpa.

Monte o documento OpenApi

Agregue schemas, paths e metadados em um tipo dedicado:

use utoipa::{Modify, OpenApi};
use utoipa::openapi::security::{Http, HttpAuthScheme, SecurityScheme};

#[derive(OpenApi)]
#[openapi(
    paths(
        criar_tarefa,
        obter_tarefa,
    ),
    components(
        schemas(CriarTarefaRequest, TarefaResponse, ErroApi)
    ),
    tags(
        (name = "tarefas", description = "CRUD de tarefas")
    ),
    modifiers(&SecurityAddon),
    info(
        title = "API de Tarefas",
        version = "1.0.0",
        description = "Exemplo de contrato OpenAPI com Axum e utoipa"
    )
)]
pub struct ApiDoc;

struct SecurityAddon;

impl Modify for SecurityAddon {
    fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
        let components = openapi.components.get_or_insert_with(Default::default);
        components.add_security_scheme(
            "bearer_auth",
            SecurityScheme::Http(Http::new(HttpAuthScheme::Bearer)),
        );
    }
}

Esse ApiDoc::openapi() vira a fonte do JSON. Trate version como versão do contrato, não necessariamente a mesma do crate. Se você publica breaking changes em /v2, deixe isso explícito no info e nas tags.

Exponha JSON e Swagger UI no Axum

Com o documento pronto, sirva-o:

use axum::{routing::get, Router};
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;

pub fn app() -> Router {
    Router::new()
        .route("/tarefas", axum::routing::post(criar_tarefa))
        .route("/tarefas/{id}", get(obter_tarefa))
        .merge(
            SwaggerUi::new("/swagger-ui")
                .url("/api-docs/openapi.json", ApiDoc::openapi()),
        )
}

Em desenvolvimento local, abra /swagger-ui e valide os exemplos. Em produção, decida conscientemente:

  • manter a UI apenas em staging;
  • proteger com autenticação interna;
  • publicar só o JSON para portais de desenvolvedores;
  • ou expor a UI pública se a API for aberta.

Nunca trate Swagger UI como plano de autenticação. A UI é exploração; a autorização continua nos handlers e na camada Tower.

Integre autenticação, erros e versionamento

JWT Bearer

Declare o security scheme, marque paths protegidos e documente 401/403 de verdade. Clientes gerados a partir do OpenAPI conseguem preencher o header com menos tentativa e erro. Se o token carrega claims, não precisa expor a struct interna inteira: documente só o que o consumidor precisa saber.

Erros estáveis

Prefira códigos de erro legíveis (titulo_obrigatorio, rate_limited, conflito_versao) em vez de mensagens soltas traduzidas ad hoc. Isso facilita telemetria e retries. O mesmo cuidado aparece em serviços resilientes e em APIs gRPC com Tonic, onde o contrato também é o produto.

Versionamento

Estratégias comuns:

  1. path (/v1/tarefas) — explícita e fácil de roteá;
  2. header de versão — flexível, porém menos óbvia na UI;
  3. evolução compatível no mesmo path — ótima até o primeiro breaking change inevitável.

Qualquer que seja a escolha, o documento OpenAPI precisa refletir a realidade do roteador Axum. Gerar um JSON “bonito” que não corresponde às rotas piora a confiança.

Valide o contrato na CI

Documentação que não entra no pipeline envelhece. Algumas práticas simples:

  1. Compile os derives — se o schema quebra, o build quebra.
  2. Exporte o JSON em um job e faça diff contra o artefato anterior em PRs.
  3. Falhe em breaking changes quando o time adota política semver para a API.
  4. Smoke test de /api-docs/openapi.json no ambiente de staging.
  5. Revise exemplos quando um campo muda de significado sem mudar de nome.

Ferramentas do ecossistema Cargo que você já pode ter no toolbox — cargo-nextest, cargo-audit e o cluster de supply chain com cargo-deny — cuidam de testes e dependências. O contrato OpenAPI merece o mesmo rigor: é interface externa.

Se o time também trabalha com Go, compare mentalmente com a cultura de geração de clientes e specs do ecossistema reunido no Golang Brasil. A linguagem muda; a necessidade de contrato versionado não.

Checklist de portfólio e vagas

Um projeto Axum fica muito mais convincente para carreira Rust quando o README mostra:

  • rotas reais com create/read/update;
  • autenticação documentada no OpenAPI;
  • erros com código estável e exemplos;
  • /api-docs/openapi.json acessível no ambiente de demo;
  • Swagger UI ou link para portal equivalente;
  • testes de integração cobrindo status codes documentados;
  • Docker Compose ou script de subida;
  • nota sobre versão do contrato e política de breaking changes.

Recrutadores e avaliadores técnicos raramente executam todos os caminhos da API. Eles abrem a documentação. Se o contrato estiver claro, o projeto parece produto; se estiver ausente, parece exercício de framework.

Erros frequentes

Documentar só o happy path

Esquecer 400, 401, 404 e 409 empurra o consumidor para engenharia reversa de logs.

Reutilizar a entity do banco como schema público

Vaza colunas internas, timestamps confusos e campos que você não quer comprometer semver.

Deixar a UI aberta com dados reais em produção

Swagger UI sem proteção pode expor superfície desnecessária. Separe exploração de operação.

Mudar significado de campo sem bump de versão

status: String que deixa de aceitar valores antigos é breaking change disfarçado.

Gerar OpenAPI e nunca olhar o JSON

Macros ajudam, mas alguém precisa revisar o artefato como parte do PR.

Confundir OpenAPI com gRPC

Borda REST documenta com OpenAPI; serviços internos de alto throughput podem preferir Protobuf. Muitos sistemas usam os dois.

Perguntas frequentes

O que é utoipa no Rust?

É uma crate para gerar OpenAPI a partir de tipos e anotações Rust. Com ToSchema, utoipa::path e OpenApi, o contrato fica próximo do código compilado.

Como documentar uma API Axum com OpenAPI?

Modele schemas, anote handlers, agregue um OpenApi e sirva o JSON — com ou sem Swagger UI. Mantenha status codes e erros alinhados ao comportamento real.

utoipa substitui testes de contrato?

Não. A especificação descreve; os testes verificam. Use ambos: OpenAPI para comunicação e suíte automatizada para regressão.

Qual a diferença entre utoipa e Aide?

São abordagens diferentes no ecossistema Axum. utoipa é derive/macro-centric; Aide enfatiza outra integração com extratores e roteamento. Avalie pela ergonomia do time.

OpenAPI ajuda em vagas e portfólio Rust?

Ajuda bastante. Mostra preocupação com contrato, versionamento e experiência de quem consome a API — sinais de senioridade em backend.

Conclusão

OpenAPI não é burocracia de big tech: é a forma mais clara de dizer o que a sua API Axum aceita, devolve e rejeita. Com utoipa, essa descrição nasce dos mesmos tipos Serde que a aplicação já usa, e o Swagger UI transforma o contrato em ferramenta de exploração para o time e para quem avalia o seu portfólio.

Comece pequeno: duas rotas, um schema de erro estável, Bearer auth e o JSON versionado na CI. Depois evolua tags, exemplos e política de breaking changes. Para aprofundar o stack ao redor, volte ao guia de Axum, ao tutorial de API REST, ao comparativo SQLx vs Diesel vs SeaORM e às ferramentas essenciais do Cargo. Contrato bom não substitui código bom — mas código bom sem contrato envelhece mais rápido do que deveria.