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