Skip to main content

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}