Skip to main content

ranvier_openapi/
lib.rs

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/// OpenAPI components object (schemas, securitySchemes, etc.).
20#[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/// OpenAPI Security Scheme object.
29#[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/// OpenAPI generator bound to a set of ingress route descriptors.
181#[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    /// Register JSON request schema using a `FromRequest` implementor type.
265    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    /// Register JSON response schema using an `IntoResponse` implementor type.
291    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    /// Add a Bearer token SecurityScheme (`bearerAuth`) to the spec.
303    ///
304    /// When enabled, each operation will reference the `bearerAuth` security scheme,
305    /// and the Swagger UI will show a token input.
306    pub fn with_bearer_auth(mut self) -> Self {
307        self.bearer_auth = true;
308        self
309    }
310
311    /// Add RFC 7807 ProblemDetail error schemas for 4xx/5xx responses.
312    ///
313    /// When enabled, every operation gets `400`, `404`, and `500` response entries
314    /// with a `ProblemDetail` JSON schema.
315    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            // Auto-apply body_schema from post_typed / put_typed / patch_typed
364            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            // Manual patches override auto-captured schemas
379            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            // Add ProblemDetail error responses
387            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        // Build components
418        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    // --- M241: SecurityScheme + ProblemDetail tests ---
629
630    #[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        // All operations should have error responses
726        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    // --- M296: body_schema auto-application tests ---
766
767    #[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}