doxa_docs/doc_traits.rs
1//! Role-based traits through which handler-argument types contribute
2//! to an OpenAPI [`Operation`]. Mirrors how axum splits request
3//! handling across [`FromRequestParts`][frp] / [`FromRequest`][fr]:
4//! each extractor plays one or more documentation roles, and each
5//! role is one trait.
6//!
7//! [frp]: https://docs.rs/axum/latest/axum/extract/trait.FromRequestParts.html
8//! [fr]: https://docs.rs/axum/latest/axum/extract/trait.FromRequest.html
9//!
10//! ## Why separate traits per role
11//!
12//! A single unified trait would force every implementor to opt in or
13//! out of every role up-front and would collide under coherence when
14//! multiple blanket impls tried to cover overlapping wrapper types.
15//! Splitting by role lets a transparent wrapper such as `Valid<T>`
16//! forward each role independently without coupling them.
17//!
18//! ## Extension for third-party extractors
19//!
20//! A wrapper that is semantically transparent (forwards all behavior
21//! to its inner extractor) adds one blanket impl per role it wants to
22//! surface. For example:
23//!
24//! ```ignore
25//! impl<T: DocQueryParams> DocQueryParams for MyValidator<T> {
26//! fn describe(op: &mut Operation) { T::describe(op) }
27//! }
28//! ```
29//!
30//! The method macro emits unconditional trait calls for every handler
31//! argument via autoref-specialized dispatch (see
32//! [`crate::__private`]); types that do not implement a given role
33//! silently no-op instead of failing compilation.
34//!
35//! ## Path parameter names
36//!
37//! The method macro parses `{name}` segments from the route template
38//! and passes them to [`DocPathParams::describe`] as the
39//! `path_param_names` slice. Struct-form `Path<T: IntoParams>` ignores
40//! the slice (names come from the struct). Scalar and tuple `Path<T>`
41//! use the slice by index — a scalar `Path<Uuid>` takes names[0]; a
42//! tuple `Path<(A, B)>` takes names[0] and names[1] — because the
43//! URL-template segment names, not the handler's binding names, are
44//! authoritative in OpenAPI.
45
46use utoipa::openapi::path::{Operation, ParameterBuilder, ParameterIn};
47use utoipa::openapi::schema::{KnownFormat, SchemaFormat};
48use utoipa::openapi::{ObjectBuilder, RefOr, Required, Schema, Type};
49use utoipa::{IntoParams, PartialSchema};
50
51use crate::headers::DocumentedHeader;
52
53/// Contributes query parameters to an operation.
54///
55/// Implemented by [`axum::extract::Query<T>`] for any `T: IntoParams`,
56/// and by transparent wrappers (e.g. `Valid<Query<T>>`) via blanket
57/// impls that forward to the inner type.
58pub trait DocQueryParams {
59 /// Append this extractor's query parameters to `op.parameters`.
60 fn describe(op: &mut Operation);
61}
62
63/// Contributes path parameters to an operation.
64///
65/// Handles three shapes via separate impls:
66/// - struct form — `Path<T: IntoParams>` with `#[into_params(parameter_in =
67/// Path)]` on `T`; names come from the struct's field identifiers.
68/// - scalar form — `Path<T: PathScalar>` for primitives; `names[0]` provides
69/// the parameter name from the route template.
70/// - tuple form — `Path<(T1, …, Tn)>` where every element is `PathScalar`;
71/// `names[i]` provides the i-th parameter name.
72pub trait DocPathParams {
73 /// Append path parameters to `op.parameters`.
74 ///
75 /// `path_param_names` is the ordered list of `{name}` segments
76 /// parsed from the route template.
77 fn describe(op: &mut Operation, path_param_names: &[&'static str]);
78}
79
80/// Sealed trait for primitives usable as scalar `Path` parameters.
81/// Implementations supply the OpenAPI schema for the parameter —
82/// done manually (rather than via `utoipa::PartialSchema`) because
83/// common scalar path types like `Uuid` are recognized by utoipa
84/// only via token inspection in derives, not through a `PartialSchema`
85/// impl.
86pub trait PathScalar: sealed::Sealed {
87 /// OpenAPI schema to embed for this scalar parameter.
88 fn path_scalar_schema() -> RefOr<Schema>;
89}
90
91mod sealed {
92 pub trait Sealed {}
93}
94
95/// Contributes header parameters to an operation.
96pub trait DocHeaderParams {
97 /// Append header parameters to `op.parameters`.
98 fn describe(op: &mut Operation);
99}
100
101/// Contributes the request body schema to an operation.
102///
103/// Only one extractor per handler should implement this — a handler
104/// with two request bodies is ill-formed.
105pub trait DocRequestBody {
106 /// Set `op.request_body` to describe the body this extractor consumes.
107 fn describe(op: &mut Operation);
108}
109
110/// Extractor-side contribution of per-operation security/permission
111/// metadata. Implemented by per-route guards (e.g. a permission
112/// extractor that names the action it requires) so the resulting
113/// OpenAPI operation documents the requirement.
114///
115/// Prefer this over [`crate::DocumentedLayer`] when the requirement
116/// varies per handler — `DocumentedLayer` stamps the same contribution
117/// on every operation a layer covers, which is the right tool for
118/// blanket "must be authenticated" declarations but the wrong tool for
119/// per-route permissions.
120///
121/// Implementations typically emit both a standard
122/// [`SecurityRequirement`](utoipa::openapi::security::SecurityRequirement)
123/// (so OpenAPI codegen sees the required scope) and an
124/// `x-required-permissions` extension (so doc UIs surface a
125/// human-readable badge). The
126/// [`crate::record_required_permission`] helper does the dual write
127/// in one call.
128pub trait DocOperationSecurity {
129 /// Append this extractor's security/permission metadata to `op`.
130 fn describe(op: &mut Operation);
131}
132
133// ---------------------------------------------------------------------------
134// Built-in extractor impls
135// ---------------------------------------------------------------------------
136
137impl<T: IntoParams> DocQueryParams for axum::extract::Query<T> {
138 fn describe(op: &mut Operation) {
139 let params = T::into_params(|| Some(ParameterIn::Query));
140 if params.is_empty() {
141 return;
142 }
143 op.parameters.get_or_insert_with(Vec::new).extend(params);
144 }
145}
146
147/// Struct-form path impl — names come from `T::into_params`.
148impl<T: IntoParams> DocPathParams for axum::extract::Path<T> {
149 fn describe(op: &mut Operation, _path_param_names: &[&'static str]) {
150 let params = T::into_params(|| Some(ParameterIn::Path));
151 if params.is_empty() {
152 return;
153 }
154 op.parameters.get_or_insert_with(Vec::new).extend(params);
155 }
156}
157
158/// Helper: push one scalar path parameter built from schema `T` and a
159/// route-template name.
160fn push_scalar_path<T: PathScalar>(op: &mut Operation, name: &str) {
161 let param = ParameterBuilder::new()
162 .name(name)
163 .parameter_in(ParameterIn::Path)
164 .required(Required::True)
165 .schema(Some(T::path_scalar_schema()))
166 .build();
167 op.parameters.get_or_insert_with(Vec::new).push(param);
168}
169
170/// Build an inline schema of a given OpenAPI [`Type`], optionally
171/// with a known format hint (e.g. `int32`).
172fn scalar_schema(ty: Type, fmt: Option<KnownFormat>) -> RefOr<Schema> {
173 let mut b = ObjectBuilder::new().schema_type(ty);
174 if let Some(f) = fmt {
175 b = b.format(Some(SchemaFormat::KnownFormat(f)));
176 }
177 RefOr::T(Schema::Object(b.build()))
178}
179
180/// Fetch `path_param_names[index]` or fall back to a synthetic name.
181/// The fallback keeps the spec syntactically valid when the handler's
182/// pattern arity disagrees with the URL template.
183fn name_at(names: &[&'static str], index: usize) -> String {
184 names
185 .get(index)
186 .map(|s| (*s).to_string())
187 .unwrap_or_else(|| format!("param{index}"))
188}
189
190// `PathScalar` impls for primitives and well-known types. Schemas
191// mirror what utoipa emits for the same types in a struct-derived
192// schema (see utoipa-gen `schema_type::SchemaTypeInner` for the
193// reference set).
194macro_rules! impl_path_scalar {
195 ($($t:ty => ($ty_enum:expr, $fmt:expr)),* $(,)?) => {
196 $(
197 impl sealed::Sealed for $t {}
198 impl PathScalar for $t {
199 fn path_scalar_schema() -> RefOr<Schema> {
200 scalar_schema($ty_enum, $fmt)
201 }
202 }
203 )*
204 };
205}
206
207impl_path_scalar!(
208 bool => (Type::Boolean, None),
209 i8 => (Type::Integer, Some(KnownFormat::Int32)),
210 i16 => (Type::Integer, Some(KnownFormat::Int32)),
211 i32 => (Type::Integer, Some(KnownFormat::Int32)),
212 i64 => (Type::Integer, Some(KnownFormat::Int64)),
213 i128 => (Type::Integer, None),
214 isize => (Type::Integer, None),
215 u8 => (Type::Integer, Some(KnownFormat::Int32)),
216 u16 => (Type::Integer, Some(KnownFormat::Int32)),
217 u32 => (Type::Integer, Some(KnownFormat::Int32)),
218 u64 => (Type::Integer, Some(KnownFormat::Int64)),
219 u128 => (Type::Integer, None),
220 usize => (Type::Integer, None),
221 f32 => (Type::Number, Some(KnownFormat::Float)),
222 f64 => (Type::Number, Some(KnownFormat::Double)),
223 String => (Type::String, None),
224);
225
226impl sealed::Sealed for uuid::Uuid {}
227impl PathScalar for uuid::Uuid {
228 fn path_scalar_schema() -> RefOr<Schema> {
229 scalar_schema(Type::String, Some(KnownFormat::Uuid))
230 }
231}
232
233// Note: scalar `Path<T>` must NOT overlap with the struct-form impl
234// `Path<T: IntoParams>` above. Rust coherence rejects two blanket
235// impls with overlapping bounds, so we emit scalar and tuple impls
236// via a separate trait [`DocPathParamsScalarOrTuple`] that
237// `DocPathParams for &Path<...>` lifts — the autoref-specialization
238// layer in [`crate::__private`] selects between them. The struct
239// impl wins when `T: IntoParams`; the scalar/tuple impl wins
240// otherwise via the probe's fallback chain.
241//
242// For now we provide scalar/tuple support via direct impls at a
243// *different* receiver ref-depth than the struct impl, using a
244// distinct trait below. The method macro's dispatch struct invokes
245// both probes; at most one contributes.
246
247/// Scalar/tuple path impl trait. Kept separate from
248/// [`DocPathParams`] to avoid overlap with the struct-form impl.
249pub trait DocPathScalar {
250 /// Append scalar/tuple path parameter(s) to `op.parameters`.
251 fn describe_scalar(op: &mut Operation, path_param_names: &[&'static str]);
252}
253
254impl<T: PathScalar> DocPathScalar for axum::extract::Path<T> {
255 fn describe_scalar(op: &mut Operation, path_param_names: &[&'static str]) {
256 let name = name_at(path_param_names, 0);
257 push_scalar_path::<T>(op, &name);
258 }
259}
260
261// Tuple arity impls via declarative macro — mirrors axum's
262// `all_the_tuples!` pattern so new arities stay easy to add.
263macro_rules! impl_tuple_path {
264 ($($idx:tt => $T:ident),+ $(,)?) => {
265 impl<$($T: PathScalar),+> DocPathScalar for axum::extract::Path<($($T,)+)> {
266 fn describe_scalar(op: &mut Operation, path_param_names: &[&'static str]) {
267 $(
268 let name = name_at(path_param_names, $idx);
269 push_scalar_path::<$T>(op, &name);
270 )+
271 }
272 }
273 };
274}
275
276impl_tuple_path!(0 => T1, 1 => T2);
277impl_tuple_path!(0 => T1, 1 => T2, 2 => T3);
278impl_tuple_path!(0 => T1, 1 => T2, 2 => T3, 3 => T4);
279impl_tuple_path!(0 => T1, 1 => T2, 2 => T3, 3 => T4, 4 => T5);
280impl_tuple_path!(0 => T1, 1 => T2, 2 => T3, 3 => T4, 4 => T5, 5 => T6);
281impl_tuple_path!(0 => T1, 1 => T2, 2 => T3, 3 => T4, 4 => T5, 5 => T6, 6 => T7);
282impl_tuple_path!(0 => T1, 1 => T2, 2 => T3, 3 => T4, 4 => T5, 5 => T6, 6 => T7, 7 => T8);
283
284impl<H: DocumentedHeader> DocHeaderParams for crate::extractor::Header<H> {
285 fn describe(op: &mut Operation) {
286 use utoipa::openapi::path::{ParameterBuilder, ParameterIn as InLoc};
287 use utoipa::openapi::{ObjectBuilder, RefOr, Required, Schema, Type};
288 let mut b = ParameterBuilder::new()
289 .name(H::name())
290 .parameter_in(InLoc::Header)
291 .required(Required::True)
292 .schema(Some(RefOr::T(Schema::Object(
293 ObjectBuilder::new().schema_type(Type::String).build(),
294 ))));
295 let desc = H::description();
296 if !desc.is_empty() {
297 b = b.description(Some(desc.to_string()));
298 }
299 if let Some(ex) = H::example() {
300 b = b.example(Some(serde_json::Value::String(ex.to_string())));
301 }
302 op.parameters.get_or_insert_with(Vec::new).push(b.build());
303 }
304}
305
306impl<T: utoipa::ToSchema + PartialSchema + 'static> DocRequestBody for axum::Json<T> {
307 fn describe(op: &mut Operation) {
308 use utoipa::openapi::request_body::RequestBodyBuilder;
309 use utoipa::openapi::{ContentBuilder, Required};
310 let content = ContentBuilder::new().schema(Some(T::schema())).build();
311 let body = RequestBodyBuilder::new()
312 .content("application/json", content)
313 .required(Some(Required::True))
314 .build();
315 op.request_body = Some(body);
316 }
317}