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