Skip to main content

a3s_boot/
openapi.rs

1use crate::openapi_security::{OpenApiSecurityRequirement, OpenApiSecurityScheme};
2use crate::{BootError, HttpMethod, Result, RouteDefinition};
3use serde::Serialize;
4use serde_json::{json, Value};
5use std::collections::{BTreeMap, BTreeSet};
6
7/// Basic OpenAPI document information.
8#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
9pub struct OpenApiInfo {
10    pub title: String,
11    pub version: String,
12    #[serde(skip_serializing_if = "Option::is_none")]
13    pub description: Option<String>,
14    #[serde(skip)]
15    pub servers: Vec<OpenApiServer>,
16    #[serde(skip)]
17    pub external_docs: Option<OpenApiExternalDocs>,
18    #[serde(skip)]
19    pub tags: Vec<OpenApiTag>,
20}
21
22impl OpenApiInfo {
23    pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
24        Self {
25            title: title.into(),
26            version: version.into(),
27            description: None,
28            servers: Vec::new(),
29            external_docs: None,
30            tags: Vec::new(),
31        }
32    }
33
34    pub fn with_description(mut self, description: impl Into<String>) -> Self {
35        self.description = Some(description.into());
36        self
37    }
38
39    pub fn with_server(mut self, url: impl Into<String>) -> Self {
40        self.servers.push(OpenApiServer::new(url));
41        self
42    }
43
44    pub fn with_server_description(
45        mut self,
46        url: impl Into<String>,
47        description: impl Into<String>,
48    ) -> Self {
49        self.servers
50            .push(OpenApiServer::new(url).with_description(description));
51        self
52    }
53
54    pub fn with_external_docs(
55        mut self,
56        description: impl Into<String>,
57        url: impl Into<String>,
58    ) -> Self {
59        self.external_docs = Some(OpenApiExternalDocs::new(description, url));
60        self
61    }
62
63    pub fn with_tag_description(
64        mut self,
65        name: impl Into<String>,
66        description: impl Into<String>,
67    ) -> Self {
68        let name = name.into();
69        let tag = OpenApiTag::new(name.clone()).with_description(description);
70        if let Some(existing) = self.tags.iter_mut().find(|tag| tag.name == name) {
71            *existing = tag;
72        } else {
73            self.tags.push(tag);
74        }
75        self
76    }
77
78    pub fn with_tag_external_docs(
79        mut self,
80        name: impl Into<String>,
81        description: impl Into<String>,
82        url: impl Into<String>,
83    ) -> Self {
84        let name = name.into();
85        let external_docs = OpenApiExternalDocs::new(description, url);
86        if let Some(existing) = self.tags.iter_mut().find(|tag| tag.name == name) {
87            existing.external_docs = Some(external_docs);
88        } else {
89            self.tags
90                .push(OpenApiTag::new(name).with_external_docs_object(external_docs));
91        }
92        self
93    }
94}
95
96/// OpenAPI server entry.
97#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
98pub struct OpenApiServer {
99    pub url: String,
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub description: Option<String>,
102}
103
104impl OpenApiServer {
105    pub fn new(url: impl Into<String>) -> Self {
106        Self {
107            url: url.into(),
108            description: None,
109        }
110    }
111
112    pub fn with_description(mut self, description: impl Into<String>) -> Self {
113        self.description = Some(description.into());
114        self
115    }
116}
117
118/// OpenAPI external documentation link.
119#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
120pub struct OpenApiExternalDocs {
121    pub description: String,
122    pub url: String,
123}
124
125impl OpenApiExternalDocs {
126    pub fn new(description: impl Into<String>, url: impl Into<String>) -> Self {
127        Self {
128            description: description.into(),
129            url: url.into(),
130        }
131    }
132}
133
134/// OpenAPI document generated from resolved Boot routes.
135#[derive(Clone, Debug, PartialEq, Serialize)]
136pub struct OpenApiDocument {
137    pub openapi: String,
138    pub info: OpenApiInfo,
139    pub paths: BTreeMap<String, OpenApiPathItem>,
140    #[serde(skip_serializing_if = "OpenApiComponents::is_empty")]
141    pub components: OpenApiComponents,
142    #[serde(skip_serializing_if = "Vec::is_empty")]
143    pub servers: Vec<OpenApiServer>,
144    #[serde(skip_serializing_if = "Vec::is_empty")]
145    pub tags: Vec<OpenApiTag>,
146    #[serde(rename = "externalDocs", skip_serializing_if = "Option::is_none")]
147    pub external_docs: Option<OpenApiExternalDocs>,
148}
149
150impl OpenApiDocument {
151    pub fn from_routes(info: OpenApiInfo, routes: &[RouteDefinition]) -> Self {
152        let mut tags = info
153            .tags
154            .iter()
155            .map(|tag| (tag.name.clone(), tag.clone()))
156            .collect::<BTreeMap<_, _>>();
157        let servers = info.servers.clone();
158        let external_docs = info.external_docs.clone();
159        let mut paths = BTreeMap::<String, OpenApiPathItem>::new();
160        let mut components = OpenApiComponents::default();
161
162        for route in routes {
163            if route.openapi().hidden {
164                continue;
165            }
166
167            let operation = OpenApiOperation::from_route(route);
168            for tag in &operation.tags {
169                tags.entry(tag.clone())
170                    .or_insert_with(|| OpenApiTag::new(tag.clone()));
171            }
172            components.merge_schemas(route.openapi().schema_components.clone());
173            components.merge_responses(route.openapi().response_components.clone());
174            components.merge_parameters(route.openapi().parameter_components.clone());
175            components.merge_examples(route.openapi().example_components.clone());
176            components.merge_request_bodies(route.openapi().request_body_components.clone());
177            components.merge_headers(route.openapi().header_components.clone());
178            components.merge_security_schemes(route.openapi().security_schemes.clone());
179
180            let path = paths.entry(openapi_route_path(route.path())).or_default();
181            for method in openapi_methods(route.method()) {
182                if route.method().is_wildcard() {
183                    path.operations
184                        .entry(method.to_string())
185                        .or_insert_with(|| operation.clone());
186                } else {
187                    path.operations
188                        .insert(method.to_string(), operation.clone());
189                }
190            }
191        }
192
193        Self {
194            openapi: "3.0.3".to_string(),
195            info,
196            paths,
197            components,
198            servers,
199            tags: tags.into_values().collect(),
200            external_docs,
201        }
202    }
203}
204
205fn openapi_route_path(path: &str) -> String {
206    let path = path.strip_prefix('/').unwrap_or(path);
207    if path.is_empty() {
208        return "/".to_string();
209    }
210
211    let segments = path
212        .split('/')
213        .map(|segment| {
214            segment
215                .strip_prefix("{*")
216                .and_then(|name| name.strip_suffix('}'))
217                .map(|name| format!("{{{name}}}"))
218                .unwrap_or_else(|| segment.to_string())
219        })
220        .collect::<Vec<_>>();
221
222    format!("/{}", segments.join("/"))
223}
224
225fn escape_openapi_component_name(name: &str) -> String {
226    name.replace('~', "~0").replace('/', "~1")
227}
228
229/// OpenAPI components generated or registered by routes.
230#[derive(Clone, Debug, Default, PartialEq, Serialize)]
231pub struct OpenApiComponents {
232    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
233    pub schemas: BTreeMap<String, OpenApiSchema>,
234    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
235    pub responses: BTreeMap<String, OpenApiResponse>,
236    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
237    pub parameters: BTreeMap<String, OpenApiParameter>,
238    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
239    pub examples: BTreeMap<String, OpenApiExample>,
240    #[serde(rename = "requestBodies", skip_serializing_if = "BTreeMap::is_empty")]
241    pub request_bodies: BTreeMap<String, OpenApiRequestBody>,
242    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
243    pub headers: BTreeMap<String, OpenApiHeader>,
244    #[serde(rename = "securitySchemes", skip_serializing_if = "BTreeMap::is_empty")]
245    pub security_schemes: BTreeMap<String, OpenApiSecurityScheme>,
246}
247
248impl OpenApiComponents {
249    pub fn is_empty(&self) -> bool {
250        self.schemas.is_empty()
251            && self.responses.is_empty()
252            && self.parameters.is_empty()
253            && self.examples.is_empty()
254            && self.request_bodies.is_empty()
255            && self.headers.is_empty()
256            && self.security_schemes.is_empty()
257    }
258
259    pub fn merge_schemas(&mut self, schemas: BTreeMap<String, OpenApiSchema>) {
260        self.schemas.extend(schemas);
261    }
262
263    pub fn merge_responses(&mut self, responses: BTreeMap<String, OpenApiResponse>) {
264        self.responses.extend(responses);
265    }
266
267    pub fn merge_parameters(&mut self, parameters: BTreeMap<String, OpenApiParameter>) {
268        self.parameters.extend(parameters);
269    }
270
271    pub fn merge_examples(&mut self, examples: BTreeMap<String, OpenApiExample>) {
272        self.examples.extend(examples);
273    }
274
275    pub fn merge_request_bodies(&mut self, request_bodies: BTreeMap<String, OpenApiRequestBody>) {
276        self.request_bodies.extend(request_bodies);
277    }
278
279    pub fn merge_headers(&mut self, headers: BTreeMap<String, OpenApiHeader>) {
280        self.headers.extend(headers);
281    }
282
283    pub fn merge_security_schemes(
284        &mut self,
285        security_schemes: BTreeMap<String, OpenApiSecurityScheme>,
286    ) {
287        self.security_schemes.extend(security_schemes);
288    }
289}
290
291/// OpenAPI Reference Object.
292#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
293pub struct OpenApiRef {
294    #[serde(rename = "$ref")]
295    pub reference: String,
296    #[serde(skip)]
297    parameter_location: Option<OpenApiParameterLocation>,
298    #[serde(skip)]
299    parameter_name: Option<String>,
300}
301
302impl OpenApiRef {
303    pub fn new(reference: impl Into<String>) -> Self {
304        Self {
305            reference: reference.into(),
306            parameter_location: None,
307            parameter_name: None,
308        }
309    }
310
311    pub fn component(component: impl Into<String>, name: impl AsRef<str>) -> Self {
312        Self::new(format!(
313            "#/components/{}/{}",
314            component.into(),
315            escape_openapi_component_name(name.as_ref())
316        ))
317    }
318
319    pub fn schema(name: impl AsRef<str>) -> Self {
320        Self::component("schemas", name)
321    }
322
323    pub fn response(name: impl AsRef<str>) -> Self {
324        Self::component("responses", name)
325    }
326
327    pub fn parameter(name: impl AsRef<str>) -> Self {
328        Self::component("parameters", name)
329    }
330
331    pub fn example(name: impl AsRef<str>) -> Self {
332        Self::component("examples", name)
333    }
334
335    pub fn request_body(name: impl AsRef<str>) -> Self {
336        Self::component("requestBodies", name)
337    }
338
339    pub fn header(name: impl AsRef<str>) -> Self {
340        Self::component("headers", name)
341    }
342
343    pub fn security_scheme(name: impl AsRef<str>) -> Self {
344        Self::component("securitySchemes", name)
345    }
346
347    pub fn with_parameter_metadata(
348        mut self,
349        location: OpenApiParameterLocation,
350        name: impl Into<String>,
351    ) -> Self {
352        self.parameter_location = Some(location);
353        self.parameter_name = Some(name.into());
354        self
355    }
356
357    pub(crate) fn parameter_identity(&self) -> Option<(OpenApiParameterLocation, &str)> {
358        Some((self.parameter_location?, self.parameter_name.as_deref()?))
359    }
360}
361
362/// Either an inline OpenAPI object or a Reference Object.
363#[derive(Clone, Debug, PartialEq, Serialize)]
364#[serde(untagged)]
365pub enum OpenApiReferenceOr<T> {
366    Reference(OpenApiRef),
367    Value(T),
368}
369
370impl<T> OpenApiReferenceOr<T> {
371    pub fn reference(reference: OpenApiRef) -> Self {
372        Self::Reference(reference)
373    }
374
375    pub fn value(value: T) -> Self {
376        Self::Value(value)
377    }
378
379    pub fn value_mut(&mut self) -> Option<&mut T> {
380        match self {
381            Self::Reference(_) => None,
382            Self::Value(value) => Some(value),
383        }
384    }
385}
386
387impl<T> From<T> for OpenApiReferenceOr<T> {
388    fn from(value: T) -> Self {
389        Self::value(value)
390    }
391}
392
393impl OpenApiReferenceOr<OpenApiParameter> {
394    pub(crate) fn parameter_identity(&self) -> Option<(OpenApiParameterLocation, &str)> {
395        match self {
396            Self::Reference(reference) => reference.parameter_identity(),
397            Self::Value(parameter) => Some((parameter.location, parameter.name.as_str())),
398        }
399    }
400}
401
402/// OpenAPI path item containing operations keyed by HTTP method.
403#[derive(Clone, Debug, Default, PartialEq, Serialize)]
404pub struct OpenApiPathItem {
405    #[serde(flatten)]
406    pub operations: BTreeMap<String, OpenApiOperation>,
407}
408
409/// OpenAPI operation metadata for one Boot route.
410#[derive(Clone, Debug, PartialEq, Serialize)]
411pub struct OpenApiOperation {
412    #[serde(skip_serializing_if = "Vec::is_empty")]
413    pub tags: Vec<String>,
414    #[serde(skip_serializing_if = "Option::is_none")]
415    pub summary: Option<String>,
416    #[serde(skip_serializing_if = "Option::is_none")]
417    pub description: Option<String>,
418    #[serde(rename = "operationId", skip_serializing_if = "Option::is_none")]
419    pub operation_id: Option<String>,
420    #[serde(skip_serializing_if = "Vec::is_empty")]
421    pub parameters: Vec<OpenApiReferenceOr<OpenApiParameter>>,
422    #[serde(rename = "requestBody", skip_serializing_if = "Option::is_none")]
423    pub request_body: Option<OpenApiReferenceOr<OpenApiRequestBody>>,
424    pub responses: BTreeMap<String, OpenApiReferenceOr<OpenApiResponse>>,
425    #[serde(skip_serializing_if = "Vec::is_empty")]
426    pub servers: Vec<OpenApiServer>,
427    #[serde(rename = "externalDocs", skip_serializing_if = "Option::is_none")]
428    pub external_docs: Option<OpenApiExternalDocs>,
429    #[serde(flatten)]
430    pub extensions: BTreeMap<String, Value>,
431    #[serde(skip_serializing_if = "Vec::is_empty")]
432    pub security: Vec<OpenApiSecurityRequirement>,
433    #[serde(skip_serializing_if = "is_false")]
434    pub deprecated: bool,
435}
436
437impl OpenApiOperation {
438    fn from_route(route: &RouteDefinition) -> Self {
439        let metadata = route.openapi();
440        let mut parameters = metadata.parameters.clone();
441
442        for name in route.path_param_names() {
443            if !parameters.iter().any(|parameter| {
444                parameter
445                    .parameter_identity()
446                    .is_some_and(|(location, parameter_name)| {
447                        location == OpenApiParameterLocation::Path && parameter_name == name
448                    })
449            }) {
450                parameters.push(OpenApiReferenceOr::value(OpenApiParameter::path(
451                    name,
452                    OpenApiSchema::string(),
453                )));
454            }
455        }
456
457        let mut responses = metadata.responses.clone();
458        if responses.is_empty() {
459            responses.insert(
460                "200".to_string(),
461                OpenApiReferenceOr::value(OpenApiResponse::description("Success")),
462            );
463        }
464
465        Self {
466            tags: metadata.tags.clone(),
467            summary: metadata.summary.clone(),
468            description: metadata.description.clone(),
469            operation_id: metadata.operation_id.clone(),
470            parameters,
471            request_body: metadata.request_body.clone(),
472            responses,
473            servers: metadata.servers.clone(),
474            external_docs: metadata.external_docs.clone(),
475            extensions: metadata.extensions.clone(),
476            security: metadata.security.clone(),
477            deprecated: metadata.deprecated,
478        }
479    }
480}
481
482/// Route-level OpenAPI metadata carried by [`RouteDefinition`].
483#[derive(Clone, Debug, Default, PartialEq)]
484pub struct OpenApiRouteMetadata {
485    pub tags: Vec<String>,
486    pub operation_id: Option<String>,
487    pub summary: Option<String>,
488    pub description: Option<String>,
489    pub parameters: Vec<OpenApiReferenceOr<OpenApiParameter>>,
490    pub request_body: Option<OpenApiReferenceOr<OpenApiRequestBody>>,
491    pub responses: BTreeMap<String, OpenApiReferenceOr<OpenApiResponse>>,
492    pub servers: Vec<OpenApiServer>,
493    pub external_docs: Option<OpenApiExternalDocs>,
494    pub extensions: BTreeMap<String, Value>,
495    pub schema_components: BTreeMap<String, OpenApiSchema>,
496    pub response_components: BTreeMap<String, OpenApiResponse>,
497    pub parameter_components: BTreeMap<String, OpenApiParameter>,
498    pub example_components: BTreeMap<String, OpenApiExample>,
499    pub request_body_components: BTreeMap<String, OpenApiRequestBody>,
500    pub header_components: BTreeMap<String, OpenApiHeader>,
501    pub security_schemes: BTreeMap<String, OpenApiSecurityScheme>,
502    pub security: Vec<OpenApiSecurityRequirement>,
503    pub deprecated: bool,
504    pub hidden: bool,
505}
506
507/// OpenAPI tag entry.
508#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
509pub struct OpenApiTag {
510    pub name: String,
511    #[serde(skip_serializing_if = "Option::is_none")]
512    pub description: Option<String>,
513    #[serde(rename = "externalDocs", skip_serializing_if = "Option::is_none")]
514    pub external_docs: Option<OpenApiExternalDocs>,
515}
516
517impl OpenApiTag {
518    pub fn new(name: impl Into<String>) -> Self {
519        Self {
520            name: name.into(),
521            description: None,
522            external_docs: None,
523        }
524    }
525
526    pub fn with_description(mut self, description: impl Into<String>) -> Self {
527        self.description = Some(description.into());
528        self
529    }
530
531    pub fn with_external_docs(
532        mut self,
533        description: impl Into<String>,
534        url: impl Into<String>,
535    ) -> Self {
536        self.external_docs = Some(OpenApiExternalDocs::new(description, url));
537        self
538    }
539
540    fn with_external_docs_object(mut self, external_docs: OpenApiExternalDocs) -> Self {
541        self.external_docs = Some(external_docs);
542        self
543    }
544}
545
546/// OpenAPI parameter location.
547#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
548#[serde(rename_all = "lowercase")]
549pub enum OpenApiParameterLocation {
550    Path,
551    Query,
552    Header,
553    Cookie,
554}
555
556/// OpenAPI parameter metadata.
557#[derive(Clone, Debug, PartialEq, Serialize)]
558pub struct OpenApiParameter {
559    pub name: String,
560    #[serde(rename = "in")]
561    pub location: OpenApiParameterLocation,
562    pub required: bool,
563    pub schema: OpenApiSchema,
564    #[serde(skip_serializing_if = "Option::is_none")]
565    pub description: Option<String>,
566    #[serde(skip_serializing_if = "is_false")]
567    pub deprecated: bool,
568    #[serde(rename = "allowReserved", skip_serializing_if = "is_false")]
569    pub allow_reserved: bool,
570    #[serde(skip_serializing_if = "Option::is_none")]
571    pub style: Option<String>,
572    #[serde(skip_serializing_if = "Option::is_none")]
573    pub explode: Option<bool>,
574    #[serde(skip_serializing_if = "Option::is_none")]
575    pub example: Option<Value>,
576    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
577    pub examples: BTreeMap<String, OpenApiReferenceOr<OpenApiExample>>,
578}
579
580impl OpenApiParameter {
581    pub fn new(
582        location: OpenApiParameterLocation,
583        name: impl Into<String>,
584        required: bool,
585        schema: OpenApiSchema,
586    ) -> Self {
587        Self {
588            name: name.into(),
589            location,
590            required,
591            schema,
592            description: None,
593            deprecated: false,
594            allow_reserved: false,
595            style: None,
596            explode: None,
597            example: None,
598            examples: BTreeMap::new(),
599        }
600    }
601
602    pub fn path(name: impl Into<String>, schema: OpenApiSchema) -> Self {
603        Self::new(OpenApiParameterLocation::Path, name, true, schema)
604    }
605
606    pub fn query(name: impl Into<String>, required: bool, schema: OpenApiSchema) -> Self {
607        Self::new(OpenApiParameterLocation::Query, name, required, schema)
608    }
609
610    pub fn header(name: impl Into<String>, required: bool, schema: OpenApiSchema) -> Self {
611        Self::new(OpenApiParameterLocation::Header, name, required, schema)
612    }
613
614    pub fn cookie(name: impl Into<String>, required: bool, schema: OpenApiSchema) -> Self {
615        Self::new(OpenApiParameterLocation::Cookie, name, required, schema)
616    }
617
618    pub fn with_description(mut self, description: impl Into<String>) -> Self {
619        self.description = Some(description.into());
620        self
621    }
622
623    pub fn with_deprecated(mut self) -> Self {
624        self.deprecated = true;
625        self
626    }
627
628    pub fn with_allow_reserved(mut self) -> Self {
629        self.allow_reserved = true;
630        self
631    }
632
633    pub fn with_style(mut self, style: impl Into<String>) -> Self {
634        self.style = Some(style.into());
635        self
636    }
637
638    pub fn with_explode(mut self, explode: bool) -> Self {
639        self.explode = Some(explode);
640        self
641    }
642
643    pub fn with_example_value(mut self, example: Value) -> Self {
644        self.example = Some(example);
645        self.examples.clear();
646        self
647    }
648
649    pub fn try_with_example<T>(self, example: T) -> Result<Self>
650    where
651        T: Serialize,
652    {
653        Ok(self.with_example_value(serialize_openapi_example(example)?))
654    }
655
656    pub fn with_named_example_value(
657        mut self,
658        name: impl Into<String>,
659        example: OpenApiExample,
660    ) -> Self {
661        self.example = None;
662        self.examples
663            .insert(name.into(), OpenApiReferenceOr::value(example));
664        self
665    }
666
667    pub fn try_with_named_example<T>(self, name: impl Into<String>, example: T) -> Result<Self>
668    where
669        T: Serialize,
670    {
671        Ok(self.with_named_example_value(name, OpenApiExample::try_value(example)?))
672    }
673
674    pub fn with_named_example_ref(
675        mut self,
676        name: impl Into<String>,
677        component_name: impl AsRef<str>,
678    ) -> Self {
679        self.example = None;
680        self.examples.insert(
681            name.into(),
682            OpenApiReferenceOr::reference(OpenApiRef::example(component_name)),
683        );
684        self
685    }
686}
687
688/// OpenAPI request body metadata.
689#[derive(Clone, Debug, PartialEq, Serialize)]
690pub struct OpenApiRequestBody {
691    #[serde(skip_serializing_if = "Option::is_none")]
692    pub description: Option<String>,
693    pub required: bool,
694    pub content: BTreeMap<String, OpenApiMediaType>,
695}
696
697impl OpenApiRequestBody {
698    pub fn content(content_type: impl Into<String>, schema: OpenApiSchema) -> Self {
699        let mut content = BTreeMap::new();
700        content.insert(content_type.into(), OpenApiMediaType::new(schema));
701        Self {
702            description: None,
703            required: true,
704            content,
705        }
706    }
707
708    pub fn json(schema: OpenApiSchema) -> Self {
709        Self::content("application/json", schema)
710    }
711
712    pub fn try_content_example<T>(
713        content_type: impl Into<String>,
714        schema: OpenApiSchema,
715        example: T,
716    ) -> Result<Self>
717    where
718        T: Serialize,
719    {
720        let content_type = content_type.into();
721        Self::content(content_type.clone(), schema).try_with_content_example(content_type, example)
722    }
723
724    pub fn try_json_example<T>(schema: OpenApiSchema, example: T) -> Result<Self>
725    where
726        T: Serialize,
727    {
728        Self::json(schema).try_with_json_example(example)
729    }
730
731    pub fn with_description(mut self, description: impl Into<String>) -> Self {
732        self.description = Some(description.into());
733        self
734    }
735
736    pub fn optional(mut self) -> Self {
737        self.required = false;
738        self
739    }
740
741    pub fn try_with_content_example<T>(
742        mut self,
743        content_type: impl Into<String>,
744        example: T,
745    ) -> Result<Self>
746    where
747        T: Serialize,
748    {
749        let content_type = content_type.into();
750        let example = serialize_openapi_example(example)?;
751        let media = self
752            .content
753            .entry(content_type)
754            .or_insert_with(|| OpenApiMediaType::new(OpenApiSchema::object()));
755        media.example = Some(example);
756        media.examples.clear();
757        Ok(self)
758    }
759
760    pub fn try_with_content_named_example<T>(
761        mut self,
762        content_type: impl Into<String>,
763        name: impl Into<String>,
764        example: T,
765    ) -> Result<Self>
766    where
767        T: Serialize,
768    {
769        let content_type = content_type.into();
770        self.content
771            .entry(content_type)
772            .or_insert_with(|| OpenApiMediaType::new(OpenApiSchema::object()))
773            .try_insert_example(name, example)?;
774        Ok(self)
775    }
776
777    pub fn with_content_named_example_ref(
778        mut self,
779        content_type: impl Into<String>,
780        name: impl Into<String>,
781        component_name: impl AsRef<str>,
782    ) -> Self {
783        self.content
784            .entry(content_type.into())
785            .or_insert_with(|| OpenApiMediaType::new(OpenApiSchema::object()))
786            .insert_example_ref(name, component_name);
787        self
788    }
789
790    pub fn try_with_json_example<T>(self, example: T) -> Result<Self>
791    where
792        T: Serialize,
793    {
794        self.try_with_content_example("application/json", example)
795    }
796
797    pub fn try_with_json_named_example<T>(self, name: impl Into<String>, example: T) -> Result<Self>
798    where
799        T: Serialize,
800    {
801        self.try_with_content_named_example("application/json", name, example)
802    }
803
804    pub fn with_json_named_example_ref(
805        self,
806        name: impl Into<String>,
807        component_name: impl AsRef<str>,
808    ) -> Self {
809        self.with_content_named_example_ref("application/json", name, component_name)
810    }
811}
812
813/// OpenAPI response metadata.
814#[derive(Clone, Debug, PartialEq, Serialize)]
815pub struct OpenApiResponse {
816    pub description: String,
817    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
818    pub headers: BTreeMap<String, OpenApiReferenceOr<OpenApiHeader>>,
819    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
820    pub content: BTreeMap<String, OpenApiMediaType>,
821}
822
823impl OpenApiResponse {
824    pub fn description(description: impl Into<String>) -> Self {
825        Self {
826            description: description.into(),
827            headers: BTreeMap::new(),
828            content: BTreeMap::new(),
829        }
830    }
831
832    pub fn content(
833        description: impl Into<String>,
834        content_type: impl Into<String>,
835        schema: OpenApiSchema,
836    ) -> Self {
837        let mut content = BTreeMap::new();
838        content.insert(content_type.into(), OpenApiMediaType::new(schema));
839        Self {
840            description: description.into(),
841            headers: BTreeMap::new(),
842            content,
843        }
844    }
845
846    pub fn json(description: impl Into<String>, schema: OpenApiSchema) -> Self {
847        Self::content(description, "application/json", schema)
848    }
849
850    pub fn try_content_example<T>(
851        description: impl Into<String>,
852        content_type: impl Into<String>,
853        schema: OpenApiSchema,
854        example: T,
855    ) -> Result<Self>
856    where
857        T: Serialize,
858    {
859        let content_type = content_type.into();
860        Self::content(description, content_type.clone(), schema)
861            .try_with_content_example(content_type, example)
862    }
863
864    pub fn try_json_example<T>(
865        description: impl Into<String>,
866        schema: OpenApiSchema,
867        example: T,
868    ) -> Result<Self>
869    where
870        T: Serialize,
871    {
872        Self::json(description, schema).try_with_json_example(example)
873    }
874
875    pub fn try_with_content_example<T>(
876        mut self,
877        content_type: impl Into<String>,
878        example: T,
879    ) -> Result<Self>
880    where
881        T: Serialize,
882    {
883        let content_type = content_type.into();
884        let example = serialize_openapi_example(example)?;
885        let media = self
886            .content
887            .entry(content_type)
888            .or_insert_with(|| OpenApiMediaType::new(OpenApiSchema::object()));
889        media.example = Some(example);
890        media.examples.clear();
891        Ok(self)
892    }
893
894    pub fn try_with_content_named_example<T>(
895        mut self,
896        content_type: impl Into<String>,
897        name: impl Into<String>,
898        example: T,
899    ) -> Result<Self>
900    where
901        T: Serialize,
902    {
903        let content_type = content_type.into();
904        self.content
905            .entry(content_type)
906            .or_insert_with(|| OpenApiMediaType::new(OpenApiSchema::object()))
907            .try_insert_example(name, example)?;
908        Ok(self)
909    }
910
911    pub fn with_content_named_example_ref(
912        mut self,
913        content_type: impl Into<String>,
914        name: impl Into<String>,
915        component_name: impl AsRef<str>,
916    ) -> Self {
917        self.content
918            .entry(content_type.into())
919            .or_insert_with(|| OpenApiMediaType::new(OpenApiSchema::object()))
920            .insert_example_ref(name, component_name);
921        self
922    }
923
924    pub fn try_with_json_example<T>(self, example: T) -> Result<Self>
925    where
926        T: Serialize,
927    {
928        self.try_with_content_example("application/json", example)
929    }
930
931    pub fn try_with_json_named_example<T>(self, name: impl Into<String>, example: T) -> Result<Self>
932    where
933        T: Serialize,
934    {
935        self.try_with_content_named_example("application/json", name, example)
936    }
937
938    pub fn with_json_named_example_ref(
939        self,
940        name: impl Into<String>,
941        component_name: impl AsRef<str>,
942    ) -> Self {
943        self.with_content_named_example_ref("application/json", name, component_name)
944    }
945
946    pub fn with_header(mut self, name: impl Into<String>, header: OpenApiHeader) -> Self {
947        self.headers
948            .insert(name.into(), OpenApiReferenceOr::value(header));
949        self
950    }
951
952    pub fn with_header_ref(
953        mut self,
954        name: impl Into<String>,
955        component_name: impl AsRef<str>,
956    ) -> Self {
957        self.headers.insert(
958            name.into(),
959            OpenApiReferenceOr::reference(OpenApiRef::header(component_name)),
960        );
961        self
962    }
963}
964
965/// OpenAPI response header metadata.
966#[derive(Clone, Debug, PartialEq, Serialize)]
967pub struct OpenApiHeader {
968    pub schema: OpenApiSchema,
969    #[serde(skip_serializing_if = "Option::is_none")]
970    pub description: Option<String>,
971}
972
973impl OpenApiHeader {
974    pub fn new(schema: OpenApiSchema) -> Self {
975        Self {
976            schema,
977            description: None,
978        }
979    }
980
981    pub fn with_description(mut self, description: impl Into<String>) -> Self {
982        self.description = Some(description.into());
983        self
984    }
985}
986
987/// OpenAPI media type metadata.
988#[derive(Clone, Debug, PartialEq, Serialize)]
989pub struct OpenApiMediaType {
990    pub schema: OpenApiSchema,
991    #[serde(skip_serializing_if = "Option::is_none")]
992    pub example: Option<Value>,
993    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
994    pub examples: BTreeMap<String, OpenApiReferenceOr<OpenApiExample>>,
995}
996
997impl OpenApiMediaType {
998    pub fn new(schema: OpenApiSchema) -> Self {
999        Self {
1000            schema,
1001            example: None,
1002            examples: BTreeMap::new(),
1003        }
1004    }
1005
1006    pub fn with_example_value(mut self, example: Value) -> Self {
1007        self.example = Some(example);
1008        self.examples.clear();
1009        self
1010    }
1011
1012    pub fn try_with_example<T>(self, example: T) -> Result<Self>
1013    where
1014        T: Serialize,
1015    {
1016        Ok(self.with_example_value(serialize_openapi_example(example)?))
1017    }
1018
1019    pub fn with_named_example_value(
1020        mut self,
1021        name: impl Into<String>,
1022        example: OpenApiExample,
1023    ) -> Self {
1024        self.example = None;
1025        self.examples
1026            .insert(name.into(), OpenApiReferenceOr::value(example));
1027        self
1028    }
1029
1030    pub fn with_named_example_ref(
1031        mut self,
1032        name: impl Into<String>,
1033        component_name: impl AsRef<str>,
1034    ) -> Self {
1035        self.example = None;
1036        self.examples.insert(
1037            name.into(),
1038            OpenApiReferenceOr::reference(OpenApiRef::example(component_name)),
1039        );
1040        self
1041    }
1042
1043    pub fn try_with_named_example<T>(self, name: impl Into<String>, example: T) -> Result<Self>
1044    where
1045        T: Serialize,
1046    {
1047        Ok(self.with_named_example_value(name, OpenApiExample::try_value(example)?))
1048    }
1049
1050    fn try_insert_example<T>(&mut self, name: impl Into<String>, example: T) -> Result<()>
1051    where
1052        T: Serialize,
1053    {
1054        self.example = None;
1055        self.examples.insert(
1056            name.into(),
1057            OpenApiReferenceOr::value(OpenApiExample::try_value(example)?),
1058        );
1059        Ok(())
1060    }
1061
1062    fn insert_example_ref(&mut self, name: impl Into<String>, component_name: impl AsRef<str>) {
1063        self.example = None;
1064        self.examples.insert(
1065            name.into(),
1066            OpenApiReferenceOr::reference(OpenApiRef::example(component_name)),
1067        );
1068    }
1069}
1070
1071/// Named OpenAPI media example.
1072#[derive(Clone, Debug, PartialEq, Serialize)]
1073pub struct OpenApiExample {
1074    #[serde(skip_serializing_if = "Option::is_none")]
1075    pub summary: Option<String>,
1076    #[serde(skip_serializing_if = "Option::is_none")]
1077    pub description: Option<String>,
1078    pub value: Value,
1079}
1080
1081impl OpenApiExample {
1082    pub fn value(value: Value) -> Self {
1083        Self {
1084            summary: None,
1085            description: None,
1086            value,
1087        }
1088    }
1089
1090    pub fn try_value<T>(value: T) -> Result<Self>
1091    where
1092        T: Serialize,
1093    {
1094        Ok(Self::value(serialize_openapi_example(value)?))
1095    }
1096
1097    pub fn with_summary(mut self, summary: impl Into<String>) -> Self {
1098        self.summary = Some(summary.into());
1099        self
1100    }
1101
1102    pub fn with_description(mut self, description: impl Into<String>) -> Self {
1103        self.description = Some(description.into());
1104        self
1105    }
1106}
1107
1108fn serialize_openapi_example<T>(example: T) -> Result<Value>
1109where
1110    T: Serialize,
1111{
1112    serde_json::to_value(example).map_err(|error| {
1113        BootError::Internal(format!("OpenAPI example could not be serialized: {error}"))
1114    })
1115}
1116
1117/// OpenAPI schema value. This intentionally stays schema-crate neutral.
1118#[derive(Clone, Debug, PartialEq)]
1119pub struct OpenApiSchema(Value);
1120
1121impl OpenApiSchema {
1122    pub fn from_value(value: Value) -> Self {
1123        Self(value)
1124    }
1125
1126    #[cfg(feature = "openapi-schemas")]
1127    pub fn json_schema<T>() -> std::result::Result<Self, serde_json::Error>
1128    where
1129        T: schemars::JsonSchema,
1130    {
1131        let mut value = serde_json::to_value(schemars::schema_for!(T))?;
1132        if let Value::Object(schema) = &mut value {
1133            schema.remove("$schema");
1134        }
1135        Ok(Self(value))
1136    }
1137
1138    pub fn string() -> Self {
1139        Self(json!({ "type": "string" }))
1140    }
1141
1142    pub fn integer() -> Self {
1143        Self(json!({ "type": "integer" }))
1144    }
1145
1146    pub fn number() -> Self {
1147        Self(json!({ "type": "number" }))
1148    }
1149
1150    pub fn boolean() -> Self {
1151        Self(json!({ "type": "boolean" }))
1152    }
1153
1154    pub fn object() -> Self {
1155        Self(json!({ "type": "object" }))
1156    }
1157
1158    pub fn array(items: OpenApiSchema) -> Self {
1159        Self(json!({ "type": "array", "items": items.0 }))
1160    }
1161
1162    pub fn string_enum<I, S>(values: I) -> Self
1163    where
1164        I: IntoIterator<Item = S>,
1165        S: Into<String>,
1166    {
1167        let values = values.into_iter().map(Into::into).collect::<Vec<_>>();
1168        Self(json!({ "type": "string", "enum": values }))
1169    }
1170
1171    pub fn binary_file() -> Self {
1172        Self(json!({ "type": "string", "format": "binary" }))
1173    }
1174
1175    pub fn all_of<I>(schemas: I) -> Self
1176    where
1177        I: IntoIterator<Item = OpenApiSchema>,
1178    {
1179        Self(schema_composition("allOf", schemas))
1180    }
1181
1182    pub fn one_of<I>(schemas: I) -> Self
1183    where
1184        I: IntoIterator<Item = OpenApiSchema>,
1185    {
1186        Self(schema_composition("oneOf", schemas))
1187    }
1188
1189    pub fn any_of<I>(schemas: I) -> Self
1190    where
1191        I: IntoIterator<Item = OpenApiSchema>,
1192    {
1193        Self(schema_composition("anyOf", schemas))
1194    }
1195
1196    pub fn object_with_properties<P, K, R, S>(properties: P, required: R) -> Self
1197    where
1198        P: IntoIterator<Item = (K, OpenApiSchema)>,
1199        K: Into<String>,
1200        R: IntoIterator<Item = S>,
1201        S: Into<String>,
1202    {
1203        let properties = properties
1204            .into_iter()
1205            .map(|(name, schema)| (name.into(), schema.into_value()))
1206            .collect::<BTreeMap<String, Value>>();
1207        let required = required.into_iter().map(Into::into).collect::<Vec<_>>();
1208        let mut schema = json!({
1209            "type": "object",
1210            "properties": properties,
1211        });
1212
1213        if !required.is_empty() {
1214            if let Value::Object(object) = &mut schema {
1215                object.insert("required".to_string(), json!(required));
1216            }
1217        }
1218
1219        Self(schema)
1220    }
1221
1222    pub fn with_title(mut self, title: impl Into<String>) -> Self {
1223        insert_schema_field(&mut self.0, "title", json!(title.into()));
1224        self
1225    }
1226
1227    pub fn with_description(mut self, description: impl Into<String>) -> Self {
1228        insert_schema_field(&mut self.0, "description", json!(description.into()));
1229        self
1230    }
1231
1232    pub fn with_format(mut self, format: impl Into<String>) -> Self {
1233        insert_schema_field(&mut self.0, "format", json!(format.into()));
1234        self
1235    }
1236
1237    pub fn nullable(mut self) -> Self {
1238        insert_schema_field(&mut self.0, "nullable", json!(true));
1239        self
1240    }
1241
1242    pub fn with_property(mut self, name: impl Into<String>, schema: OpenApiSchema) -> Self {
1243        let name = name.into();
1244        let Some(object) = ensure_object_schema(&mut self.0) else {
1245            return self;
1246        };
1247        let properties = object
1248            .entry("properties".to_string())
1249            .or_insert_with(|| json!({}));
1250        if !properties.is_object() {
1251            *properties = json!({});
1252        }
1253        if let Value::Object(properties) = properties {
1254            properties.insert(name, schema.into_value());
1255        }
1256        self
1257    }
1258
1259    pub fn with_required(mut self, name: impl Into<String>) -> Self {
1260        let Some(object) = ensure_object_schema(&mut self.0) else {
1261            return self;
1262        };
1263        let required = object
1264            .entry("required".to_string())
1265            .or_insert_with(|| json!([]));
1266        if !required.is_array() {
1267            *required = json!([]);
1268        }
1269        if let Value::Array(required) = required {
1270            let name = Value::String(name.into());
1271            if !required.contains(&name) {
1272                required.push(name);
1273            }
1274        }
1275        self
1276    }
1277
1278    pub fn with_additional_properties(mut self, schema: OpenApiSchema) -> Self {
1279        let _ = ensure_object_schema(&mut self.0);
1280        insert_schema_field(&mut self.0, "additionalProperties", schema.into_value());
1281        self
1282    }
1283
1284    pub fn with_discriminator(mut self, property_name: impl Into<String>) -> Self {
1285        insert_schema_field(
1286            &mut self.0,
1287            "discriminator",
1288            json!({ "propertyName": property_name.into() }),
1289        );
1290        self
1291    }
1292
1293    pub fn with_discriminator_mapping<I, K, V>(
1294        mut self,
1295        property_name: impl Into<String>,
1296        mapping: I,
1297    ) -> Self
1298    where
1299        I: IntoIterator<Item = (K, V)>,
1300        K: Into<String>,
1301        V: Into<String>,
1302    {
1303        let mapping = mapping
1304            .into_iter()
1305            .map(|(key, value)| (key.into(), Value::String(value.into())))
1306            .collect::<serde_json::Map<_, _>>();
1307        insert_schema_field(
1308            &mut self.0,
1309            "discriminator",
1310            json!({
1311                "propertyName": property_name.into(),
1312                "mapping": mapping,
1313            }),
1314        );
1315        self
1316    }
1317
1318    pub fn with_extension_value(mut self, name: impl Into<String>, value: Value) -> Self {
1319        insert_schema_field(&mut self.0, &name.into(), value);
1320        self
1321    }
1322
1323    pub fn try_with_extension<T>(self, name: impl Into<String>, value: T) -> Result<Self>
1324    where
1325        T: Serialize,
1326    {
1327        let value = serde_json::to_value(value).map_err(|error| {
1328            BootError::Internal(format!(
1329                "OpenAPI extension could not be serialized: {error}"
1330            ))
1331        })?;
1332        Ok(self.with_extension_value(name, value))
1333    }
1334
1335    pub fn partial(mut self) -> Self {
1336        remove_schema_required(&mut self.0);
1337        self
1338    }
1339
1340    pub fn pick_properties<I, S>(mut self, names: I) -> Self
1341    where
1342        I: IntoIterator<Item = S>,
1343        S: Into<String>,
1344    {
1345        let names = names.into_iter().map(Into::into).collect::<BTreeSet<_>>();
1346        retain_schema_properties(&mut self.0, &names, true);
1347        self
1348    }
1349
1350    pub fn omit_properties<I, S>(mut self, names: I) -> Self
1351    where
1352        I: IntoIterator<Item = S>,
1353        S: Into<String>,
1354    {
1355        let names = names.into_iter().map(Into::into).collect::<BTreeSet<_>>();
1356        retain_schema_properties(&mut self.0, &names, false);
1357        self
1358    }
1359
1360    pub fn reference(name: impl AsRef<str>) -> Self {
1361        Self(json!({ "$ref": OpenApiRef::schema(name).reference }))
1362    }
1363
1364    pub fn into_value(self) -> Value {
1365        self.0
1366    }
1367}
1368
1369fn schema_composition<I>(kind: &str, schemas: I) -> Value
1370where
1371    I: IntoIterator<Item = OpenApiSchema>,
1372{
1373    let schemas = schemas
1374        .into_iter()
1375        .map(OpenApiSchema::into_value)
1376        .collect::<Vec<_>>();
1377    let mut schema = serde_json::Map::new();
1378    schema.insert(kind.to_string(), Value::Array(schemas));
1379    Value::Object(schema)
1380}
1381
1382fn insert_schema_field(schema: &mut Value, key: &str, value: Value) {
1383    if !schema.is_object() {
1384        *schema = json!({});
1385    }
1386    if let Value::Object(object) = schema {
1387        object.insert(key.to_string(), value);
1388    }
1389}
1390
1391fn ensure_object_schema(schema: &mut Value) -> Option<&mut serde_json::Map<String, Value>> {
1392    if !schema.is_object() {
1393        *schema = json!({ "type": "object" });
1394    }
1395    if let Value::Object(object) = schema {
1396        object
1397            .entry("type".to_string())
1398            .or_insert_with(|| json!("object"));
1399        return Some(object);
1400    }
1401    None
1402}
1403
1404fn remove_schema_required(schema: &mut Value) {
1405    if let Value::Object(object) = schema {
1406        object.remove("required");
1407    }
1408}
1409
1410fn retain_schema_properties(schema: &mut Value, names: &BTreeSet<String>, keep_matches: bool) {
1411    let Value::Object(object) = schema else {
1412        return;
1413    };
1414
1415    if let Some(Value::Object(properties)) = object.get_mut("properties") {
1416        properties.retain(|name, _| names.contains(name) == keep_matches);
1417    }
1418
1419    if let Some(Value::Array(required)) = object.get_mut("required") {
1420        required.retain(|value| {
1421            value
1422                .as_str()
1423                .is_some_and(|name| names.contains(name) == keep_matches)
1424        });
1425        if required.is_empty() {
1426            object.remove("required");
1427        }
1428    }
1429}
1430
1431pub fn openapi_schema_name<T>() -> String {
1432    std::any::type_name::<T>()
1433        .rsplit("::")
1434        .next()
1435        .unwrap_or("Schema")
1436        .to_string()
1437}
1438
1439impl Serialize for OpenApiSchema {
1440    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1441    where
1442        S: serde::Serializer,
1443    {
1444        self.0.serialize(serializer)
1445    }
1446}
1447
1448fn openapi_methods(method: HttpMethod) -> &'static [&'static str] {
1449    match method {
1450        HttpMethod::All => &["get", "post", "put", "patch", "delete", "options", "head"],
1451        HttpMethod::Get => &["get"],
1452        HttpMethod::Post => &["post"],
1453        HttpMethod::Put => &["put"],
1454        HttpMethod::Patch => &["patch"],
1455        HttpMethod::Delete => &["delete"],
1456        HttpMethod::Options => &["options"],
1457        HttpMethod::Head => &["head"],
1458    }
1459}
1460
1461fn is_false(value: &bool) -> bool {
1462    !*value
1463}