doxa-docs 0.1.0

Ergonomic OpenAPI documentation and Scalar UI hosting for axum. Built on utoipa + utoipa-axum with minimal handler attributes and in-memory spec serving.
Documentation

doxa

Ergonomic OpenAPI documentation for axum services. Built on top of [utoipa] and [utoipa_axum], this crate provides:

  • An [ApiDocBuilder] for assembling an OpenAPI document from a [utoipa::openapi::OpenApi] value, finalizing it into an in-memory [ApiDoc] whose serialized JSON is shared via a reference-counted [bytes::Bytes] buffer.
  • A [mount_docs] helper that mounts GET /openapi.json plus an interactive documentation UI on an existing [axum::Router], all from memory — the spec is never written to disk.
  • An RFC 7807 [ProblemDetails] response body usable as the default error schema across a project.

All UI integrations are feature-gated independently. The default feature set enables docs-scalar which mounts the Scalar API reference UI from a CDN-loaded HTML template, rendered out of the box with the three-pane modern layout, dark mode on, the schemas index hidden, the codegen sidebar suppressed, and Scalar's paid product upsells (Agent / MCP) disabled. Every one of those choices is overridable via [ScalarConfig] passed through [MountOpts::scalar]. Scalar is preferred because it is actively maintained, parses OpenAPI 3.2 natively, renders the x-badges vendor extension, and surfaces required OAuth2 scopes inline under each operation — covering per-operation permission requirements produced by extractor-side [DocOperationSecurity] impls.

Tour

The full surface of the crate, end to end. Every macro, derive, extractor, and builder method that ships in the default feature set appears in the snippet below and the whole thing compiles under cargo test --doc.

use axum::Json;
use doxa::{
    routes, ApiDocBuilder, ApiErrorBody, ApiResult, DocumentedHeader, Header,
    MountDocsExt, MountOpts, OpenApiRouter, ScalarConfig, ScalarLayout, ScalarTheme,
    SseEvent, SseEventMeta, SseSpecVersion, SseStream, ToSchema,
};
use doxa::{get, post, ApiError};
use futures_core::Stream;
use serde::{Deserialize, Serialize};
use std::convert::Infallible;

// ----- Typed error envelope ----------------------------------------------
//
// `#[derive(ApiError)]` wires both `IntoResponse` and `IntoResponses`
// from per-variant `#[api(status, code)]` attributes. Multiple
// variants may share a status — they are grouped into one OpenAPI
// response with separate examples.
#[derive(Debug, thiserror::Error, Serialize, ToSchema, ApiError)]
enum WidgetError {
    #[error("validation failed: {0}")]
    #[api(status = 400, code = "validation_error")]
    Validation(String),

    #[error("conflict: {0}")]
    #[api(status = 400, code = "conflict")]
    Conflict(String),

    #[error("not found")]
    #[api(status = 404, code = "not_found")]
    NotFound,

    #[error("internal error")]
    #[api(status = 500, code = "internal")]
    Internal,
}

// ----- Typed request / response bodies -----------------------------------
#[derive(Debug, Serialize, ToSchema)]
struct Widget { id: u32, name: String }

#[derive(Debug, Deserialize, ToSchema)]
struct CreateWidget { name: String }

// ----- Typed header extractor --------------------------------------------
//
// Implementing `DocumentedHeader` on a marker type lets the same
// marker drive both extraction (via `Header<XApiKey>`) and OpenAPI
// documentation. The macro recognizes `Header<H>` in the handler
// signature and emits the corresponding params block automatically.
struct XApiKey;
impl DocumentedHeader for XApiKey {
    fn name() -> &'static str { "X-Api-Key" }
    fn description() -> &'static str { "Tenant API key" }
}

// ----- SSE event stream --------------------------------------------------
//
// `#[derive(SseEvent)]` provides the per-variant event name; pair
// it with `serde::Serialize` and `ToSchema` so the wire format and
// OpenAPI schema stay aligned. `SseStream<E, S>` is the response
// wrapper — handlers never construct axum's `Sse` directly.
#[derive(Serialize, ToSchema, SseEvent)]
#[serde(tag = "event", content = "data", rename_all = "snake_case")]
enum BuildEvent {
    Started { id: u64 },
    Progress { done: u64, total: u64 },
    #[sse(name = "finished")]
    Completed,
}

// ----- Handlers ----------------------------------------------------------
/// Create a widget. The path uses the `#[post]` shortcut, takes a
/// typed JSON body and a typed header, and returns an
/// `ApiResult<Json<T>, E>` so successes and the full error
/// vocabulary both flow into the OpenAPI document.
#[post("/widgets", tag = "Widgets")]
async fn create_widget(
    Header(_key, ..): Header<XApiKey>,
    Json(req): Json<CreateWidget>,
) -> ApiResult<(axum::http::StatusCode, Json<Widget>), WidgetError> {
    if req.name.is_empty() {
        return Err(WidgetError::Validation("name is required".into()));
    }
    Ok((
        axum::http::StatusCode::CREATED,
        Json(Widget { id: 42, name: req.name }),
    ))
}

/// Stream build progress as Server-Sent Events. The macro
/// recognizes `SseStream<E, _>` and emits a `text/event-stream`
/// response with one `oneOf` branch per `SseEvent` variant.
#[get("/builds/{id}/events", tag = "Builds")]
async fn stream_build(
) -> SseStream<BuildEvent, impl Stream<Item = Result<BuildEvent, Infallible>>> {
    let events = futures::stream::iter(vec![
        Ok(BuildEvent::Started { id: 1 }),
        Ok(BuildEvent::Progress { done: 1, total: 10 }),
        Ok(BuildEvent::Completed),
    ]);
    SseStream::new(events)
}

// ----- Compose, finalize, mount -----------------------------------------
# async fn run() {
let (router, openapi) = OpenApiRouter::<()>::new()
    .routes(routes!(create_widget))
    .routes(routes!(stream_build))
    .split_for_parts();

let api_doc = ApiDocBuilder::new()
    .title("Widgets API")
    .version("1.0.0")
    .description("Tour service")
    .bearer_security("bearerAuth")
    .tag_group("Core", ["Widgets"])
    .tag_group("Streaming", ["Builds"])
    // Use OpenAPI 3.2 `itemSchema` for SSE responses (the default).
    .sse_openapi_version(SseSpecVersion::V3_2)
    .merge(openapi)
    .build();

// Customize the Scalar UI: classic single-column layout with a
// light theme, dark mode off. `MountOpts::default()` keeps the
// historical three-pane modern dark-mode appearance.
let app = router.mount_docs(
    api_doc,
    MountOpts::default()
        .scalar(
            ScalarConfig::default()
                .layout(ScalarLayout::Classic)
                .theme(ScalarTheme::Solarized)
                .dark_mode(false),
        ),
);
# // The body envelope `ApiErrorBody` is the shape every error
# // response carries; reference it here so the import is exercised.
# let _: ApiErrorBody<()> = ApiErrorBody::new(500, "internal", "boom", ());
# let _ = app;
# }

The crate's public surface contains no project-specific types — everything is generic over utoipa's native types so it can be lifted into any axum project.