Procedural macros for doxa.
Provides:
- [
macro@ApiError] — derive that wires an enum into both [axum::response::IntoResponse] and [utoipa::IntoResponses] from a single per-variant#[api(...)]declaration. Multiple variants may share a status code; they are grouped into one OpenAPI Response per status with each variant becoming a named example. - [
macro@SseEvent] — derive that implementsSseEventMetafor a tagged enum soSseStreamcan name each event frame after the variant carrying it. - HTTP method shortcuts ([
macro@get], [macro@post], [macro@put], [macro@patch], [macro@delete]) that delegate to [utoipa::path] with sensible defaults filled in. Use [macro@operation] for cases that need a custom HTTP method or multi-method registration.
Consumers should depend on doxa (with the default macros
feature) and import these macros via doxa::{get, post, ApiError, SseEvent, …} rather than depending on this crate
directly.
Tour
Every macro the crate exports, exercised end-to-end. Compiles
under cargo test --doc.
use axum::Json;
use doxa::{
routes, ApiDocBuilder, ApiResult, DocumentedHeader, Header,
MountDocsExt, MountOpts, OpenApiRouter, SseEventMeta, SseStream, ToSchema,
};
use doxa::{get, post, ApiError, SseEvent};
use futures_core::Stream;
use serde::{Deserialize, Serialize};
use std::convert::Infallible;
// -- ApiError: multi-variant-per-status grouping --------------------------
#[derive(Debug, thiserror::Error, Serialize, ToSchema, ApiError)]
enum WidgetError {
#[error("validation failed: {0}")]
#[api(status = 400, code = "validation_error")]
Validation(String),
// Second variant at the same status — the OpenAPI spec emits one
// 400 response with two named examples.
#[error("conflict: {0}")]
#[api(status = 400, code = "conflict")]
Conflict(String),
#[error("not found")]
#[api(status = 404, code = "not_found")]
NotFound,
}
// -- SseEvent: variant-tagged event stream --------------------------------
#[derive(Serialize, ToSchema, SseEvent)]
#[serde(tag = "event", content = "data", rename_all = "snake_case")]
enum BuildEvent {
Started { id: u64 },
Progress { done: u64, total: u64 },
// Override the default snake-case event name.
#[sse(name = "finished")]
Completed,
}
// -- DocumentedHeader: typed header on the handler signature --------------
struct XApiKey;
impl DocumentedHeader for XApiKey {
fn name() -> &'static str { "X-Api-Key" }
fn description() -> &'static str { "Tenant API key" }
}
// -- Method shortcuts: tags, request body, headers, Result return --------
#[derive(Debug, Serialize, ToSchema)]
struct Widget { id: u32, name: String }
#[derive(Debug, Deserialize, ToSchema)]
struct CreateWidget { name: String }
/// Single tag — forwarded to utoipa as `tag = "Widgets"`.
#[get("/widgets", tag = "Widgets")]
async fn list_widgets(
Header(_key, ..): Header<XApiKey>,
) -> ApiResult<Json<Vec<Widget>>, WidgetError> {
Ok(Json(vec![]))
}
/// Multiple tags — emitted as `tags = ["Widgets", "Public"]`.
/// Inferred request body (`Json<CreateWidget>`), inferred 201
/// success from `(StatusCode, Json<T>)`, error responses folded
/// in from the `Err` half of the return.
#[post("/widgets", tags("Widgets", "Public"))]
async fn create_widget(
Json(req): Json<CreateWidget>,
) -> ApiResult<(axum::http::StatusCode, Json<Widget>), WidgetError> {
Ok((
axum::http::StatusCode::CREATED,
Json(Widget { id: 1, name: req.name }),
))
}
/// Document a header without extracting its value — the marker is
/// listed under `headers(...)` and dedupes against any concurrent
/// `Header<H>` extractor on the same handler.
#[get("/health", headers(XApiKey))]
async fn health() -> &'static str { "ok" }
/// SseStream<E, _> return is recognized by the macro and emitted as
/// 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>>> {
SseStream::new(futures::stream::iter(Vec::new()))
}
# async fn run() {
let (router, openapi) = OpenApiRouter::<()>::new()
.routes(routes!(list_widgets, create_widget, health))
.routes(routes!(stream_build))
.split_for_parts();
let api_doc = ApiDocBuilder::new()
.title("Tour")
.version("1.0.0")
.merge(openapi)
.build();
let app = router.mount_docs(api_doc, MountOpts::default());
# let _ = app;
# }
Header form equivalence
The shortcut macros recognize two ways to declare a header on a
handler — the Header<H> extractor in the signature and the
headers(H, …) attribute. Both rely on the
DocumentedHeader
trait, which exposes the wire name as a runtime fn so the same
marker can be reused on the layer side via
HeaderParam::typed.
Both forms are interchangeable and dedupe against each other if
the same marker appears in both, so listing a header in
headers(...) while also extracting it never produces two spec
entries.
See the doxa crate-level docs for the broader design.