Skip to main content

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