Skip to main content

rust_webx_openapi/
openapi.rs

1//! OpenAPI 3.0.3 specification generator.
2//!
3//! Scans the inventory for all registered routes and builds
4//! a rich OpenAPI JSON document with parameters, request bodies,
5//! response schemas, and summaries.
6
7use rust_webx_core::route::scan::RouteEntry;
8use serde_json::{json, Value as JsonValue};
9use std::collections::BTreeSet;
10
11/// Generate an OpenAPI 3.0.3 specification from registered routes.
12///
13/// Returns a `serde_json::Value` that can be serialized to JSON.
14pub fn generate_openapi_spec(title: &str, version: &str) -> JsonValue {
15    let mut paths = serde_json::Map::new();
16    let mut tags_set = BTreeSet::new();
17
18    for entry in inventory::iter::<RouteEntry> {
19        let entry = entry.clone();
20        let method = entry.method.as_str().to_lowercase();
21        let tag = extract_tag(entry.path);
22        tags_set.insert(tag.clone());
23
24        let mut parameters = Vec::new();
25        let mut has_body = false;
26
27        for param in entry.params {
28            if param.source == "body" {
29                has_body = true;
30            } else {
31                parameters.push(json!({
32                    "name": param.name,
33                    "in": param.source,
34                    "required": true,
35                    "schema": { "type": param.type_hint },
36                }));
37            }
38        }
39
40        let operation_id = to_operation_id(entry.handler_type);
41
42        let mut operation = json!({
43            "operationId": operation_id,
44            "summary": entry.summary,
45            "responses": build_responses(entry.rsp_type),
46            "tags": [tag],
47        });
48
49        if !entry.description.is_empty() {
50            operation["description"] = json!(entry.description);
51        }
52
53        if !parameters.is_empty() {
54            operation["parameters"] = json!(parameters);
55        }
56
57        if has_body {
58            operation["requestBody"] = json!({
59                "required": true,
60                "content": {
61                    "application/json": {
62                        "schema": {
63                            "type": "object",
64                        }
65                    }
66                }
67            });
68        }
69
70        let path_entry = paths
71            .entry(entry.path.to_string())
72            .or_insert_with(|| json!({}));
73        if let Some(obj) = path_entry.as_object_mut() {
74            obj.insert(method, operation);
75        }
76    }
77
78    let tags: Vec<JsonValue> = tags_set.iter().map(|t| json!({"name": t})).collect();
79
80    json!({
81        "openapi": "3.0.3",
82        "info": {
83            "title": title,
84            "version": version,
85        },
86        "tags": tags,
87        "paths": paths,
88        "components": {
89            "securitySchemes": {
90                "bearerAuth": {
91                    "type": "http",
92                    "scheme": "bearer",
93                    "bearerFormat": "JWT",
94                }
95            }
96        }
97    })
98}
99
100/// Build a responses object based on the response type name.
101fn build_responses(rsp_type: &str) -> JsonValue {
102    let schema = if rsp_type == "()" || rsp_type == "unknown" {
103        json!({ "type": "object" })
104    } else if rsp_type == "String" {
105        json!({ "type": "string" })
106    } else if rsp_type.starts_with("Vec<") {
107        let inner = &rsp_type[4..rsp_type.len() - 1];
108        json!({
109            "type": "array",
110            "items": { "$ref": format!("#/components/schemas/{}", inner) }
111        })
112    } else {
113        json!({ "$ref": format!("#/components/schemas/{}", rsp_type) })
114    };
115
116    json!({
117        "200": {
118            "description": "Successful response",
119            "content": {
120                "application/json": { "schema": schema }
121            }
122        },
123        "400": { "description": "Bad request — validation or deserialization error" },
124        "404": { "description": "Resource not found" },
125        "500": { "description": "Internal server error" },
126    })
127}
128
129/// Extract a tag name from a path for controller-style grouping.
130///
131/// Uses the last non-parameter segment as the tag, so routes like
132/// `/api/users/{id}` and `/api/users` both map to tag `"Users"`.
133///
134/// e.g., "/api/users/{id}" →"Users", "/health" →"Health"
135fn extract_tag(path: &str) -> String {
136    let tag = path
137        .trim_start_matches('/')
138        .split('/')
139        .rfind(|s| !s.starts_with('{') && !s.is_empty())
140        .unwrap_or("default");
141    titlecase(tag)
142}
143
144/// Convert a handler type name to a camelCase operation ID.
145/// "GetUserRequest" →"getUserRequest", "ListUsersRequest" →"listUsersRequest"
146fn to_operation_id(handler_type: &str) -> String {
147    let name = handler_type.strip_suffix("Request").unwrap_or(handler_type);
148    let mut chars = name.chars();
149    match chars.next() {
150        None => String::new(),
151        Some(c) => c.to_ascii_lowercase().to_string() + chars.as_str(),
152    }
153}
154
155fn titlecase(s: &str) -> String {
156    let mut chars = s.chars();
157    match chars.next() {
158        None => String::new(),
159        Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
160    }
161}