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