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#[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#[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#[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#[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#[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#[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#[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#[derive(Clone, Debug, Default, PartialEq, Serialize)]
404pub struct OpenApiPathItem {
405 #[serde(flatten)]
406 pub operations: BTreeMap<String, OpenApiOperation>,
407}
408
409#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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}