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::{ParamMeta, RequestParamEntry, RouteEntry};
8use serde_json::{json, Value as JsonValue};
9use std::collections::{BTreeSet, HashMap};
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 request_params = collect_request_params();
16    let mut paths = serde_json::Map::new();
17    let mut tags_set = BTreeSet::new();
18
19    for entry in inventory::iter::<RouteEntry> {
20        let entry = entry.clone();
21        let method = entry.method.as_str().to_lowercase();
22        let tag = extract_tag(entry.path);
23        tags_set.insert(tag.clone());
24
25        let mut parameters = Vec::new();
26        let mut has_body = false;
27
28        for param in merge_params(entry.handler_type, entry.params, &request_params) {
29            if param.source == "body" {
30                has_body = true;
31            } else {
32                let required = param.source == "path";
33                parameters.push(json!({
34                    "name": param.name,
35                    "in": param.source,
36                    "required": required,
37                    "schema": { "type": param.type_hint },
38                }));
39            }
40        }
41
42        let operation_id = to_operation_id(entry.handler_type);
43
44        let mut operation = json!({
45            "operationId": operation_id,
46            "summary": entry.summary,
47            "responses": build_responses(entry.rsp_type),
48            "tags": [tag],
49        });
50
51        if !entry.description.is_empty() {
52            operation["description"] = json!(entry.description);
53        }
54
55        if !parameters.is_empty() {
56            operation["parameters"] = json!(parameters);
57        }
58
59        if has_body {
60            operation["requestBody"] = json!({
61                "required": true,
62                "content": {
63                    "application/json": {
64                        "schema": {
65                            "type": "object",
66                        }
67                    }
68                }
69            });
70        }
71
72        let path_entry = paths
73            .entry(entry.path.to_string())
74            .or_insert_with(|| json!({}));
75        if let Some(obj) = path_entry.as_object_mut() {
76            obj.insert(method, operation);
77        }
78    }
79
80    let tags: Vec<JsonValue> = tags_set.iter().map(|t| json!({"name": t})).collect();
81
82    json!({
83        "openapi": "3.0.3",
84        "info": {
85            "title": title,
86            "version": version,
87        },
88        "tags": tags,
89        "paths": paths,
90        "components": {
91            "securitySchemes": {
92                "bearerAuth": {
93                    "type": "http",
94                    "scheme": "bearer",
95                    "bearerFormat": "JWT",
96                }
97            }
98        }
99    })
100}
101
102fn collect_request_params() -> HashMap<&'static str, &'static [ParamMeta]> {
103    let mut map = HashMap::new();
104    for entry in inventory::iter::<RequestParamEntry> {
105        map.insert(entry.request_type, entry.params);
106    }
107    map
108}
109
110/// Merge route-level params (path/body from endpoint macro) with struct-level metadata.
111fn merge_params(
112    request_type: &'static str,
113    route_params: &'static [ParamMeta],
114    request_params: &HashMap<&'static str, &'static [ParamMeta]>,
115) -> Vec<ParamMeta> {
116    let mut merged: Vec<ParamMeta> = route_params.to_vec();
117    let mut seen: HashMap<(&str, &str), ()> = merged
118        .iter()
119        .map(|p| ((p.name, p.source), ()))
120        .collect();
121
122    if let Some(extra) = request_params.get(request_type) {
123        for param in *extra {
124            let key = (param.name, param.source);
125            if seen.insert(key, ()).is_none() {
126                merged.push(param.clone());
127            }
128        }
129    }
130
131    merged
132}
133
134/// Build a responses object based on the response type name.
135fn build_responses(rsp_type: &str) -> JsonValue {
136    let schema = if rsp_type == "()" || rsp_type == "unknown" {
137        json!({ "type": "object" })
138    } else if rsp_type == "String" {
139        json!({ "type": "string" })
140    } else if rsp_type.starts_with("Vec<") {
141        let inner = &rsp_type[4..rsp_type.len() - 1];
142        json!({
143            "type": "array",
144            "items": { "$ref": format!("#/components/schemas/{}", inner) }
145        })
146    } else {
147        json!({ "$ref": format!("#/components/schemas/{}", rsp_type) })
148    };
149
150    json!({
151        "200": {
152            "description": "Successful response",
153            "content": {
154                "application/json": { "schema": schema }
155            }
156        },
157        "400": { "description": "Bad request — validation or deserialization error" },
158        "404": { "description": "Resource not found" },
159        "500": { "description": "Internal server error" },
160    })
161}
162
163/// Extract a tag name from a path for controller-style grouping.
164///
165/// Uses the last non-parameter segment as the tag, so routes like
166/// `/api/users/{id}` and `/api/users` both map to tag `"Users"`.
167///
168/// e.g., "/api/users/{id}" →"Users", "/health" →"Health"
169fn extract_tag(path: &str) -> String {
170    let tag = path
171        .trim_start_matches('/')
172        .split('/')
173        .rfind(|s| !s.starts_with('{') && !s.is_empty())
174        .unwrap_or("default");
175    titlecase(tag)
176}
177
178/// Convert a handler type name to a camelCase operation ID.
179/// "GetUserRequest" →"getUserRequest", "ListUsersRequest" →"listUsersRequest"
180fn to_operation_id(handler_type: &str) -> String {
181    let name = handler_type.strip_suffix("Request").unwrap_or(handler_type);
182    let mut chars = name.chars();
183    match chars.next() {
184        None => String::new(),
185        Some(c) => c.to_ascii_lowercase().to_string() + chars.as_str(),
186    }
187}
188
189fn titlecase(s: &str) -> String {
190    let mut chars = s.chars();
191    match chars.next() {
192        None => String::new(),
193        Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
194    }
195}