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_index = RequestParams::collect();
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 body_params: Vec<ParamMeta> = Vec::new();
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        let request_params = request_index.for_route(entry.req_type_id, entry.handler_type);
55        for param in merge_params(entry.params, request_params) {
56            let source = match param.source {
57                "" if body_method => "body",
58                "" => "query",
59                other => other,
60            };
61
62            match source {
63                // An upload endpoint describes its fields as multipart parts;
64                // the generic JSON body is not applicable.
65                "form" => form_params.push(param),
66                "body" => body_params.push(param),
67                // Claims come from the principal, not from the request.
68                "claim" => {}
69                _ => {
70                    let required = param.required || source == "path";
71                    parameters.push(json!({
72                        "name": param.name,
73                        "in": source,
74                        "required": required,
75                        "schema": { "type": param.type_hint },
76                    }));
77                }
78            }
79        }
80
81        let operation_id = to_operation_id(entry.handler_type);
82        let summary = localizer.resolve(entry.name).value;
83
84        let mut operation = json!({
85            "operationId": operation_id,
86            "summary": summary,
87            "responses": build_responses(entry.rsp_type),
88            "tags": [tag],
89        });
90
91        let description = localizer.resolve(entry.description).value;
92        if !description.is_empty() {
93            operation["description"] = json!(description);
94        }
95
96        if !parameters.is_empty() {
97            operation["parameters"] = json!(parameters);
98        }
99
100        if !form_params.is_empty() {
101            operation["requestBody"] = json!({
102                "required": true,
103                "content": {
104                    "multipart/form-data": {
105                        "schema": form_schema(&form_params),
106                    }
107                }
108            });
109        } else if body_method && !body_params.is_empty() {
110            // Only write methods carry a JSON body. A read method (GET/DELETE/…)
111            // cannot bind these fields at runtime, so no request body is
112            // advertised for it.
113            operation["requestBody"] = json!({
114                "required": body_params.iter().any(|param| param.required),
115                "content": {
116                    "application/json": {
117                        "schema": body_schema(&body_params),
118                    }
119                }
120            });
121        }
122
123        let path_entry = paths
124            .entry(entry.path.to_string())
125            .or_insert_with(|| json!({}));
126        if let Some(obj) = path_entry.as_object_mut() {
127            obj.insert(method, operation);
128        }
129    }
130
131    let tags: Vec<JsonValue> = tags_set.iter().map(|t| json!({"name": t})).collect();
132
133    json!({
134        "openapi": "3.0.3",
135        "info": {
136            "title": title,
137            "version": version,
138        },
139        "tags": tags,
140        "paths": paths,
141        "components": {
142            "securitySchemes": {
143                "bearerAuth": {
144                    "type": "http",
145                    "scheme": "bearer",
146                    "bearerFormat": "JWT",
147                }
148            }
149        }
150    })
151}
152
153/// Request type → parameter metadata, indexed by `TypeId` first and by bare
154/// name only as a fallback for hand-written entries.
155struct RequestParams {
156    by_type: HashMap<std::any::TypeId, &'static [ParamMeta]>,
157    by_name: HashMap<&'static str, &'static [ParamMeta]>,
158}
159
160impl RequestParams {
161    fn collect() -> Self {
162        let mut by_type = HashMap::new();
163        let mut by_name = HashMap::new();
164        for entry in inventory::iter::<RequestParamEntry> {
165            by_name.insert(entry.request_type, entry.params);
166            // `TypeId::of::<()>()` is the "no type id" sentinel of hand-written
167            // entries (`RouteEntry::new`); it must not shadow a real type.
168            if entry.req_type_id != std::any::TypeId::of::<()>() {
169                by_type.insert(entry.req_type_id, entry.params);
170            }
171        }
172        Self { by_type, by_name }
173    }
174
175    /// The parameter metadata for one route's request type.
176    ///
177    /// The `TypeId` is authoritative: a qualified `self_ty`
178    /// (`contracts::ListOrdersRequest`) and two same-named request types in
179    /// different modules or crates can never resolve to the wrong entry. The
180    /// request type name is a compatibility fallback for hand-written entries,
181    /// whose route entry carries the `()` sentinel instead of a real type id.
182    fn for_route(
183        &self,
184        req_type_id: std::any::TypeId,
185        request_type: &'static str,
186    ) -> Option<&'static [ParamMeta]> {
187        self.by_type
188            .get(&req_type_id)
189            .copied()
190            .or_else(|| self.by_name.get(request_type).copied())
191    }
192}
193
194/// Merge route-level params (path from the route template) with the request
195/// struct's own metadata.
196///
197/// The struct is the richer source: it knows real types and requiredness, while
198/// the template only knows that a `{name}` segment exists. On a collision the
199/// struct's type hint wins, so `{id}` documents as `integer` when the field is
200/// an integer.
201fn merge_params(
202    route_params: &'static [ParamMeta],
203    request_params: Option<&'static [ParamMeta]>,
204) -> Vec<ParamMeta> {
205    let mut merged: Vec<ParamMeta> = route_params.to_vec();
206
207    if let Some(extra) = request_params {
208        for param in extra {
209            match merged
210                .iter_mut()
211                .find(|seen| seen.name == param.name && seen.source == param.source)
212            {
213                Some(existing) => {
214                    if existing.type_hint == "string" && param.type_hint != "string" {
215                        existing.type_hint = param.type_hint;
216                    }
217                    existing.required = existing.required || param.required;
218                }
219                None => merged.push(param.clone()),
220            }
221        }
222    }
223
224    merged
225}
226
227/// Build the `multipart/form-data` object schema for an upload endpoint.
228///
229/// `type_hint` of `binary` / `binary[]` (set by the derive for `FormFile`
230/// fields) maps to OpenAPI's `string`/`format: binary` representation.
231fn form_schema(params: &[ParamMeta]) -> JsonValue {
232    let mut properties = serde_json::Map::new();
233    let mut required = Vec::new();
234
235    for param in params {
236        let schema = match param.type_hint {
237            "binary" => json!({ "type": "string", "format": "binary" }),
238            "binary[]" => json!({
239                "type": "array",
240                "items": { "type": "string", "format": "binary" }
241            }),
242            other => json!({ "type": other }),
243        };
244        properties.insert(param.name.to_string(), schema);
245        if param.required {
246            required.push(json!(param.name));
247        }
248    }
249
250    json!({
251        "type": "object",
252        "properties": JsonValue::Object(properties),
253        "required": required,
254    })
255}
256
257/// Build the JSON request body schema from the field-level metadata.
258///
259/// Only entries carrying a `json_key` become named properties. A body field
260/// without one (`#[serde(flatten)]`) spreads its inner properties into the
261/// object, which this metadata cannot enumerate: those keys are reported via
262/// `additionalProperties: true` while the field's requiredness still counts
263/// towards `requestBody.required`.
264fn body_schema(params: &[ParamMeta]) -> JsonValue {
265    let mut properties = serde_json::Map::new();
266    let mut required = Vec::new();
267    let mut opaque = false;
268
269    for param in params {
270        match param.json_key {
271            Some(key) => {
272                properties.insert(key.to_string(), body_property_schema(param.type_hint));
273                if param.required {
274                    required.push(json!(key));
275                }
276            }
277            None => opaque = true,
278        }
279    }
280
281    let mut schema = json!({ "type": "object" });
282    if !properties.is_empty() {
283        schema["properties"] = JsonValue::Object(properties);
284        schema["required"] = json!(required);
285    }
286    if opaque {
287        schema["additionalProperties"] = json!(true);
288    }
289    schema
290}
291
292/// The schema of one JSON body property, from its type hint.
293fn body_property_schema(type_hint: &str) -> JsonValue {
294    match type_hint {
295        "binary" => json!({ "type": "string", "format": "binary" }),
296        "array" => json!({ "type": "array" }),
297        other => json!({ "type": other }),
298    }
299}
300
301/// Build a responses object based on the response type name.
302fn build_responses(rsp_type: &str) -> JsonValue {
303    let schema = if rsp_type == "()" || rsp_type == "unknown" {
304        json!({ "type": "object" })
305    } else if rsp_type == "String" {
306        json!({ "type": "string" })
307    } else if rsp_type.starts_with("Vec<") {
308        let inner = &rsp_type[4..rsp_type.len() - 1];
309        json!({
310            "type": "array",
311            "items": { "$ref": format!("#/components/schemas/{}", inner) }
312        })
313    } else {
314        json!({ "$ref": format!("#/components/schemas/{}", rsp_type) })
315    };
316
317    json!({
318        "200": {
319            "description": "Successful response",
320            "content": {
321                "application/json": { "schema": schema }
322            }
323        },
324        "400": { "description": "Bad request — validation or deserialization error" },
325        "404": { "description": "Resource not found" },
326        "500": { "description": "Internal server error" },
327    })
328}
329
330/// Extract a tag name from a path for controller-style grouping.
331///
332/// Uses the last non-parameter segment as the tag, so routes like
333/// `/api/users/{id}` and `/api/users` both map to tag `"Users"`.
334///
335/// e.g., "/api/users/{id}" →"Users", "/health" →"Health"
336fn extract_tag(path: &str) -> String {
337    let tag = path
338        .trim_start_matches('/')
339        .split('/')
340        .rfind(|s| !s.starts_with('{') && !s.is_empty())
341        .unwrap_or("default");
342    titlecase(tag)
343}
344
345/// Convert a handler type name to a camelCase operation ID.
346/// "GetUserRequest" →"getUserRequest", "ListUsersRequest" →"listUsersRequest"
347fn to_operation_id(handler_type: &str) -> String {
348    let name = handler_type.strip_suffix("Request").unwrap_or(handler_type);
349    let mut chars = name.chars();
350    match chars.next() {
351        None => String::new(),
352        Some(c) => c.to_ascii_lowercase().to_string() + chars.as_str(),
353    }
354}
355
356fn titlecase(s: &str) -> String {
357    let mut chars = s.chars();
358    match chars.next() {
359        None => String::new(),
360        Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
361    }
362}