1use std::collections::{BTreeMap, HashMap};
2
3use http::Method;
4use ranvier_core::Schematic;
5use ranvier_http::{FromRequest, HttpIngress, HttpRouteDescriptor, IntoResponse};
6use schemars::{JsonSchema, schema_for};
7use serde::{Deserialize, Serialize};
8use serde_json::{Value, json};
9
10#[derive(Clone, Debug, Serialize, Deserialize)]
11pub struct OpenApiDocument {
12 pub openapi: String,
13 pub info: OpenApiInfo,
14 pub paths: BTreeMap<String, OpenApiPathItem>,
15 #[serde(skip_serializing_if = "Option::is_none")]
16 pub components: Option<OpenApiComponents>,
17}
18
19#[derive(Clone, Debug, Serialize, Deserialize, Default)]
21pub struct OpenApiComponents {
22 #[serde(rename = "securitySchemes", skip_serializing_if = "BTreeMap::is_empty", default)]
23 pub security_schemes: BTreeMap<String, SecurityScheme>,
24 #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
25 pub schemas: BTreeMap<String, Value>,
26}
27
28#[derive(Clone, Debug, Serialize, Deserialize)]
30pub struct SecurityScheme {
31 #[serde(rename = "type")]
32 pub scheme_type: String,
33 #[serde(skip_serializing_if = "Option::is_none")]
34 pub scheme: Option<String>,
35 #[serde(rename = "bearerFormat", skip_serializing_if = "Option::is_none")]
36 pub bearer_format: Option<String>,
37 #[serde(skip_serializing_if = "Option::is_none")]
38 pub description: Option<String>,
39}
40
41#[derive(Clone, Debug, Serialize, Deserialize)]
42pub struct OpenApiInfo {
43 pub title: String,
44 pub version: String,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub description: Option<String>,
47}
48
49#[derive(Clone, Debug, Serialize, Deserialize, Default)]
50pub struct OpenApiPathItem {
51 #[serde(skip_serializing_if = "Option::is_none")]
52 pub get: Option<OpenApiOperation>,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub post: Option<OpenApiOperation>,
55 #[serde(skip_serializing_if = "Option::is_none")]
56 pub put: Option<OpenApiOperation>,
57 #[serde(skip_serializing_if = "Option::is_none")]
58 pub delete: Option<OpenApiOperation>,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 pub patch: Option<OpenApiOperation>,
61 #[serde(skip_serializing_if = "Option::is_none")]
62 pub options: Option<OpenApiOperation>,
63 #[serde(skip_serializing_if = "Option::is_none")]
64 pub head: Option<OpenApiOperation>,
65}
66
67impl OpenApiPathItem {
68 fn set_operation(&mut self, method: &Method, operation: OpenApiOperation) {
69 match *method {
70 Method::GET => self.get = Some(operation),
71 Method::POST => self.post = Some(operation),
72 Method::PUT => self.put = Some(operation),
73 Method::DELETE => self.delete = Some(operation),
74 Method::PATCH => self.patch = Some(operation),
75 Method::OPTIONS => self.options = Some(operation),
76 Method::HEAD => self.head = Some(operation),
77 _ => {}
78 }
79 }
80}
81
82#[derive(Clone, Debug, Serialize, Deserialize)]
83pub struct OpenApiOperation {
84 #[serde(skip_serializing_if = "Option::is_none")]
85 pub summary: Option<String>,
86 #[serde(rename = "operationId", skip_serializing_if = "Option::is_none")]
87 pub operation_id: Option<String>,
88 #[serde(skip_serializing_if = "Vec::is_empty", default)]
89 pub parameters: Vec<OpenApiParameter>,
90 #[serde(rename = "requestBody", skip_serializing_if = "Option::is_none")]
91 pub request_body: Option<OpenApiRequestBody>,
92 pub responses: BTreeMap<String, OpenApiResponse>,
93 #[serde(rename = "x-ranvier", skip_serializing_if = "Option::is_none")]
94 pub x_ranvier: Option<Value>,
95}
96
97#[derive(Clone, Debug, Serialize, Deserialize)]
98pub struct OpenApiParameter {
99 pub name: String,
100 #[serde(rename = "in")]
101 pub location: String,
102 pub required: bool,
103 pub schema: Value,
104}
105
106#[derive(Clone, Debug, Serialize, Deserialize)]
107pub struct OpenApiRequestBody {
108 pub required: bool,
109 pub content: BTreeMap<String, OpenApiMediaType>,
110}
111
112#[derive(Clone, Debug, Serialize, Deserialize)]
113pub struct OpenApiResponse {
114 pub description: String,
115 #[serde(skip_serializing_if = "Option::is_none")]
116 pub content: Option<BTreeMap<String, OpenApiMediaType>>,
117}
118
119#[derive(Clone, Debug, Serialize, Deserialize)]
120pub struct OpenApiMediaType {
121 pub schema: Value,
122}
123
124#[derive(Clone, Debug)]
125struct OperationPatch {
126 summary: Option<String>,
127 request_schema: Option<Value>,
128 response_schema: Option<Value>,
129}
130
131impl OperationPatch {
132 fn apply(self, operation: &mut OpenApiOperation) {
133 if let Some(summary) = self.summary {
134 operation.summary = Some(summary);
135 }
136 if let Some(schema) = self.request_schema {
137 let mut content = BTreeMap::new();
138 content.insert("application/json".to_string(), OpenApiMediaType { schema });
139 operation.request_body = Some(OpenApiRequestBody {
140 required: true,
141 content,
142 });
143 }
144 if let Some(schema) = self.response_schema {
145 let mut content = BTreeMap::new();
146 content.insert("application/json".to_string(), OpenApiMediaType { schema });
147
148 let response =
149 operation
150 .responses
151 .entry("200".to_string())
152 .or_insert(OpenApiResponse {
153 description: "Successful response".to_string(),
154 content: None,
155 });
156 response.content = Some(content);
157 }
158 }
159}
160
161#[derive(Clone, Debug)]
162struct SchematicMetadata {
163 id: String,
164 name: String,
165 node_count: usize,
166 edge_count: usize,
167}
168
169impl From<&Schematic> for SchematicMetadata {
170 fn from(value: &Schematic) -> Self {
171 Self {
172 id: value.id.clone(),
173 name: value.name.clone(),
174 node_count: value.nodes.len(),
175 edge_count: value.edges.len(),
176 }
177 }
178}
179
180#[derive(Clone, Debug)]
182pub struct OpenApiGenerator {
183 routes: Vec<HttpRouteDescriptor>,
184 title: String,
185 version: String,
186 description: Option<String>,
187 patches: HashMap<String, OperationPatch>,
188 schematic: Option<SchematicMetadata>,
189 bearer_auth: bool,
190 problem_detail_errors: bool,
191}
192
193impl OpenApiGenerator {
194 pub fn from_descriptors(routes: Vec<HttpRouteDescriptor>) -> Self {
195 Self {
196 routes,
197 title: "Ranvier API".to_string(),
198 version: "0.1.0".to_string(),
199 description: None,
200 patches: HashMap::new(),
201 schematic: None,
202 bearer_auth: false,
203 problem_detail_errors: false,
204 }
205 }
206
207 pub fn from_ingress<R>(ingress: &HttpIngress<R>) -> Self
208 where
209 R: ranvier_core::transition::ResourceRequirement + Clone + Send + Sync + 'static,
210 {
211 Self::from_descriptors(ingress.route_descriptors())
212 }
213
214 pub fn title(mut self, title: impl Into<String>) -> Self {
215 self.title = title.into();
216 self
217 }
218
219 pub fn version(mut self, version: impl Into<String>) -> Self {
220 self.version = version.into();
221 self
222 }
223
224 pub fn description(mut self, description: impl Into<String>) -> Self {
225 self.description = Some(description.into());
226 self
227 }
228
229 pub fn with_schematic(mut self, schematic: &Schematic) -> Self {
230 self.schematic = Some(SchematicMetadata::from(schematic));
231 self
232 }
233
234 pub fn summary(
235 mut self,
236 method: Method,
237 path_pattern: impl AsRef<str>,
238 summary: impl Into<String>,
239 ) -> Self {
240 let key = operation_key(&method, path_pattern.as_ref());
241 let patch = self.patches.entry(key).or_insert(OperationPatch {
242 summary: None,
243 request_schema: None,
244 response_schema: None,
245 });
246 patch.summary = Some(summary.into());
247 self
248 }
249
250 pub fn json_request_schema<T>(mut self, method: Method, path_pattern: impl AsRef<str>) -> Self
251 where
252 T: JsonSchema,
253 {
254 let key = operation_key(&method, path_pattern.as_ref());
255 let patch = self.patches.entry(key).or_insert(OperationPatch {
256 summary: None,
257 request_schema: None,
258 response_schema: None,
259 });
260 patch.request_schema = Some(schema_value::<T>());
261 self
262 }
263
264 pub fn json_request_schema_from_extractor<T>(
266 self,
267 method: Method,
268 path_pattern: impl AsRef<str>,
269 ) -> Self
270 where
271 T: FromRequest + JsonSchema,
272 {
273 self.json_request_schema::<T>(method, path_pattern)
274 }
275
276 pub fn json_response_schema<T>(mut self, method: Method, path_pattern: impl AsRef<str>) -> Self
277 where
278 T: JsonSchema,
279 {
280 let key = operation_key(&method, path_pattern.as_ref());
281 let patch = self.patches.entry(key).or_insert(OperationPatch {
282 summary: None,
283 request_schema: None,
284 response_schema: None,
285 });
286 patch.response_schema = Some(schema_value::<T>());
287 self
288 }
289
290 pub fn json_response_schema_from_into_response<T>(
292 self,
293 method: Method,
294 path_pattern: impl AsRef<str>,
295 ) -> Self
296 where
297 T: IntoResponse + JsonSchema,
298 {
299 self.json_response_schema::<T>(method, path_pattern)
300 }
301
302 pub fn with_bearer_auth(mut self) -> Self {
307 self.bearer_auth = true;
308 self
309 }
310
311 pub fn with_problem_detail_errors(mut self) -> Self {
316 self.problem_detail_errors = true;
317 self
318 }
319
320 pub fn build(self) -> OpenApiDocument {
321 let mut paths = BTreeMap::new();
322
323 for route in self.routes {
324 let (openapi_path, parameters) = normalize_path(route.path_pattern());
325 let default_summary = format!("{} {}", route.method(), openapi_path);
326 let operation_id = format!(
327 "{}_{}",
328 route.method().as_str().to_ascii_lowercase(),
329 route
330 .path_pattern()
331 .trim_matches('/')
332 .replace(['/', ':', '*'], "_")
333 .trim_matches('_')
334 );
335
336 let mut operation = OpenApiOperation {
337 summary: Some(default_summary),
338 operation_id: if operation_id.is_empty() {
339 None
340 } else {
341 Some(operation_id)
342 },
343 parameters,
344 request_body: None,
345 responses: BTreeMap::from([(
346 "200".to_string(),
347 OpenApiResponse {
348 description: "Successful response".to_string(),
349 content: None,
350 },
351 )]),
352 x_ranvier: self.schematic.as_ref().map(|metadata| {
353 json!({
354 "schematic_id": metadata.id,
355 "schematic_name": metadata.name,
356 "node_count": metadata.node_count,
357 "edge_count": metadata.edge_count,
358 "route_pattern": route.path_pattern(),
359 })
360 }),
361 };
362
363 if let Some(schema) = route.body_schema() {
365 let mut content = BTreeMap::new();
366 content.insert(
367 "application/json".to_string(),
368 OpenApiMediaType {
369 schema: schema.clone(),
370 },
371 );
372 operation.request_body = Some(OpenApiRequestBody {
373 required: true,
374 content,
375 });
376 }
377
378 if let Some(patch) = self
380 .patches
381 .get(&operation_key(route.method(), route.path_pattern()))
382 {
383 patch.clone().apply(&mut operation);
384 }
385
386 if self.problem_detail_errors {
388 let problem_ref = json!({"$ref": "#/components/schemas/ProblemDetail"});
389 let mut problem_content = BTreeMap::new();
390 problem_content.insert(
391 "application/problem+json".to_string(),
392 OpenApiMediaType {
393 schema: problem_ref,
394 },
395 );
396
397 for (code, desc) in [
398 ("400", "Bad Request"),
399 ("404", "Not Found"),
400 ("500", "Internal Server Error"),
401 ] {
402 operation.responses.entry(code.to_string()).or_insert(
403 OpenApiResponse {
404 description: desc.to_string(),
405 content: Some(problem_content.clone()),
406 },
407 );
408 }
409 }
410
411 paths
412 .entry(openapi_path)
413 .or_insert_with(OpenApiPathItem::default)
414 .set_operation(route.method(), operation);
415 }
416
417 let mut components = OpenApiComponents::default();
419
420 if self.bearer_auth {
421 components.security_schemes.insert(
422 "bearerAuth".to_string(),
423 SecurityScheme {
424 scheme_type: "http".to_string(),
425 scheme: Some("bearer".to_string()),
426 bearer_format: Some("JWT".to_string()),
427 description: Some("Bearer token authentication".to_string()),
428 },
429 );
430 }
431
432 if self.problem_detail_errors {
433 components.schemas.insert(
434 "ProblemDetail".to_string(),
435 json!({
436 "type": "object",
437 "description": "RFC 7807 Problem Detail",
438 "properties": {
439 "type": { "type": "string", "description": "URI reference identifying the problem type" },
440 "title": { "type": "string", "description": "Short human-readable summary" },
441 "status": { "type": "integer", "description": "HTTP status code" },
442 "detail": { "type": "string", "description": "Human-readable explanation" },
443 "instance": { "type": "string", "description": "URI reference identifying the specific occurrence" }
444 },
445 "required": ["type", "title", "status"]
446 }),
447 );
448 }
449
450 let has_components = !components.security_schemes.is_empty()
451 || !components.schemas.is_empty();
452
453 OpenApiDocument {
454 openapi: "3.0.3".to_string(),
455 info: OpenApiInfo {
456 title: self.title,
457 version: self.version,
458 description: self.description,
459 },
460 paths,
461 components: if has_components {
462 Some(components)
463 } else {
464 None
465 },
466 }
467 }
468
469 pub fn build_json(self) -> Value {
470 serde_json::to_value(self.build()).expect("openapi document should serialize")
471 }
472
473 pub fn build_pretty_json(self) -> String {
474 serde_json::to_string_pretty(&self.build()).expect("openapi document should serialize")
475 }
476}
477
478pub fn swagger_ui_html(spec_url: &str, title: &str) -> String {
479 format!(
480 r#"<!doctype html>
481<html lang="en">
482<head>
483 <meta charset="utf-8" />
484 <meta name="viewport" content="width=device-width,initial-scale=1" />
485 <title>{title}</title>
486 <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
487</head>
488<body>
489 <div id="swagger-ui"></div>
490 <script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
491 <script>
492 window.ui = SwaggerUIBundle({{
493 url: '{spec_url}',
494 dom_id: '#swagger-ui',
495 deepLinking: true,
496 presets: [SwaggerUIBundle.presets.apis]
497 }});
498 </script>
499</body>
500</html>"#
501 )
502}
503
504fn operation_key(method: &Method, path_pattern: &str) -> String {
505 format!("{} {}", method.as_str(), path_pattern)
506}
507
508fn normalize_path(path_pattern: &str) -> (String, Vec<OpenApiParameter>) {
509 if path_pattern == "/" {
510 return ("/".to_string(), Vec::new());
511 }
512
513 let mut params = Vec::new();
514 let mut segments = Vec::new();
515
516 for segment in path_pattern
517 .trim_matches('/')
518 .split('/')
519 .filter(|segment| !segment.is_empty())
520 {
521 if let Some(name) = segment
522 .strip_prefix(':')
523 .or_else(|| segment.strip_prefix('*'))
524 {
525 let normalized_name = if name.is_empty() { "path" } else { name };
526 segments.push(format!("{{{normalized_name}}}"));
527 params.push(OpenApiParameter {
528 name: normalized_name.to_string(),
529 location: "path".to_string(),
530 required: true,
531 schema: json!({"type": "string"}),
532 });
533 continue;
534 }
535
536 segments.push(segment.to_string());
537 }
538
539 (format!("/{}", segments.join("/")), params)
540}
541
542fn schema_value<T>() -> Value
543where
544 T: JsonSchema,
545{
546 serde_json::to_value(schema_for!(T)).expect("json schema should serialize")
547}
548
549pub mod prelude {
550 pub use crate::{
551 OpenApiComponents, OpenApiDocument, OpenApiGenerator, SecurityScheme, swagger_ui_html,
552 };
553}
554
555#[cfg(test)]
556mod tests {
557 use super::*;
558 use schemars::JsonSchema;
559
560 #[derive(JsonSchema)]
561 #[allow(dead_code)]
562 struct CreateUserRequest {
563 email: String,
564 }
565
566 #[derive(JsonSchema)]
567 #[allow(dead_code)]
568 struct CreateUserResponse {
569 id: String,
570 }
571
572 #[test]
573 fn normalize_path_converts_param_and_wildcard_segments() {
574 let (path, params) = normalize_path("/users/:id/files/*path");
575 assert_eq!(path, "/users/{id}/files/{path}");
576 assert_eq!(params.len(), 2);
577 assert_eq!(params[0].name, "id");
578 assert_eq!(params[1].name, "path");
579 }
580
581 #[test]
582 fn generator_builds_paths_from_route_descriptors() {
583 let doc = OpenApiGenerator::from_descriptors(vec![
584 HttpRouteDescriptor::new(Method::GET, "/users/:id"),
585 HttpRouteDescriptor::new(Method::POST, "/users"),
586 ])
587 .title("Users API")
588 .version("0.7.0")
589 .build();
590
591 assert_eq!(doc.info.title, "Users API");
592 assert!(doc.paths.contains_key("/users/{id}"));
593 assert!(doc.paths.contains_key("/users"));
594 assert!(doc.paths["/users/{id}"].get.is_some());
595 assert!(doc.paths["/users"].post.is_some());
596 }
597
598 #[test]
599 fn generator_applies_json_request_response_schema_overrides() {
600 let doc = OpenApiGenerator::from_descriptors(vec![HttpRouteDescriptor::new(
601 Method::POST,
602 "/users",
603 )])
604 .json_request_schema::<CreateUserRequest>(Method::POST, "/users")
605 .json_response_schema::<CreateUserResponse>(Method::POST, "/users")
606 .summary(Method::POST, "/users", "Create a user")
607 .build();
608
609 let operation = doc.paths["/users"].post.as_ref().expect("post operation");
610 assert_eq!(operation.summary.as_deref(), Some("Create a user"));
611 assert!(operation.request_body.is_some());
612 assert!(
613 operation.responses["200"]
614 .content
615 .as_ref()
616 .expect("response content")
617 .contains_key("application/json")
618 );
619 }
620
621 #[test]
622 fn swagger_html_contains_spec_url() {
623 let html = swagger_ui_html("/openapi.json", "API Docs");
624 assert!(html.contains("/openapi.json"));
625 assert!(html.contains("SwaggerUIBundle"));
626 }
627
628 #[test]
631 fn bearer_auth_adds_security_scheme() {
632 let doc = OpenApiGenerator::from_descriptors(vec![HttpRouteDescriptor::new(
633 Method::GET,
634 "/users",
635 )])
636 .with_bearer_auth()
637 .build();
638
639 let components = doc.components.expect("should have components");
640 let scheme = components
641 .security_schemes
642 .get("bearerAuth")
643 .expect("should have bearerAuth");
644 assert_eq!(scheme.scheme_type, "http");
645 assert_eq!(scheme.scheme.as_deref(), Some("bearer"));
646 assert_eq!(scheme.bearer_format.as_deref(), Some("JWT"));
647 }
648
649 #[test]
650 fn no_bearer_auth_means_no_components() {
651 let doc = OpenApiGenerator::from_descriptors(vec![HttpRouteDescriptor::new(
652 Method::GET,
653 "/users",
654 )])
655 .build();
656
657 assert!(doc.components.is_none());
658 }
659
660 #[test]
661 fn problem_detail_adds_error_responses() {
662 let doc = OpenApiGenerator::from_descriptors(vec![HttpRouteDescriptor::new(
663 Method::GET,
664 "/users",
665 )])
666 .with_problem_detail_errors()
667 .build();
668
669 let op = doc.paths["/users"].get.as_ref().unwrap();
670 assert!(op.responses.contains_key("400"));
671 assert!(op.responses.contains_key("404"));
672 assert!(op.responses.contains_key("500"));
673
674 let r404 = &op.responses["404"];
675 assert_eq!(r404.description, "Not Found");
676 let content = r404.content.as_ref().unwrap();
677 assert!(content.contains_key("application/problem+json"));
678 }
679
680 #[test]
681 fn problem_detail_schema_in_components() {
682 let doc = OpenApiGenerator::from_descriptors(vec![HttpRouteDescriptor::new(
683 Method::GET,
684 "/users",
685 )])
686 .with_problem_detail_errors()
687 .build();
688
689 let components = doc.components.expect("should have components");
690 let schema = components
691 .schemas
692 .get("ProblemDetail")
693 .expect("should have ProblemDetail schema");
694 assert_eq!(schema["type"], "object");
695 assert!(schema["properties"]["type"].is_object());
696 assert!(schema["properties"]["title"].is_object());
697 assert!(schema["properties"]["status"].is_object());
698 }
699
700 #[test]
701 fn problem_detail_references_schema() {
702 let doc = OpenApiGenerator::from_descriptors(vec![HttpRouteDescriptor::new(
703 Method::POST,
704 "/orders",
705 )])
706 .with_problem_detail_errors()
707 .build();
708
709 let op = doc.paths["/orders"].post.as_ref().unwrap();
710 let content_500 = op.responses["500"].content.as_ref().unwrap();
711 let schema = &content_500["application/problem+json"].schema;
712 assert_eq!(schema["$ref"], "#/components/schemas/ProblemDetail");
713 }
714
715 #[test]
716 fn multiple_routes_all_get_error_responses() {
717 let doc = OpenApiGenerator::from_descriptors(vec![
718 HttpRouteDescriptor::new(Method::GET, "/users"),
719 HttpRouteDescriptor::new(Method::POST, "/users"),
720 HttpRouteDescriptor::new(Method::DELETE, "/users/:id"),
721 ])
722 .with_problem_detail_errors()
723 .build();
724
725 let get_op = doc.paths["/users"].get.as_ref().unwrap();
727 let post_op = doc.paths["/users"].post.as_ref().unwrap();
728 let delete_op = doc.paths["/users/{id}"].delete.as_ref().unwrap();
729
730 assert!(get_op.responses.contains_key("400"));
731 assert!(post_op.responses.contains_key("404"));
732 assert!(delete_op.responses.contains_key("500"));
733 }
734
735 #[test]
736 fn bearer_auth_and_problem_detail_combined() {
737 let doc = OpenApiGenerator::from_descriptors(vec![HttpRouteDescriptor::new(
738 Method::GET,
739 "/protected",
740 )])
741 .with_bearer_auth()
742 .with_problem_detail_errors()
743 .build();
744
745 let components = doc.components.expect("should have components");
746 assert!(components.security_schemes.contains_key("bearerAuth"));
747 assert!(components.schemas.contains_key("ProblemDetail"));
748 }
749
750 #[test]
751 fn bearer_auth_serializes_in_json() {
752 let doc = OpenApiGenerator::from_descriptors(vec![HttpRouteDescriptor::new(
753 Method::GET,
754 "/api",
755 )])
756 .with_bearer_auth()
757 .build_json();
758
759 let schemes = &doc["components"]["securitySchemes"];
760 assert_eq!(schemes["bearerAuth"]["type"], "http");
761 assert_eq!(schemes["bearerAuth"]["scheme"], "bearer");
762 assert_eq!(schemes["bearerAuth"]["bearerFormat"], "JWT");
763 }
764
765 #[test]
768 fn body_schema_auto_applied_to_request_body() {
769 let schema = schema_value::<CreateUserRequest>();
770 let mut desc = HttpRouteDescriptor::new(Method::POST, "/users");
771 desc.body_schema = Some(schema.clone());
772
773 let doc = OpenApiGenerator::from_descriptors(vec![desc]).build();
774
775 let operation = doc.paths["/users"].post.as_ref().expect("post operation");
776 let body = operation.request_body.as_ref().expect("request body");
777 assert!(body.required);
778 let media = body.content.get("application/json").expect("json content");
779 assert_eq!(media.schema, schema);
780 }
781
782 #[test]
783 fn manual_patch_overrides_auto_body_schema() {
784 let auto_schema = schema_value::<CreateUserRequest>();
785 let manual_schema = schema_value::<CreateUserResponse>();
786 let mut desc = HttpRouteDescriptor::new(Method::POST, "/users");
787 desc.body_schema = Some(auto_schema);
788
789 let doc = OpenApiGenerator::from_descriptors(vec![desc])
790 .json_request_schema::<CreateUserResponse>(Method::POST, "/users")
791 .build();
792
793 let operation = doc.paths["/users"].post.as_ref().expect("post operation");
794 let body = operation.request_body.as_ref().expect("request body");
795 let media = body.content.get("application/json").expect("json content");
796 assert_eq!(media.schema, manual_schema);
797 }
798
799 #[test]
800 fn no_body_schema_means_no_request_body() {
801 let doc = OpenApiGenerator::from_descriptors(vec![HttpRouteDescriptor::new(
802 Method::GET,
803 "/users",
804 )])
805 .build();
806
807 let operation = doc.paths["/users"].get.as_ref().expect("get operation");
808 assert!(operation.request_body.is_none());
809 }
810}