Skip to main content

doxa_docs/
builder.rs

1//! Build an [`ApiDoc`] from project metadata plus a
2//! [`utoipa::openapi::OpenApi`].
3//!
4//! [`ApiDocBuilder`] is the assembly point that takes title / version /
5//! description / servers / security schemes plus an existing
6//! [`utoipa::openapi::OpenApi`] (typically obtained from
7//! [`utoipa_axum::router::OpenApiRouter::split_for_parts`]) and produces
8//! an immutable [`ApiDoc`] whose JSON serialization lives in an
9//! [`Arc<str>`] — built once at startup, shared across requests with
10//! zero copying.
11
12use std::collections::{BTreeSet, HashMap, HashSet};
13use std::fmt;
14use std::sync::Arc;
15
16use bytes::Bytes;
17use utoipa::openapi::security::{Flow, HttpAuthScheme, HttpBuilder, OAuth2, SecurityScheme};
18use utoipa::openapi::{
19    ContactBuilder, InfoBuilder, License, OpenApi, OpenApiBuilder, ServerBuilder,
20};
21
22/// Immutable, in-memory OpenAPI document with its serialized JSON form
23/// pre-rendered into a [`Bytes`] buffer.
24///
25/// Cloning is cheap (`Bytes` is reference-counted, `openapi` is wrapped
26/// in [`Arc`]). The JSON is serialized exactly once when
27/// [`ApiDocBuilder::build`] is called and shared across all subsequent
28/// reads — handlers built by [`crate::mount_docs`] hand the same
29/// [`Bytes`] to every response with zero copying.
30#[derive(Clone)]
31pub struct ApiDoc {
32    /// The structured OpenAPI document.
33    pub openapi: Arc<OpenApi>,
34    /// The serialized JSON form of [`Self::openapi`], pre-rendered for
35    /// zero-copy serving from memory.
36    pub spec_json: Bytes,
37}
38
39impl fmt::Debug for ApiDoc {
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        f.debug_struct("ApiDoc")
42            .field("title", &self.openapi.info.title)
43            .field("version", &self.openapi.info.version)
44            .field("paths", &self.openapi.paths.paths.len())
45            .field("spec_json_bytes", &self.spec_json.len())
46            .finish()
47    }
48}
49
50/// Errors that can occur while [`ApiDocBuilder::build`]ing an [`ApiDoc`].
51#[derive(Debug)]
52pub enum BuildError {
53    /// The structured OpenAPI document could not be serialized to JSON.
54    /// In practice this only fails if a custom schema produced
55    /// non-finite floats or otherwise invalid JSON values.
56    Serialize(serde_json::Error),
57}
58
59impl fmt::Display for BuildError {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        match self {
62            Self::Serialize(e) => write!(f, "failed to serialize OpenAPI document: {e}"),
63        }
64    }
65}
66
67impl std::error::Error for BuildError {
68    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
69        match self {
70            Self::Serialize(e) => Some(e),
71        }
72    }
73}
74
75/// Builder for an [`ApiDoc`].
76///
77/// Typical use:
78///
79/// ```no_run
80/// use doxa::ApiDocBuilder;
81/// use utoipa_axum::router::OpenApiRouter;
82///
83/// let (router, openapi) = OpenApiRouter::<()>::new().split_for_parts();
84///
85/// let api_doc = ApiDocBuilder::new()
86///     .title("Example API")
87///     .version(env!("CARGO_PKG_VERSION"))
88///     .description("Example service")
89///     .server("/", "current host")
90///     .bearer_security("bearer")
91///     .merge(openapi)
92///     .build();
93/// # let _ = (router, api_doc);
94/// ```
95#[derive(Default)]
96pub struct ApiDocBuilder {
97    title: Option<String>,
98    version: Option<String>,
99    description: Option<String>,
100    contact_name: Option<String>,
101    contact_email: Option<String>,
102    contact_url: Option<String>,
103    license_name: Option<String>,
104    license_url: Option<String>,
105    servers: Vec<(String, Option<String>)>,
106    security_schemes: Vec<(String, SecurityScheme)>,
107    /// Explicit tag metadata (name → description). When present, these
108    /// descriptions override the auto-generated ones.
109    tags: Vec<(String, String)>,
110    /// Explicit tag groups. When non-empty, auto-grouping is disabled.
111    tag_groups: Vec<(String, Vec<String>)>,
112    /// Name for the default tag group that collects tags without a
113    /// prefix delimiter. Defaults to `"API"` when auto-grouping.
114    default_tag_group: Option<String>,
115    /// Delimiter used to split tag names into `(group, name)` for
116    /// auto-generated tag groups. Defaults to `": "`.
117    tag_group_delimiter: Option<String>,
118    schema_tags: Vec<(String, String)>,
119    base: Option<OpenApi>,
120    /// OpenAPI version used to render `text/event-stream` responses.
121    /// Defaults to [`SseSpecVersion::V3_2`].
122    sse_spec_version: SseSpecVersion,
123}
124
125/// OpenAPI spec version used to render Server-Sent Event (SSE) responses
126/// in the generated document.
127///
128/// The runtime layer ([`crate::SseStream`], [`crate::SseEventMeta`])
129/// is version-agnostic — the only effect of this choice is the shape
130/// of the `text/event-stream` response in the rendered OpenAPI JSON
131/// and, with [`Self::V3_2`], the document's root `openapi` field.
132///
133/// | Variant | Root `openapi` | SSE response key | Typical consumer |
134/// |---|---|---|---|
135/// | [`Self::V3_2`] | `"3.2.0"` | `itemSchema` | Scalar, Swagger UI 5.32+ |
136/// | [`Self::V3_1`] | `"3.1.0"` | `schema` | Redoc, openapi-generator, older tooling |
137///
138/// Default is [`Self::V3_2`] because Scalar — the UI this crate mounts —
139/// supports it natively and `itemSchema` is the documented way to
140/// describe SSE streams. Downstream consumers that still need 3.1
141/// (Redoc, openapi-generator, anything on swagger-parser < 3.2) should
142/// opt out via [`ApiDocBuilder::sse_openapi_version`].
143#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
144pub enum SseSpecVersion {
145    /// Emit OpenAPI 3.1-compatible SSE responses (`schema` under
146    /// `text/event-stream`, root `openapi` left at utoipa's default
147    /// `"3.1.0"`).
148    V3_1,
149    /// Emit OpenAPI 3.2 SSE responses (`itemSchema` under
150    /// `text/event-stream`, root `openapi` set to `"3.2.0"`).
151    #[default]
152    V3_2,
153}
154
155impl ApiDocBuilder {
156    /// Construct an empty builder. Title, version, and at least one path
157    /// (via [`Self::merge`]) should be supplied before [`Self::build`].
158    pub fn new() -> Self {
159        Self::default()
160    }
161
162    /// Set the API title that appears in the document's `info.title`.
163    pub fn title(mut self, title: impl Into<String>) -> Self {
164        self.title = Some(title.into());
165        self
166    }
167
168    /// Set the API version that appears in the document's `info.version`.
169    /// Conventionally `env!("CARGO_PKG_VERSION")`.
170    pub fn version(mut self, version: impl Into<String>) -> Self {
171        self.version = Some(version.into());
172        self
173    }
174
175    /// Set the API description that appears in the document's
176    /// `info.description`. Markdown is supported by most viewers.
177    pub fn description(mut self, description: impl Into<String>) -> Self {
178        self.description = Some(description.into());
179        self
180    }
181
182    /// Set the contact name on the document's `info.contact`.
183    pub fn contact_name(mut self, name: impl Into<String>) -> Self {
184        self.contact_name = Some(name.into());
185        self
186    }
187
188    /// Set the contact email on the document's `info.contact`.
189    pub fn contact_email(mut self, email: impl Into<String>) -> Self {
190        self.contact_email = Some(email.into());
191        self
192    }
193
194    /// Set the contact URL on the document's `info.contact`.
195    pub fn contact_url(mut self, url: impl Into<String>) -> Self {
196        self.contact_url = Some(url.into());
197        self
198    }
199
200    /// Set the license name on the document's `info.license`.
201    pub fn license(mut self, name: impl Into<String>) -> Self {
202        self.license_name = Some(name.into());
203        self
204    }
205
206    /// Set the license URL on the document's `info.license`.
207    pub fn license_url(mut self, url: impl Into<String>) -> Self {
208        self.license_url = Some(url.into());
209        self
210    }
211
212    /// Append a server entry. The first call sets the primary server;
213    /// additional calls add alternates (staging, regional endpoints).
214    pub fn server(mut self, url: impl Into<String>, description: impl Into<String>) -> Self {
215        let description = description.into();
216        let description = if description.is_empty() {
217            None
218        } else {
219            Some(description)
220        };
221        self.servers.push((url.into(), description));
222        self
223    }
224
225    /// Register an HTTP bearer security scheme under the given name,
226    /// advertising the bearer format as `JWT`.
227    ///
228    /// The name is what handlers reference in their `security(...)`
229    /// blocks (e.g., `security(("bearer" = []))`). Use
230    /// [`bearer_security_with_format`](Self::bearer_security_with_format)
231    /// to override the format (for example for opaque,
232    /// introspection-based tokens).
233    pub fn bearer_security(self, name: impl Into<String>) -> Self {
234        self.bearer_security_with_format(name, "JWT")
235    }
236
237    /// Register an HTTP bearer security scheme under the given name with
238    /// an explicit `bearerFormat` value.
239    ///
240    /// Use this when the bearer token format is not `JWT` — for example
241    /// RFC 7662 opaque tokens validated by introspection, SAML bearer
242    /// assertions, or a proprietary token format that clients should not
243    /// attempt to decode locally.
244    pub fn bearer_security_with_format(
245        mut self,
246        name: impl Into<String>,
247        bearer_format: impl Into<String>,
248    ) -> Self {
249        let scheme = SecurityScheme::Http(
250            HttpBuilder::new()
251                .scheme(HttpAuthScheme::Bearer)
252                .bearer_format(bearer_format)
253                .build(),
254        );
255        self.security_schemes.push((name.into(), scheme));
256        self
257    }
258
259    /// Register an arbitrary security scheme under the given name. Use
260    /// this for OAuth flows, API keys, OpenID Connect, etc.
261    pub fn security_scheme(mut self, name: impl Into<String>, scheme: SecurityScheme) -> Self {
262        self.security_schemes.push((name.into(), scheme));
263        self
264    }
265
266    /// Register an OAuth2 security scheme under the given name with
267    /// the supplied [`Flow`] entries.
268    ///
269    /// Prefer this over [`Self::bearer_security`] when handlers
270    /// declare per-operation scope requirements (e.g. via
271    /// [`crate::DocOperationSecurity`] impls). The bearer-only HTTP
272    /// scheme has no scope vocabulary, so OpenAPI client codegen
273    /// (`openapi-generator`, `oapi-codegen`, etc.) silently drops any
274    /// scopes attached to operation security entries. With an OAuth2
275    /// scheme that publishes the scope vocabulary in
276    /// `flows.<flow>.scopes`, generated clients carry the required
277    /// scopes through to the token request.
278    pub fn oauth2_security(
279        mut self,
280        name: impl Into<String>,
281        flows: impl IntoIterator<Item = Flow>,
282    ) -> Self {
283        self.security_schemes
284            .push((name.into(), SecurityScheme::OAuth2(OAuth2::new(flows))));
285        self
286    }
287
288    /// Register a tag with a display name and description. Tags are
289    /// auto-discovered from operations at build time, so this is only
290    /// needed when you want to **override the description** shown in
291    /// the docs UI for a specific tag.
292    ///
293    /// When provided, the tag's position in the `.tag()` call order
294    /// determines its display order in the sidebar. Auto-discovered
295    /// tags without an explicit `.tag()` entry appear after any
296    /// explicitly registered ones, sorted alphabetically.
297    pub fn tag(mut self, name: impl Into<String>, description: impl Into<String>) -> Self {
298        self.tags.push((name.into(), description.into()));
299        self
300    }
301
302    /// Manually group tags into a named section via the `x-tagGroups`
303    /// vendor extension (Scalar + Redoc).
304    ///
305    /// **When any `.tag_group()` call is present, auto-grouping is
306    /// disabled** — the caller takes full control of the grouping.
307    /// Tags not assigned to any group may be hidden by some renderers
308    /// (notably Redoc).
309    ///
310    /// When no `.tag_group()` calls are present, the builder
311    /// auto-generates groups by splitting tag names on the delimiter
312    /// (default `": "`). For example, `"Admin: Models"` is placed in
313    /// the `"Admin"` group. Tags without the delimiter go into the
314    /// default group (see [`Self::default_tag_group`]).
315    pub fn tag_group(
316        mut self,
317        name: impl Into<String>,
318        tags: impl IntoIterator<Item = impl Into<String>>,
319    ) -> Self {
320        self.tag_groups
321            .push((name.into(), tags.into_iter().map(Into::into).collect()));
322        self
323    }
324
325    /// Set the name of the default tag group for tags that don't
326    /// contain the group delimiter. Defaults to `"API"` when
327    /// auto-grouping is active.
328    ///
329    /// Only meaningful when no explicit `.tag_group()` calls are
330    /// present (i.e., auto-grouping is active).
331    pub fn default_tag_group(mut self, name: impl Into<String>) -> Self {
332        self.default_tag_group = Some(name.into());
333        self
334    }
335
336    /// Set the delimiter used to split tag names into
337    /// `(group, display_name)` for auto-generated tag groups.
338    /// Defaults to `": "`.
339    ///
340    /// For example, with delimiter `": "`, the tag `"Admin: Models"`
341    /// is placed in the `"Admin"` group. With delimiter `"/"`, the
342    /// tag `"Admin/Models"` would be placed in the `"Admin"` group.
343    pub fn tag_group_delimiter(mut self, delimiter: impl Into<String>) -> Self {
344        self.tag_group_delimiter = Some(delimiter.into());
345        self
346    }
347
348    /// Manually assign a tag to a schema. The tag is injected as an
349    /// `x-tags` vendor extension on the schema in
350    /// `components.schemas`. Manual tags merge with auto-inferred
351    /// tags (schemas automatically inherit tags from the operations
352    /// that reference them).
353    pub fn schema_tag(mut self, schema: impl Into<String>, tag: impl Into<String>) -> Self {
354        self.schema_tags.push((schema.into(), tag.into()));
355        self
356    }
357
358    /// Choose the OpenAPI spec version used to render `text/event-stream`
359    /// responses produced by handlers returning [`crate::SseStream`].
360    ///
361    /// Defaults to [`SseSpecVersion::V3_2`] — the rendered document
362    /// will carry `openapi: "3.2.0"` and SSE responses will place the
363    /// event schema under `itemSchema`, matching the OpenAPI 3.2
364    /// first-class SSE support. Call with [`SseSpecVersion::V3_1`] to
365    /// downgrade for consumers that still require the 3.1 shape
366    /// (Redoc, openapi-generator, swagger-parser < 3.2).
367    ///
368    /// # Example
369    ///
370    /// ```
371    /// use doxa::{ApiDocBuilder, SseSpecVersion};
372    ///
373    /// // Render against OpenAPI 3.1 for consumers that don't yet
374    /// // understand the 3.2 `itemSchema` keyword.
375    /// let doc = ApiDocBuilder::new()
376    ///     .title("Compat")
377    ///     .version("1.0.0")
378    ///     .sse_openapi_version(SseSpecVersion::V3_1)
379    ///     .build();
380    /// # let _ = doc;
381    /// ```
382    pub fn sse_openapi_version(mut self, version: SseSpecVersion) -> Self {
383        self.sse_spec_version = version;
384        self
385    }
386
387    /// Merge an existing [`OpenApi`] (typically from
388    /// [`utoipa_axum::router::OpenApiRouter::split_for_parts`]) into the
389    /// document. The merged document inherits paths, components, and
390    /// tags from the supplied value.
391    pub fn merge(mut self, openapi: OpenApi) -> Self {
392        match self.base.as_mut() {
393            Some(base) => base.merge(openapi),
394            None => self.base = Some(openapi),
395        }
396        self
397    }
398
399    /// Finalize the builder, producing an [`ApiDoc`] with its JSON
400    /// representation pre-serialized into an [`Arc<str>`]. Returns an
401    /// error only if JSON serialization itself fails — which in practice
402    /// requires a malformed custom schema to be present in the merged
403    /// document.
404    pub fn try_build(self) -> Result<ApiDoc, BuildError> {
405        let mut doc = self.base.unwrap_or_else(|| OpenApiBuilder::new().build());
406
407        // Apply info fields. We always rebuild `info` to ensure the
408        // builder-supplied values win over whatever the merged base
409        // brought along, since `merge` doesn't touch `info`.
410        let mut info = InfoBuilder::new()
411            .title(self.title.unwrap_or_else(|| doc.info.title.clone()))
412            .version(self.version.unwrap_or_else(|| doc.info.version.clone()));
413        if let Some(description) = self.description.or(doc.info.description.clone()) {
414            info = info.description(Some(description));
415        }
416        if self.contact_name.is_some() || self.contact_email.is_some() || self.contact_url.is_some()
417        {
418            let mut contact = ContactBuilder::new();
419            if let Some(name) = self.contact_name {
420                contact = contact.name(Some(name));
421            }
422            if let Some(email) = self.contact_email {
423                contact = contact.email(Some(email));
424            }
425            if let Some(url) = self.contact_url {
426                contact = contact.url(Some(url));
427            }
428            info = info.contact(Some(contact.build()));
429        }
430        if let Some(name) = self.license_name {
431            let mut license = License::new(name);
432            if let Some(url) = self.license_url {
433                license.url = Some(url);
434            }
435            info = info.license(Some(license));
436        }
437        doc.info = info.build();
438
439        // Servers — replace whatever the merged document had if the
440        // builder supplied any.
441        if !self.servers.is_empty() {
442            let servers = self
443                .servers
444                .into_iter()
445                .map(|(url, description)| {
446                    let mut server = ServerBuilder::new().url(url);
447                    if let Some(description) = description {
448                        server = server.description(Some(description));
449                    }
450                    server.build()
451                })
452                .collect::<Vec<_>>();
453            doc.servers = Some(servers);
454        }
455
456        // Security schemes are stored under components. Initialize the
457        // components container if it's missing.
458        if !self.security_schemes.is_empty() {
459            let components = doc
460                .components
461                .get_or_insert_with(utoipa::openapi::Components::new);
462            for (name, scheme) in self.security_schemes {
463                components.security_schemes.insert(name, scheme);
464            }
465        }
466
467        // --- Tag discovery and grouping ---
468        //
469        // 1. Walk all operations to discover every tag in use.
470        // 2. Build the top-level `tags` array: explicit `.tag()` entries first
471        //    (preserving order), then auto-discovered tags sorted alphabetically.
472        // 3. Build `x-tagGroups`: if the caller supplied explicit `.tag_group()`
473        //    entries, use those verbatim. Otherwise, auto-generate groups by splitting
474        //    tag names on the delimiter (default `": "`).
475        {
476            use utoipa::openapi::tag::TagBuilder;
477
478            // Discover all tags from operations.
479            let mut discovered: BTreeSet<String> = BTreeSet::new();
480            for path_item in doc.paths.paths.values() {
481                for op in crate::contribution::path_item_operations(path_item) {
482                    if let Some(ref tags) = op.tags {
483                        discovered.extend(tags.iter().cloned());
484                    }
485                }
486            }
487
488            // Build the explicit description map for lookups.
489            let explicit_descs: HashMap<String, String> = self.tags.iter().cloned().collect();
490            let explicit_order: Vec<String> = self.tags.iter().map(|(n, _)| n.clone()).collect();
491
492            // Ordered tag list: explicit first (in call order), then
493            // remaining discovered tags alphabetically.
494            let mut ordered_tags: Vec<String> =
495                Vec::with_capacity(explicit_order.len() + discovered.len());
496            let mut seen: HashSet<&str> = HashSet::with_capacity(ordered_tags.capacity());
497            for name in explicit_order.iter().chain(discovered.iter()) {
498                if seen.insert(name.as_str()) {
499                    ordered_tags.push(name.clone());
500                }
501            }
502
503            // Emit top-level tags array.
504            if !ordered_tags.is_empty() {
505                doc.tags = Some(
506                    ordered_tags
507                        .iter()
508                        .map(|name| {
509                            let mut b = TagBuilder::new().name(name);
510                            if let Some(desc) = explicit_descs.get(name) {
511                                b = b.description(Some(desc.clone()));
512                            }
513                            b.build()
514                        })
515                        .collect(),
516                );
517            }
518
519            // Emit x-tagGroups.
520            let groups_json: Vec<serde_json::Value> = if !self.tag_groups.is_empty() {
521                // Explicit groups — use verbatim.
522                self.tag_groups
523                    .into_iter()
524                    .map(|(name, tags)| serde_json::json!({ "name": name, "tags": tags }))
525                    .collect()
526            } else if !ordered_tags.is_empty() {
527                // Auto-generate groups from tag naming convention.
528                let delimiter = self.tag_group_delimiter.as_deref().unwrap_or(": ");
529                let default_group = self.default_tag_group.as_deref().unwrap_or("API");
530                auto_tag_groups(&ordered_tags, delimiter, default_group)
531            } else {
532                Vec::new()
533            };
534
535            if !groups_json.is_empty() {
536                use utoipa::openapi::extensions::ExtensionsBuilder;
537                let ext = ExtensionsBuilder::new()
538                    .add("x-tagGroups", serde_json::Value::Array(groups_json))
539                    .build();
540                match doc.extensions.as_mut() {
541                    Some(existing) => existing.merge(ext),
542                    None => doc.extensions = Some(ext),
543                }
544            }
545        }
546
547        // The `#[derive(ApiError)]`-generated `IntoResponses` impl
548        // emits responses that reference `#/components/schemas/ApiErrorBody`
549        // — register the actual schema here so the `$ref`s resolve.
550        // Same for `ProblemDetails` because consumers may opt into it
551        // for individual responses.
552        //
553        // `serde_json::Value` is the default `E` type parameter for
554        // `ApiErrorBody<E>` on layer contributions and hand-rolled
555        // error responses that don't carry a concrete error enum.
556        // utoipa gives it the name `"Value"` but its
557        // `ToSchema::schemas` impl is an empty default — register the
558        // schema manually so `$ref: #/components/schemas/Value`
559        // resolves.
560        {
561            use utoipa::PartialSchema;
562            let components = doc
563                .components
564                .get_or_insert_with(utoipa::openapi::Components::new);
565            components
566                .schemas
567                .entry("ApiErrorBody".to_string())
568                .or_insert_with(<crate::ApiErrorBody as utoipa::PartialSchema>::schema);
569            components
570                .schemas
571                .entry("ProblemDetails".to_string())
572                .or_insert_with(crate::ProblemDetails::schema);
573            components
574                .schemas
575                .entry("Value".to_string())
576                .or_insert_with(<serde_json::Value as utoipa::PartialSchema>::schema);
577        }
578
579        // Auto-infer x-tags on schemas: walk operations, follow $ref
580        // links in request bodies and responses, and assign each
581        // referenced schema the tags of the operations that use it.
582        // Manual `.schema_tag()` overrides merge with the inferred set.
583        {
584            use utoipa::openapi::RefOr;
585
586            let mut schema_tag_map: HashMap<String, BTreeSet<String>> = HashMap::new();
587
588            // Collect manual schema tags first.
589            for (schema, tag) in self.schema_tags {
590                schema_tag_map.entry(schema).or_default().insert(tag);
591            }
592
593            // Walk operations and collect $ref → tags.
594            for path_item in doc.paths.paths.values() {
595                for op in crate::contribution::path_item_operations(path_item) {
596                    let op_tags = match &op.tags {
597                        Some(t) if !t.is_empty() => t,
598                        _ => continue,
599                    };
600
601                    // Collect refs from request body.
602                    if let Some(ref body) = op.request_body {
603                        collect_content_refs(body.content.values(), op_tags, &mut schema_tag_map);
604                    }
605
606                    // Collect refs from responses.
607                    for resp in op.responses.responses.values() {
608                        if let RefOr::T(ref response) = resp {
609                            collect_content_refs(
610                                response.content.values(),
611                                op_tags,
612                                &mut schema_tag_map,
613                            );
614                        }
615                    }
616                }
617            }
618
619            // Inject x-tags into each schema.
620            if let Some(ref mut components) = doc.components {
621                for (schema_name, tags) in &schema_tag_map {
622                    if let Some(RefOr::T(ref mut schema)) = components.schemas.get_mut(schema_name)
623                    {
624                        if let Some(slot) = schema_extensions_mut(schema) {
625                            let tags_json: Vec<serde_json::Value> = tags
626                                .iter()
627                                .map(|t| serde_json::Value::String(t.clone()))
628                                .collect();
629                            let ext = utoipa::openapi::extensions::ExtensionsBuilder::new()
630                                .add("x-tags", serde_json::Value::Array(tags_json))
631                                .build();
632                            match slot.as_mut() {
633                                Some(existing) => existing.merge(ext),
634                                None => *slot = Some(ext),
635                            }
636                        }
637                    }
638                }
639            }
640        }
641
642        // Serialize to a `Value` first so the SSE post-process can
643        // inject OpenAPI 3.2 `itemSchema` entries without having to
644        // model that field on utoipa's typed `OpenApi` (utoipa targets
645        // 3.1 and has no `itemSchema` support as of 5.4). The rewrite
646        // is isolated to a single function and operates on the same
647        // representation we're about to freeze into `Bytes`, so the
648        // cost is one intermediate `Value` tree — paid once at
649        // startup, amortized over every doc request.
650        let mut value = serde_json::to_value(&doc).map_err(BuildError::Serialize)?;
651        apply_sse_spec_version(&mut value, self.sse_spec_version);
652        let spec_json = serde_json::to_vec(&value).map_err(BuildError::Serialize)?;
653        Ok(ApiDoc {
654            openapi: Arc::new(doc),
655            spec_json: Bytes::from(spec_json),
656        })
657    }
658
659    /// Convenience wrapper around [`Self::try_build`] that panics on
660    /// serialization failure. Suitable for startup code where a failed
661    /// build is unrecoverable.
662    pub fn build(self) -> ApiDoc {
663        self.try_build().expect("OpenAPI document serialization")
664    }
665}
666
667/// Post-process the serialized OpenAPI document's `Value` tree to
668/// apply the selected [`SseSpecVersion`].
669///
670/// Walks every `paths.*.*.responses.*.content["text/event-stream"]`
671/// entry. Entries marked with `x-sse-stream: true` are the ones
672/// emitted by the method-shortcut macros for handlers returning
673/// [`crate::SseStream`] — those are the only entries this function
674/// rewrites. The marker is stripped in both version modes so it
675/// never leaks to downstream consumers.
676///
677/// When targeting [`SseSpecVersion::V3_2`], the function also moves
678/// the `schema` value under the key `itemSchema` and sets the
679/// document root `openapi` field to `"3.2.0"`. The event-enum
680/// schema itself is unchanged between the two modes — only its
681/// location in the content entry differs — because utoipa already
682/// emits a valid `oneOf`-of-tagged-variants shape under either
683/// version.
684///
685/// TODO: when utoipa ships native OpenAPI 3.2 / `itemSchema`
686/// emission, collapse this post-process into a thin shim (or remove
687/// it entirely if utoipa exposes the selection at build time).
688fn apply_sse_spec_version(value: &mut serde_json::Value, version: SseSpecVersion) {
689    use serde_json::Value;
690
691    let Some(obj) = value.as_object_mut() else {
692        return;
693    };
694
695    let mut any_sse = false;
696    if let Some(Value::Object(paths)) = obj.get_mut("paths") {
697        for path_item in paths.values_mut() {
698            let Some(path_obj) = path_item.as_object_mut() else {
699                continue;
700            };
701            for op in path_obj.values_mut() {
702                let Some(op_obj) = op.as_object_mut() else {
703                    continue;
704                };
705                let Some(Value::Object(responses)) = op_obj.get_mut("responses") else {
706                    continue;
707                };
708                for resp in responses.values_mut() {
709                    let Some(resp_obj) = resp.as_object_mut() else {
710                        continue;
711                    };
712                    let Some(Value::Object(content)) = resp_obj.get_mut("content") else {
713                        continue;
714                    };
715                    let Some(Value::Object(sse_entry)) = content.get_mut("text/event-stream")
716                    else {
717                        continue;
718                    };
719
720                    // Only rewrite entries our macros marked — avoids
721                    // mangling text/event-stream responses users
722                    // declared by hand without going through
723                    // `SseStream<E, …>`.
724                    if !matches!(sse_entry.remove("x-sse-stream"), Some(Value::Bool(true))) {
725                        continue;
726                    }
727                    any_sse = true;
728
729                    if matches!(version, SseSpecVersion::V3_2) {
730                        if let Some(schema) = sse_entry.remove("schema") {
731                            sse_entry.insert("itemSchema".to_string(), schema);
732                        }
733                    }
734                }
735            }
736        }
737    }
738
739    if any_sse && matches!(version, SseSpecVersion::V3_2) {
740        obj.insert("openapi".to_string(), Value::String("3.2.0".to_string()));
741    }
742}
743
744/// Auto-generate `x-tagGroups` JSON from a list of tag names by
745/// splitting each tag on `delimiter`. Tags whose name contains the
746/// delimiter are placed in a group named after the prefix; tags
747/// without the delimiter go into `default_group`. Group order
748/// follows first-seen order of prefixes; within each group, tags
749/// appear in input order.
750fn auto_tag_groups(
751    tags: &[String],
752    delimiter: &str,
753    default_group: &str,
754) -> Vec<serde_json::Value> {
755    // LinkedHashMap-like: preserve insertion order of group names.
756    let mut group_order: Vec<String> = Vec::new();
757    let mut group_map: HashMap<String, Vec<String>> = HashMap::new();
758
759    for tag in tags {
760        let group_name = match tag.find(delimiter) {
761            Some(idx) => &tag[..idx],
762            None => default_group,
763        };
764        let entry = group_map.entry(group_name.to_string()).or_insert_with(|| {
765            group_order.push(group_name.to_string());
766            Vec::new()
767        });
768        entry.push(tag.clone());
769    }
770
771    group_order
772        .into_iter()
773        .map(|name| {
774            let tags = group_map.remove(&name).unwrap_or_default();
775            serde_json::json!({ "name": name, "tags": tags })
776        })
777        .collect()
778}
779
780/// Extract schema names from `$ref` entries in a content map and
781/// record the given `op_tags` against each one. Only direct `$ref`
782/// links to `#/components/schemas/<Name>` are followed — deeply
783/// nested refs are not chased to keep the pass simple and
784/// predictable.
785fn collect_content_refs<V>(
786    content: impl IntoIterator<Item = V>,
787    op_tags: &[String],
788    out: &mut HashMap<String, BTreeSet<String>>,
789) where
790    V: std::borrow::Borrow<utoipa::openapi::content::Content>,
791{
792    use utoipa::openapi::RefOr;
793
794    for c in content {
795        let c = c.borrow();
796        let schema = match &c.schema {
797            Some(s) => s,
798            None => continue,
799        };
800        if let RefOr::Ref(r) = schema {
801            if let Some(name) = r.ref_location.strip_prefix("#/components/schemas/") {
802                let entry = out.entry(name.to_string()).or_default();
803                entry.extend(op_tags.iter().cloned());
804            }
805        }
806    }
807}
808
809/// Get a mutable reference to the extensions on any
810/// [`utoipa::openapi::schema::Schema`] variant. All variants carry
811/// `extensions`. Get a mutable reference to the extensions on any known
812/// [`utoipa::openapi::schema::Schema`] variant. Returns [`None`]
813/// for future variants added to the non-exhaustive enum.
814fn schema_extensions_mut(
815    schema: &mut utoipa::openapi::schema::Schema,
816) -> Option<&mut Option<utoipa::openapi::extensions::Extensions>> {
817    use utoipa::openapi::schema::Schema;
818    match schema {
819        Schema::Object(o) => Some(&mut o.extensions),
820        Schema::Array(a) => Some(&mut a.extensions),
821        Schema::OneOf(o) => Some(&mut o.extensions),
822        Schema::AllOf(a) => Some(&mut a.extensions),
823        Schema::AnyOf(a) => Some(&mut a.extensions),
824        _ => None,
825    }
826}
827
828#[cfg(test)]
829mod tests {
830    use super::*;
831
832    #[test]
833    fn build_minimal_document() {
834        let doc = ApiDocBuilder::new().title("test").version("1.2.3").build();
835        assert_eq!(doc.openapi.info.title, "test");
836        assert_eq!(doc.openapi.info.version, "1.2.3");
837        // Spec JSON is non-empty and parses as valid JSON.
838        assert!(!doc.spec_json.is_empty());
839        let _: serde_json::Value = serde_json::from_slice(&doc.spec_json).unwrap();
840    }
841
842    #[test]
843    fn description_appears_in_serialized_spec() {
844        let doc = ApiDocBuilder::new()
845            .title("test")
846            .version("0.1")
847            .description("hello world")
848            .build();
849        let parsed: serde_json::Value = serde_json::from_slice(&doc.spec_json).unwrap();
850        assert_eq!(parsed["info"]["description"], "hello world");
851    }
852
853    #[test]
854    fn server_entry_is_recorded() {
855        let doc = ApiDocBuilder::new()
856            .title("test")
857            .version("0.1")
858            .server("/api", "primary")
859            .build();
860        let servers = doc.openapi.servers.as_ref().unwrap();
861        assert_eq!(servers.len(), 1);
862        assert_eq!(servers[0].url, "/api");
863        assert_eq!(servers[0].description.as_deref(), Some("primary"));
864    }
865
866    #[test]
867    fn bearer_security_scheme_is_registered() {
868        let doc = ApiDocBuilder::new()
869            .title("test")
870            .version("0.1")
871            .bearer_security("bearer")
872            .build();
873        let parsed: serde_json::Value = serde_json::from_slice(&doc.spec_json).unwrap();
874        let schemes = &parsed["components"]["securitySchemes"]["bearer"];
875        assert_eq!(schemes["type"], "http");
876        assert_eq!(schemes["scheme"], "bearer");
877    }
878
879    #[test]
880    fn bearer_security_defaults_to_jwt_format() {
881        let doc = ApiDocBuilder::new()
882            .title("test")
883            .version("0.1")
884            .bearer_security("bearer")
885            .build();
886        let parsed: serde_json::Value = serde_json::from_slice(&doc.spec_json).unwrap();
887        let schemes = &parsed["components"]["securitySchemes"]["bearer"];
888        assert_eq!(schemes["bearerFormat"], "JWT");
889    }
890
891    #[test]
892    fn bearer_security_with_format_overrides_bearer_format() {
893        let doc = ApiDocBuilder::new()
894            .title("test")
895            .version("0.1")
896            .bearer_security_with_format("jwt", "opaque")
897            .build();
898        let parsed: serde_json::Value = serde_json::from_slice(&doc.spec_json).unwrap();
899        let schemes = &parsed["components"]["securitySchemes"]["jwt"];
900        assert_eq!(schemes["type"], "http");
901        assert_eq!(schemes["scheme"], "bearer");
902        assert_eq!(schemes["bearerFormat"], "opaque");
903    }
904
905    #[test]
906    fn merge_preserves_paths_from_base() {
907        // Build a small OpenApi with one path manually and merge it.
908        use utoipa::openapi::{
909            path::{HttpMethod, OperationBuilder},
910            PathItem, PathsBuilder,
911        };
912        let path_item = PathItem::new(HttpMethod::Get, OperationBuilder::new().build());
913        let paths = PathsBuilder::new().path("/example", path_item).build();
914        let base = OpenApiBuilder::new().paths(paths).build();
915        let doc = ApiDocBuilder::new()
916            .title("test")
917            .version("0.1")
918            .merge(base)
919            .build();
920        assert!(doc.openapi.paths.paths.contains_key("/example"));
921    }
922
923    #[test]
924    fn license_appears_in_serialized_spec() {
925        let doc = ApiDocBuilder::new()
926            .title("test")
927            .version("0.1")
928            .license("MIT")
929            .license_url("https://opensource.org/licenses/MIT")
930            .build();
931        let parsed: serde_json::Value = serde_json::from_slice(&doc.spec_json).unwrap();
932        assert_eq!(parsed["info"]["license"]["name"], "MIT");
933        assert_eq!(
934            parsed["info"]["license"]["url"],
935            "https://opensource.org/licenses/MIT"
936        );
937    }
938
939    #[test]
940    fn contact_block_appears_when_any_field_set() {
941        let doc = ApiDocBuilder::new()
942            .title("test")
943            .version("0.1")
944            .contact_name("Ops")
945            .contact_email("ops@example.com")
946            .contact_url("https://example.com/contact")
947            .build();
948        let parsed: serde_json::Value = serde_json::from_slice(&doc.spec_json).unwrap();
949        assert_eq!(parsed["info"]["contact"]["name"], "Ops");
950        assert_eq!(parsed["info"]["contact"]["email"], "ops@example.com");
951        assert_eq!(
952            parsed["info"]["contact"]["url"],
953            "https://example.com/contact"
954        );
955    }
956
957    #[test]
958    fn multiple_servers_are_recorded_in_order() {
959        let doc = ApiDocBuilder::new()
960            .title("test")
961            .version("0.1")
962            .server("/", "primary")
963            .server("https://staging.example.com", "staging")
964            .build();
965        let servers = doc.openapi.servers.as_ref().unwrap();
966        assert_eq!(servers.len(), 2);
967        assert_eq!(servers[0].url, "/");
968        assert_eq!(servers[1].url, "https://staging.example.com");
969        assert_eq!(servers[1].description.as_deref(), Some("staging"));
970    }
971
972    #[test]
973    fn merge_then_override_uses_builder_info_fields() {
974        // Build a base with its own title; the builder's title should
975        // win over the merged base's title.
976        let base = OpenApiBuilder::new()
977            .info(
978                InfoBuilder::new()
979                    .title("from-base")
980                    .version("9.9.9")
981                    .build(),
982            )
983            .build();
984        let doc = ApiDocBuilder::new()
985            .title("from-builder")
986            .version("0.1")
987            .merge(base)
988            .build();
989        assert_eq!(doc.openapi.info.title, "from-builder");
990        assert_eq!(doc.openapi.info.version, "0.1");
991    }
992
993    #[test]
994    fn build_without_title_inherits_from_merged_base() {
995        // When the builder doesn't supply title/version, the merged
996        // base's values are preserved.
997        let base = OpenApiBuilder::new()
998            .info(
999                InfoBuilder::new()
1000                    .title("base-title")
1001                    .version("3.0.0")
1002                    .build(),
1003            )
1004            .build();
1005        let doc = ApiDocBuilder::new().merge(base).build();
1006        assert_eq!(doc.openapi.info.title, "base-title");
1007        assert_eq!(doc.openapi.info.version, "3.0.0");
1008    }
1009
1010    #[test]
1011    fn server_with_empty_description_omits_the_field() {
1012        let doc = ApiDocBuilder::new()
1013            .title("t")
1014            .version("0.1")
1015            .server("/api", "")
1016            .build();
1017        let servers = doc.openapi.servers.as_ref().unwrap();
1018        assert_eq!(servers.len(), 1);
1019        assert!(servers[0].description.is_none());
1020    }
1021
1022    #[test]
1023    fn spec_json_clone_is_shallow() {
1024        let doc = ApiDocBuilder::new().title("t").version("0.1").build();
1025        let cloned = doc.clone();
1026        // Bytes::clone is a refcount bump — both clones must point at
1027        // the same underlying buffer.
1028        assert_eq!(doc.spec_json.as_ptr(), cloned.spec_json.as_ptr());
1029        assert!(Arc::ptr_eq(&doc.openapi, &cloned.openapi));
1030    }
1031
1032    #[test]
1033    fn tag_metadata_appears_in_serialized_spec() {
1034        let doc = ApiDocBuilder::new()
1035            .title("test")
1036            .version("0.1")
1037            .tag("Models", "CRUD operations for data models")
1038            .tag("Compute", "Query execution and charting")
1039            .build();
1040        let parsed: serde_json::Value = serde_json::from_slice(&doc.spec_json).unwrap();
1041        let tags = parsed["tags"].as_array().expect("tags array present");
1042        assert_eq!(tags.len(), 2);
1043        assert_eq!(tags[0]["name"], "Models");
1044        assert_eq!(tags[0]["description"], "CRUD operations for data models");
1045        assert_eq!(tags[1]["name"], "Compute");
1046        assert_eq!(tags[1]["description"], "Query execution and charting");
1047    }
1048
1049    #[test]
1050    fn tag_order_is_preserved() {
1051        let doc = ApiDocBuilder::new()
1052            .title("t")
1053            .version("0.1")
1054            .tag("Z", "last")
1055            .tag("A", "first")
1056            .build();
1057        let tags = doc.openapi.tags.as_ref().unwrap();
1058        assert_eq!(tags[0].name, "Z");
1059        assert_eq!(tags[1].name, "A");
1060    }
1061
1062    #[test]
1063    fn explicit_tag_groups_disable_auto_grouping() {
1064        let doc = ApiDocBuilder::new()
1065            .title("t")
1066            .version("0.1")
1067            .tag_group("Public", ["Models", "Compute"])
1068            .tag_group("Admin", ["Admin: Models"])
1069            .build();
1070        let parsed: serde_json::Value = serde_json::from_slice(&doc.spec_json).unwrap();
1071        let groups = parsed["x-tagGroups"]
1072            .as_array()
1073            .expect("x-tagGroups present");
1074        assert_eq!(groups.len(), 2);
1075        assert_eq!(groups[0]["name"], "Public");
1076        assert_eq!(groups[0]["tags"], serde_json::json!(["Models", "Compute"]));
1077        assert_eq!(groups[1]["name"], "Admin");
1078        assert_eq!(groups[1]["tags"], serde_json::json!(["Admin: Models"]));
1079    }
1080
1081    /// Helper: build an OpenApi with tagged operations.
1082    fn openapi_with_tagged_ops(tag_pairs: &[(&str, &str)]) -> OpenApi {
1083        use utoipa::openapi::path::{HttpMethod, OperationBuilder, PathItem};
1084        use utoipa::openapi::PathsBuilder;
1085
1086        let mut paths = PathsBuilder::new();
1087        for (path, tag) in tag_pairs {
1088            let op = OperationBuilder::new().tag(*tag).build();
1089            paths = paths.path(*path, PathItem::new(HttpMethod::Get, op));
1090        }
1091        OpenApiBuilder::new().paths(paths.build()).build()
1092    }
1093
1094    #[test]
1095    fn auto_discovers_tags_from_operations() {
1096        let base = openapi_with_tagged_ops(&[("/a", "Alpha"), ("/b", "Beta")]);
1097        let doc = ApiDocBuilder::new()
1098            .title("t")
1099            .version("0.1")
1100            .merge(base)
1101            .build();
1102        let tags = doc.openapi.tags.as_ref().expect("tags present");
1103        let names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect();
1104        assert!(names.contains(&"Alpha"));
1105        assert!(names.contains(&"Beta"));
1106    }
1107
1108    #[test]
1109    fn explicit_tags_appear_before_discovered_tags() {
1110        let base = openapi_with_tagged_ops(&[("/a", "Alpha"), ("/b", "Beta")]);
1111        let doc = ApiDocBuilder::new()
1112            .title("t")
1113            .version("0.1")
1114            .tag("Beta", "explicitly first")
1115            .merge(base)
1116            .build();
1117        let tags = doc.openapi.tags.as_ref().expect("tags present");
1118        // Beta was explicitly registered first, so it appears before Alpha.
1119        assert_eq!(tags[0].name, "Beta");
1120        assert_eq!(
1121            tags[0].description.as_deref(),
1122            Some("explicitly first"),
1123            "explicit description wins"
1124        );
1125        assert_eq!(tags[1].name, "Alpha");
1126        assert!(
1127            tags[1].description.is_none(),
1128            "auto-discovered tag has no description"
1129        );
1130    }
1131
1132    #[test]
1133    fn auto_groups_tags_by_colon_delimiter() {
1134        let base = openapi_with_tagged_ops(&[
1135            ("/models", "Models"),
1136            ("/compute", "Compute"),
1137            ("/admin/models", "Admin: Models"),
1138            ("/admin/auth", "Admin: Auth"),
1139        ]);
1140        let doc = ApiDocBuilder::new()
1141            .title("t")
1142            .version("0.1")
1143            .default_tag_group("Public API")
1144            .merge(base)
1145            .build();
1146        let parsed: serde_json::Value = serde_json::from_slice(&doc.spec_json).unwrap();
1147        let groups = parsed["x-tagGroups"]
1148            .as_array()
1149            .expect("x-tagGroups present");
1150
1151        // Two groups: "Admin" (from prefix, first alphabetically) and
1152        // "Public API" (default group for tags without delimiter).
1153        assert_eq!(groups.len(), 2);
1154
1155        // Groups appear in first-seen order of prefixes. Since tags
1156        // are sorted alphabetically, "Admin: Auth" is first →
1157        // "Admin" group appears before "Public API".
1158        let group_names: Vec<&str> = groups.iter().map(|g| g["name"].as_str().unwrap()).collect();
1159        assert!(group_names.contains(&"Admin"));
1160        assert!(group_names.contains(&"Public API"));
1161
1162        let admin_group = groups.iter().find(|g| g["name"] == "Admin").unwrap();
1163        let admin_tags = admin_group["tags"].as_array().unwrap();
1164        assert!(admin_tags.iter().any(|t| t == "Admin: Models"));
1165        assert!(admin_tags.iter().any(|t| t == "Admin: Auth"));
1166
1167        let public_group = groups.iter().find(|g| g["name"] == "Public API").unwrap();
1168        let public_tags = public_group["tags"].as_array().unwrap();
1169        assert!(public_tags.iter().any(|t| t == "Compute"));
1170        assert!(public_tags.iter().any(|t| t == "Models"));
1171    }
1172
1173    #[test]
1174    fn custom_delimiter_splits_tags() {
1175        let base = openapi_with_tagged_ops(&[("/a", "team/models"), ("/b", "team/auth")]);
1176        let doc = ApiDocBuilder::new()
1177            .title("t")
1178            .version("0.1")
1179            .tag_group_delimiter("/")
1180            .merge(base)
1181            .build();
1182        let parsed: serde_json::Value = serde_json::from_slice(&doc.spec_json).unwrap();
1183        let groups = parsed["x-tagGroups"]
1184            .as_array()
1185            .expect("x-tagGroups present");
1186        assert_eq!(groups.len(), 1);
1187        assert_eq!(groups[0]["name"], "team");
1188    }
1189}