Skip to main content

doxa_macros/
lib.rs

1//! Procedural macros for [`doxa`](../doxa/index.html).
2//!
3//! Provides:
4//!
5//! - [`macro@ApiError`] — derive that wires an enum into both
6//!   [`axum::response::IntoResponse`] and [`utoipa::IntoResponses`] from a
7//!   single per-variant `#[api(...)]` declaration. Multiple variants may share
8//!   a status code; they are grouped into one OpenAPI Response per status with
9//!   each variant becoming a named example.
10//! - [`macro@SseEvent`] — derive that implements
11//!   [`SseEventMeta`](../doxa/trait.SseEventMeta.html) for a tagged enum
12//!   so [`SseStream`](../doxa/struct.SseStream.html) can name each event
13//!   frame after the variant carrying it.
14//! - HTTP method shortcuts ([`macro@get`], [`macro@post`], [`macro@put`],
15//!   [`macro@patch`], [`macro@delete`]) that delegate to [`utoipa::path`] with
16//!   sensible defaults filled in. Use [`macro@operation`] for cases that need a
17//!   custom HTTP method or multi-method registration.
18//!
19//! Consumers should depend on `doxa` (with the default `macros`
20//! feature) and import these macros via `doxa::{get, post,
21//! ApiError, SseEvent, …}` rather than depending on this crate
22//! directly.
23//!
24//! # Tour
25//!
26//! Every macro the crate exports, exercised end-to-end. Compiles
27//! under `cargo test --doc`.
28//!
29//! ```no_run
30//! use axum::Json;
31//! use doxa::{
32//!     routes, ApiDocBuilder, ApiResult, DocumentedHeader, Header,
33//!     MountDocsExt, MountOpts, OpenApiRouter, SseEventMeta, SseStream, ToSchema,
34//! };
35//! use doxa::{get, post, ApiError, SseEvent};
36//! use futures_core::Stream;
37//! use serde::{Deserialize, Serialize};
38//! use std::convert::Infallible;
39//!
40//! // -- ApiError: multi-variant-per-status grouping --------------------------
41//! #[derive(Debug, thiserror::Error, Serialize, ToSchema, ApiError)]
42//! enum WidgetError {
43//!     #[error("validation failed: {0}")]
44//!     #[api(status = 400, code = "validation_error")]
45//!     Validation(String),
46//!
47//!     // Second variant at the same status — the OpenAPI spec emits one
48//!     // 400 response with two named examples.
49//!     #[error("conflict: {0}")]
50//!     #[api(status = 400, code = "conflict")]
51//!     Conflict(String),
52//!
53//!     #[error("not found")]
54//!     #[api(status = 404, code = "not_found")]
55//!     NotFound,
56//! }
57//!
58//! // -- SseEvent: variant-tagged event stream --------------------------------
59//! #[derive(Serialize, ToSchema, SseEvent)]
60//! #[serde(tag = "event", content = "data", rename_all = "snake_case")]
61//! enum BuildEvent {
62//!     Started { id: u64 },
63//!     Progress { done: u64, total: u64 },
64//!     // Override the default snake-case event name.
65//!     #[sse(name = "finished")]
66//!     Completed,
67//! }
68//!
69//! // -- DocumentedHeader: typed header on the handler signature --------------
70//! struct XApiKey;
71//! impl DocumentedHeader for XApiKey {
72//!     fn name() -> &'static str { "X-Api-Key" }
73//!     fn description() -> &'static str { "Tenant API key" }
74//! }
75//!
76//! // -- Method shortcuts: tags, request body, headers, Result return --------
77//! #[derive(Debug, Serialize, ToSchema)]
78//! struct Widget { id: u32, name: String }
79//!
80//! #[derive(Debug, Deserialize, ToSchema)]
81//! struct CreateWidget { name: String }
82//!
83//! /// Single tag — forwarded to utoipa as `tag = "Widgets"`.
84//! #[get("/widgets", tag = "Widgets")]
85//! async fn list_widgets(
86//!     Header(_key, ..): Header<XApiKey>,
87//! ) -> ApiResult<Json<Vec<Widget>>, WidgetError> {
88//!     Ok(Json(vec![]))
89//! }
90//!
91//! /// Multiple tags — emitted as `tags = ["Widgets", "Public"]`.
92//! /// Inferred request body (`Json<CreateWidget>`), inferred 201
93//! /// success from `(StatusCode, Json<T>)`, error responses folded
94//! /// in from the `Err` half of the return.
95//! #[post("/widgets", tags("Widgets", "Public"))]
96//! async fn create_widget(
97//!     Json(req): Json<CreateWidget>,
98//! ) -> ApiResult<(axum::http::StatusCode, Json<Widget>), WidgetError> {
99//!     Ok((
100//!         axum::http::StatusCode::CREATED,
101//!         Json(Widget { id: 1, name: req.name }),
102//!     ))
103//! }
104//!
105//! /// Document a header without extracting its value — the marker is
106//! /// listed under `headers(...)` and dedupes against any concurrent
107//! /// `Header<H>` extractor on the same handler.
108//! #[get("/health", headers(XApiKey))]
109//! async fn health() -> &'static str { "ok" }
110//!
111//! /// SseStream<E, _> return is recognized by the macro and emitted as
112//! /// a `text/event-stream` response with one `oneOf` branch per
113//! /// `SseEvent` variant.
114//! #[get("/builds/{id}/events", tag = "Builds")]
115//! async fn stream_build(
116//! ) -> SseStream<BuildEvent, impl Stream<Item = Result<BuildEvent, Infallible>>> {
117//!     SseStream::new(futures::stream::iter(Vec::new()))
118//! }
119//!
120//! # async fn run() {
121//! let (router, openapi) = OpenApiRouter::<()>::new()
122//!     .routes(routes!(list_widgets, create_widget, health))
123//!     .routes(routes!(stream_build))
124//!     .split_for_parts();
125//!
126//! let api_doc = ApiDocBuilder::new()
127//!     .title("Tour")
128//!     .version("1.0.0")
129//!     .merge(openapi)
130//!     .build();
131//!
132//! let app = router.mount_docs(api_doc, MountOpts::default());
133//! # let _ = app;
134//! # }
135//! ```
136//!
137//! ## Header form equivalence
138//!
139//! The shortcut macros recognize two ways to declare a header on a
140//! handler — the `Header<H>` extractor in the signature **and** the
141//! `headers(H, …)` attribute. Both rely on the
142//! [`DocumentedHeader`](../doxa/trait.DocumentedHeader.html)
143//! trait, which exposes the wire name as a runtime fn so the same
144//! marker can be reused on the layer side via
145//! [`HeaderParam::typed`](../doxa/struct.HeaderParam.html#method.typed).
146//! Both forms are interchangeable and dedupe against each other if
147//! the same marker appears in both, so listing a header in
148//! `headers(...)` while also extracting it never produces two spec
149//! entries.
150//!
151//! See the `doxa` crate-level docs for the broader design.
152
153use proc_macro::TokenStream;
154
155mod api_error;
156mod capability;
157mod method;
158mod sig;
159mod sse_event;
160
161/// Derive [`axum::response::IntoResponse`] and [`utoipa::IntoResponses`]
162/// for an error enum from a single per-variant declaration.
163///
164/// Each variant is annotated with `#[api_error(status = N, code =
165/// "string")]` where:
166///
167/// - `status` — the HTTP status code as a `u16` literal
168/// - `code` — an application-level error code string written into the `code`
169///   field of the
170///   [`doxa::ApiErrorBody`](../doxa/struct.ApiErrorBody.html)
171///   response body emitted by the generated `IntoResponse` impl
172///
173/// Multiple variants may share the same status code. The derive groups
174/// them at expand time so the OpenAPI spec emits one `Response` per
175/// status with each variant contributing a named example.
176///
177/// # Example
178///
179/// ```ignore
180/// use doxa_macros::ApiError;
181///
182/// #[derive(Debug, thiserror::Error, ApiError)]
183/// pub enum MyError {
184///     #[error("validation failed: {0}")]
185///     #[api_error(status = 400, code = "validation_error")]
186///     Validation(String),
187///
188///     #[error("query failed: {0}")]
189///     #[api_error(status = 400, code = "query_error")]
190///     Query(String),
191///
192///     #[error("not found: {0}")]
193///     #[api_error(status = 404, code = "not_found")]
194///     NotFound(String),
195///
196///     #[error("internal error")]
197///     #[api_error(status = 500, code = "internal")]
198///     Internal,
199/// }
200/// ```
201///
202/// The generated `IntoResponse` impl maps each variant to its declared
203/// status and emits an `ApiErrorBody` envelope with the variant's
204/// `code` and the variant's `Display` output as the `message`. The
205/// `IntoResponses` impl groups `Validation` and `Query` under one
206/// `400` response with two examples.
207#[proc_macro_derive(ApiError, attributes(api, api_error, api_default))]
208pub fn derive_api_error(input: TokenStream) -> TokenStream {
209    api_error::expand(input.into())
210        .unwrap_or_else(syn::Error::into_compile_error)
211        .into()
212}
213
214/// Derive [`SseEventMeta`](../doxa/trait.SseEventMeta.html) for an
215/// enum whose variants represent the events of a Server-Sent Event
216/// stream.
217///
218/// Pair with upstream `serde::Serialize` and `utoipa::ToSchema` derives
219/// plus `#[serde(tag = "event", content = "data", rename_all =
220/// "snake_case")]` so the wire format and the OpenAPI schema stay
221/// aligned. Each variant's event name defaults to its snake-case form;
222/// override with `#[sse(name = "…")]`.
223///
224/// ```rust,ignore
225/// use doxa::SseEvent;
226///
227/// #[derive(serde::Serialize, utoipa::ToSchema, SseEvent)]
228/// #[serde(tag = "event", content = "data", rename_all = "snake_case")]
229/// enum MigrationEvent {
230///     Started { pipeline: String },
231///     Progress { done: u64, total: u64 },
232///     #[sse(name = "finished")]
233///     Completed,
234///     Heartbeat,
235/// }
236/// ```
237///
238/// The derive does not implement `Serialize` or `ToSchema` itself —
239/// that keeps serde's renaming rules authoritative and avoids
240/// duplicating them in this crate.
241#[proc_macro_derive(SseEvent, attributes(sse))]
242pub fn derive_sse_event(input: TokenStream) -> TokenStream {
243    sse_event::expand(input.into())
244        .unwrap_or_else(syn::Error::into_compile_error)
245        .into()
246}
247
248/// Shortcut for `#[utoipa::path(get, path = "...")]`.
249///
250/// Auto-fills `operation_id` from the function name when omitted. The
251/// path string lives in exactly one place.
252///
253/// Supports `tag = "..."` for a single tag or `tags("A", "B")` for
254/// multiple tags. Tags control how operations are grouped in
255/// documentation UIs (Scalar, Swagger UI, Redoc) and code generators.
256///
257/// Additional `key = value` pairs are forwarded to `utoipa::path`
258/// verbatim, so any feature accepted by the upstream macro (request
259/// body, responses, security, params) works without modification.
260///
261/// # Tags
262///
263/// ```rust,ignore
264/// // Single tag (forwarded to utoipa as-is):
265/// #[get("/api/v1/models", tag = "Models")]
266///
267/// // Multiple tags (extracted and emitted as `tags = [...]`):
268/// #[get("/api/v1/models", tags("Models", "Public API"))]
269/// ```
270#[proc_macro_attribute]
271pub fn get(args: TokenStream, item: TokenStream) -> TokenStream {
272    method::expand("get", args.into(), item.into())
273        .unwrap_or_else(syn::Error::into_compile_error)
274        .into()
275}
276
277/// `#[post("/path", ...)]` shortcut for [`utoipa::path`]. See
278/// [`macro@get`] for the inference rules.
279#[proc_macro_attribute]
280pub fn post(args: TokenStream, item: TokenStream) -> TokenStream {
281    method::expand("post", args.into(), item.into())
282        .unwrap_or_else(syn::Error::into_compile_error)
283        .into()
284}
285
286/// `#[put("/path", ...)]` shortcut for [`utoipa::path`]. See
287/// [`macro@get`] for the inference rules.
288#[proc_macro_attribute]
289pub fn put(args: TokenStream, item: TokenStream) -> TokenStream {
290    method::expand("put", args.into(), item.into())
291        .unwrap_or_else(syn::Error::into_compile_error)
292        .into()
293}
294
295/// `#[patch("/path", ...)]` shortcut for [`utoipa::path`]. See
296/// [`macro@get`] for the inference rules.
297#[proc_macro_attribute]
298pub fn patch(args: TokenStream, item: TokenStream) -> TokenStream {
299    method::expand("patch", args.into(), item.into())
300        .unwrap_or_else(syn::Error::into_compile_error)
301        .into()
302}
303
304/// `#[delete("/path", ...)]` shortcut for [`utoipa::path`]. See
305/// [`macro@get`] for the inference rules.
306#[proc_macro_attribute]
307pub fn delete(args: TokenStream, item: TokenStream) -> TokenStream {
308    method::expand("delete", args.into(), item.into())
309        .unwrap_or_else(syn::Error::into_compile_error)
310        .into()
311}
312
313/// Declare a `Capable` marker type backed by a `Capability` constant.
314///
315/// Generates the struct, a hidden `Capability` constant, and the
316/// `Capable` impl so the marker can be used with
317/// `doxa_auth::Require<M>` immediately. Requires `doxa-policy` in the
318/// consumer's dependency tree.
319///
320/// # Attribute arguments
321///
322/// - `name = "scope.name"` — the stable client-facing capability identifier.
323/// - `description = "Human-readable description"` — displayed in UI badges.
324/// - `checks(action = "...", entity_type = "...", entity_id = "...")` — one or
325///   more check blocks. All must pass for the capability to be granted.
326///
327/// # Example
328///
329/// ```rust,ignore
330/// use doxa_macros::capability;
331///
332/// #[capability(
333///     name = "widgets.read",
334///     description = "Read widget definitions",
335///     checks(action = "read", entity_type = "Widget", entity_id = "collection"),
336/// )]
337/// pub struct WidgetsRead;
338/// ```
339#[proc_macro_attribute]
340pub fn capability(args: TokenStream, item: TokenStream) -> TokenStream {
341    capability::expand(args.into(), item.into()).into()
342}
343
344/// Generic operation attribute for cases where the HTTP method must be
345/// specified explicitly (multi-method routes, non-standard verbs).
346///
347/// `#[operation(get, "/path", ...)]` is equivalent to
348/// `#[get("/path", ...)]`. Prefer the method-specific shortcuts for
349/// clarity.
350#[proc_macro_attribute]
351pub fn operation(args: TokenStream, item: TokenStream) -> TokenStream {
352    method::expand_operation(args.into(), item.into())
353        .unwrap_or_else(syn::Error::into_compile_error)
354        .into()
355}