doxa_docs/doc_responses.rs
1//! Role trait through which a handler's return type contributes the
2//! success response to an OpenAPI [`Operation`].
3//!
4//! Parallel to the per-argument role traits in [`crate::doc_traits`] —
5//! query/path/header/request-body/security are per-argument, whereas
6//! the response body is per-return-type. The method-shortcut macros
7//! invoke the trait via autoref-specialized dispatch (see
8//! [`crate::__private::ResponseBodyContribution`]) once per handler,
9//! so types that do not implement [`DocResponseBody`] silently
10//! contribute nothing instead of failing compilation.
11//!
12//! # Extension for third-party response wrappers
13//!
14//! Implement [`DocResponseBody`] on the type a handler returns. The
15//! impl mutates the operation's 200 response to add the appropriate
16//! content-type and schema reference, and optionally registers any
17//! referenced schemas on the output vector so they land in
18//! `components.schemas`.
19//!
20//! ```ignore
21//! use doxa::DocResponseBody;
22//! use utoipa::openapi::path::Operation;
23//! use utoipa::openapi::{RefOr, Schema};
24//!
25//! pub struct Csv<T>(pub T);
26//!
27//! impl<T: utoipa::PartialSchema + 'static> DocResponseBody for Csv<T> {
28//! fn describe(op: &mut Operation, _: &mut Vec<(String, RefOr<Schema>)>) {
29//! // add a 200 / text/csv / $ref to T here
30//! let _ = op;
31//! }
32//! }
33//! ```
34
35use utoipa::openapi::path::Operation;
36use utoipa::openapi::response::ResponseBuilder;
37use utoipa::openapi::{Content, RefOr, Schema};
38
39/// Describe how a handler's return type contributes to its
40/// OpenAPI operation's 200 response.
41///
42/// Invoked once per handler at spec-build time via the
43/// [`crate::__private::ResponseBodyContribution`] autoref dispatch.
44/// Implementors mutate `op.responses.responses` (typically inserting
45/// an entry at `"200"`) and append any schema components the response
46/// references to `schemas` so they can be registered on
47/// `components.schemas` by the surrounding
48/// [`crate::ApidocHandlerSchemas`] machinery.
49///
50/// The blanket impl on [`Result<Ok, Err>`] means handlers returning
51/// `Result<Foo, MyError>` transparently defer to `Foo`'s impl; the
52/// error half is handled separately by utoipa's
53/// [`utoipa::IntoResponses`] from the macro's existing `responses(E)`
54/// emission.
55pub trait DocResponseBody {
56 /// Add the success response entry to `op` and append any referenced
57 /// schemas to `schemas`.
58 fn describe(op: &mut Operation, schemas: &mut Vec<(String, RefOr<Schema>)>);
59}
60
61// ---------------------------------------------------------------------------
62// axum::Json<T> — 200 application/json
63// ---------------------------------------------------------------------------
64
65impl<T> DocResponseBody for axum::Json<T>
66where
67 T: utoipa::PartialSchema + utoipa::ToSchema + 'static,
68{
69 fn describe(op: &mut Operation, schemas: &mut Vec<(String, RefOr<Schema>)>) {
70 if looks_nominal::<T>() {
71 // Nominal types (objects, enums) get a `$ref` to
72 // `components.schemas.<name>` plus the schema registered
73 // there — matches utoipa's native `body = T` output for
74 // doc compactness.
75 register_named_schema::<T>(schemas);
76 insert_ref_json_200::<T>(op);
77 } else {
78 // Generic containers (Vec<T>, Option<T>, …) lack a nominal
79 // component name of their own — render their schema
80 // inline. utoipa's derived `PartialSchema::schema()`
81 // already embeds `$ref`s to their nominal element types
82 // where appropriate, so nested refs still resolve.
83 insert_inline_json_200::<T>(op);
84 <T as utoipa::ToSchema>::schemas(schemas);
85 }
86 }
87}
88
89// ---------------------------------------------------------------------------
90// SseStream<E, S> — 200 text/event-stream with x-sse-stream marker
91// ---------------------------------------------------------------------------
92
93impl<E, S> DocResponseBody for crate::SseStream<E, S>
94where
95 E: utoipa::PartialSchema + utoipa::ToSchema + 'static,
96{
97 fn describe(op: &mut Operation, schemas: &mut Vec<(String, RefOr<Schema>)>) {
98 insert_sse_200::<E>(op);
99 register_named_schema::<E>(schemas);
100 }
101}
102
103// ---------------------------------------------------------------------------
104// Result<Ok, Err> — passthrough on the success side
105// ---------------------------------------------------------------------------
106
107impl<Ok, Err> DocResponseBody for Result<Ok, Err>
108where
109 Ok: DocResponseBody,
110{
111 fn describe(op: &mut Operation, schemas: &mut Vec<(String, RefOr<Schema>)>) {
112 <Ok as DocResponseBody>::describe(op, schemas)
113 }
114}
115
116// ---------------------------------------------------------------------------
117// Helpers
118// ---------------------------------------------------------------------------
119
120fn insert_ref_json_200<T>(op: &mut Operation)
121where
122 T: utoipa::PartialSchema + utoipa::ToSchema,
123{
124 if op.responses.responses.contains_key("200") {
125 // Caller supplied an explicit override via `responses(...)` —
126 // don't overwrite.
127 return;
128 }
129 let name = schema_component_name::<T>();
130 let reference = RefOr::Ref(utoipa::openapi::Ref::new(format!(
131 "#/components/schemas/{name}"
132 )));
133 let content = Content::new(Some(reference));
134 let response = ResponseBuilder::new()
135 .description("")
136 .content("application/json", content)
137 .build();
138 op.responses
139 .responses
140 .insert("200".to_string(), RefOr::T(response));
141}
142
143fn insert_inline_json_200<T>(op: &mut Operation)
144where
145 T: utoipa::PartialSchema,
146{
147 if op.responses.responses.contains_key("200") {
148 return;
149 }
150 let content = Content::new(Some(<T as utoipa::PartialSchema>::schema()));
151 let response = ResponseBuilder::new()
152 .description("")
153 .content("application/json", content)
154 .build();
155 op.responses
156 .responses
157 .insert("200".to_string(), RefOr::T(response));
158}
159
160fn insert_sse_200<E>(op: &mut Operation)
161where
162 E: utoipa::PartialSchema + utoipa::ToSchema,
163{
164 if op.responses.responses.contains_key("200") {
165 return;
166 }
167 // SSE responses use a `$ref` directly to the event enum by name —
168 // the 3.2 post-process in `ApiDocBuilder::build` relies on that
169 // shape to rewrite `schema` → `itemSchema`. Nominal tagged enums
170 // always have a non-empty `ToSchema::name`.
171 let name = schema_component_name::<E>();
172 let schema = if name.is_empty() {
173 <E as utoipa::PartialSchema>::schema()
174 } else {
175 RefOr::Ref(utoipa::openapi::Ref::new(format!(
176 "#/components/schemas/{name}"
177 )))
178 };
179 let content = Content::new(Some(schema));
180 let response = ResponseBuilder::new()
181 .description("")
182 .content("text/event-stream", content)
183 .build();
184 op.responses
185 .responses
186 .insert("200".to_string(), RefOr::T(response));
187 // Tag the text/event-stream content entry so
188 // `ApiDocBuilder::build`'s post-process can rewrite it under the
189 // selected `SseSpecVersion`. See `crate::sse::mark_sse_response`.
190 crate::sse::mark_sse_response(op);
191}
192
193/// Runtime heuristic that distinguishes a "nominal" schema type
194/// (struct, enum, union — user-derived with `#[derive(ToSchema)]`)
195/// from a generic container type (`Vec<T>`, `Option<T>`, arrays, …).
196///
197/// utoipa's macro layer distinguishes these at compile time via type
198/// tree analysis. At runtime we only have the schema value and the
199/// name, so we serialize `T`'s schema and inspect its shape:
200///
201/// - `"type": "array"` → container, **not** nominal.
202/// - `"$ref": "…"` → already a reference; treat as nominal.
203/// - `"oneOf"` / `"allOf"` / `"anyOf"` → tagged enum / polymorphic → nominal.
204/// - Otherwise (including `"type": "object"` and the absence of a `"type"` key)
205/// → nominal.
206///
207/// Nominal types get a `$ref` response and are registered on
208/// `components.schemas`; non-nominal types render inline.
209///
210/// Generic instantiations (detected via [`has_collision_prone_name`])
211/// are still nominal, but they're registered under a composed name
212/// — `Paginated_Inner` — to avoid the bare-ident collision that
213/// would otherwise clobber sibling instantiations in
214/// `components.schemas`. See [`composed_schema_name`] for the
215/// naming rule, which mirrors utoipa's own per-field composition at
216/// `utoipa-gen/src/component.rs` (the `format!("{}_{}", base,
217/// children)` branch).
218fn looks_nominal<T: utoipa::PartialSchema + utoipa::ToSchema>() -> bool {
219 if <T as utoipa::ToSchema>::name().is_empty() {
220 return false;
221 }
222 let schema = <T as utoipa::PartialSchema>::schema();
223 let Ok(value) = serde_json::to_value(&schema) else {
224 return false;
225 };
226 let Some(obj) = value.as_object() else {
227 return false;
228 };
229 if obj.contains_key("$ref") {
230 return true;
231 }
232 !matches!(obj.get("type"), Some(serde_json::Value::String(s)) if s == "array")
233}
234
235/// Detects generic instantiations whose [`utoipa::ToSchema::name`]
236/// is the bare Rust ident (no per-instantiation suffix) — e.g.
237/// `Paginated<A>` and `Paginated<B>` both report `"Paginated"`.
238///
239/// Returns `true` when [`std::any::type_name`] reveals Rust-level
240/// generic arguments (`<…>`) that the schema name does not encode
241/// (no `<` and no `_`-separated suffix). Callers compose a richer
242/// name via [`composed_schema_name`] when this is true; types with
243/// `#[schema(as = Path<Inner>)]` — which already encode the
244/// instantiation — return `false` and register under their declared
245/// name unchanged.
246pub(crate) fn has_collision_prone_name<T: utoipa::ToSchema>() -> bool {
247 let rust_name = std::any::type_name::<T>();
248 if !rust_name.contains('<') {
249 return false;
250 }
251 let schema_name = <T as utoipa::ToSchema>::name();
252 !schema_name.contains('<') && !schema_name.contains('_')
253}
254
255/// Compose a per-instantiation schema name by joining `T`'s
256/// [`utoipa::ToSchema::name`] with the inner generic argument names
257/// parsed from [`std::any::type_name`], using the same
258/// `{outer}_{inner}` convention utoipa uses for field-composed names
259/// at `utoipa-gen/src/component.rs` (the `format!("{}_{}", base,
260/// children)` path).
261///
262/// Example: `Paginated<datalake::SourceSummary>` →
263/// `"Paginated_SourceSummary"`. Multi-argument generics join every
264/// argument in declaration order:
265/// `Map<Key, datalake::SourceSummary>` → `"Map_Key_SourceSummary"`.
266///
267/// Only used when [`has_collision_prone_name`] returns `true`. The
268/// inner names are parsed from the type-name string rather than via
269/// a trait lookup because Rust has no way to iterate type arguments
270/// of an arbitrary generic type at runtime — but
271/// [`std::any::type_name`] is guaranteed to contain the arguments
272/// inside the outer `<…>`, which is enough for OpenAPI naming.
273pub(crate) fn composed_schema_name<T: utoipa::ToSchema>() -> String {
274 let rust_name = std::any::type_name::<T>();
275 let outer = <T as utoipa::ToSchema>::name();
276 let mut composed = String::from(outer.as_ref());
277 for segment in split_top_level_generic_args(rust_name) {
278 composed.push('_');
279 composed.push_str(last_path_segment(segment));
280 }
281 composed
282}
283
284/// Extract the top-level generic arguments from a type-name string
285/// produced by [`std::any::type_name`]. Respects angle-bracket
286/// nesting so `Map<Key, Vec<Foo>>` yields `["Key", "Vec<Foo>"]`, not
287/// three splits on the comma.
288///
289/// Returns an empty iterator if the type has no generic arguments
290/// (no outer `<…>` in the type name).
291fn split_top_level_generic_args(type_name: &str) -> Vec<&str> {
292 let Some(open) = type_name.find('<') else {
293 return Vec::new();
294 };
295 let Some(close) = type_name.rfind('>') else {
296 return Vec::new();
297 };
298 if close <= open {
299 return Vec::new();
300 }
301 let body = &type_name[open + 1..close];
302 let mut out = Vec::new();
303 let mut depth: i32 = 0;
304 let mut start = 0;
305 for (i, ch) in body.char_indices() {
306 match ch {
307 '<' => depth += 1,
308 '>' => depth -= 1,
309 ',' if depth == 0 => {
310 out.push(body[start..i].trim());
311 start = i + 1;
312 }
313 _ => {}
314 }
315 }
316 let tail = body[start..].trim();
317 if !tail.is_empty() {
318 out.push(tail);
319 }
320 out
321}
322
323/// Return the last `::`-separated segment of a Rust path (the
324/// trailing ident), plus any generic arguments attached to it. Used
325/// to drop module prefixes when composing OpenAPI component names —
326/// `datalake_server::api::Foo` becomes `Foo`.
327fn last_path_segment(path: &str) -> &str {
328 // Strip leading reference / whitespace noise that type_name
329 // might include.
330 let path = path.trim().trim_start_matches('&').trim();
331 // Only look at the path prefix up to the first `<`, since the
332 // segment inside generic args doesn't belong to the outer ident.
333 let prefix_end = path.find('<').unwrap_or(path.len());
334 let prefix = &path[..prefix_end];
335 let last_sep = prefix.rfind("::").map(|i| i + 2).unwrap_or(0);
336 &path[last_sep..]
337}
338
339/// Register a nominal type `T` under its [`utoipa::ToSchema::name`] in
340/// the component-schemas collection, plus every schema transitively
341/// referenced by it.
342///
343/// `ToSchema::schemas` walks transitive dependencies but does not
344/// always include the root type itself (particularly for
345/// `#[serde(tag, content)]` enums and for concrete instantiations of
346/// generic types whose inner parameters never appear as a direct
347/// return type elsewhere — see the
348/// [`crate::__private::GenericArgSchemaContribution`] probe). We
349/// insert the root under its name so `$ref`s resolve. Callers should
350/// only invoke this for types that [`looks_nominal`] considers
351/// nominal — passing a container like `Vec<T>` would register a bogus
352/// `Vec` component.
353///
354/// Exposed at crate scope so the per-handler `ApidocHandlerSchemas`
355/// probes generated by the method macros can compensate for utoipa's
356/// generic-parameter gap: when a handler returns
357/// `Json<Paginated<SourceSummary>>`, utoipa registers
358/// `Paginated_SourceSummary` but leaves `SourceSummary` dangling
359/// because the derive filters type-parameter fields into the
360/// `generic_references` bucket that emits only the recursive
361/// `<T as ToSchema>::schemas(out)` call and never pushes `T`'s own
362/// `(name, schema)` pair. The method macro walks the return type's
363/// nested generic arguments and routes each one through
364/// `register_named_schema` via the autoref probe, closing the gap.
365pub(crate) fn register_named_schema<T>(out: &mut Vec<(String, RefOr<utoipa::openapi::Schema>)>)
366where
367 T: utoipa::PartialSchema + utoipa::ToSchema,
368{
369 let name = schema_component_name::<T>();
370 if !name.is_empty() && !out.iter().any(|(n, _)| *n == name) {
371 out.push((name, <T as utoipa::PartialSchema>::schema()));
372 }
373 <T as utoipa::ToSchema>::schemas(out);
374}
375
376/// Resolve the OpenAPI component name used for a schema of type
377/// `T` — either the plain [`utoipa::ToSchema::name`] or, for
378/// collision-prone generic instantiations, the composed
379/// `{outer}_{inner}` name produced by [`composed_schema_name`].
380///
381/// Callers that need to emit `$ref` pointers into
382/// `components.schemas` should use this function so the `$ref`
383/// target matches the key [`register_named_schema`] would push
384/// under.
385pub(crate) fn schema_component_name<T: utoipa::PartialSchema + utoipa::ToSchema>() -> String {
386 if has_collision_prone_name::<T>() {
387 composed_schema_name::<T>()
388 } else {
389 <T as utoipa::ToSchema>::name().into_owned()
390 }
391}