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    let mut schema_names = BTreeSet::new();
29
30    for entry in inventory::iter::<RouteEntry> {
31        let entry = entry.clone();
32        let method = entry.method.as_str().to_lowercase();
33        // `#[category]` is the explicit grouping; fall back to the path-derived
34        // tag so uncategorized endpoints keep their documentation grouping.
35        let category = localizer.resolve(entry.category).value;
36        let tag = if category.is_empty() {
37            extract_tag(entry.path)
38        } else {
39            category
40        };
41        tags_set.insert(tag.clone());
42
43        let mut parameters = Vec::new();
44        let mut body_params: Vec<ParamMeta> = Vec::new();
45        let mut form_params: Vec<ParamMeta> = Vec::new();
46
47        // An unmarked field (`source == ""`) is a body field on a method that
48        // carries a body, and a query field everywhere else — the same rule the
49        // runtime binder applies.
50        let body_method = matches!(
51            entry.method,
52            HttpMethod::Post | HttpMethod::Put | HttpMethod::Patch
53        );
54
55        let request_params = request_index.for_route(entry.req_type_id, entry.handler_type);
56        for param in merge_params(entry.params, request_params) {
57            let source = match param.source {
58                "" if body_method => "body",
59                "" => "query",
60                other => other,
61            };
62
63            match source {
64                // An upload endpoint describes its fields as multipart parts;
65                // the generic JSON body is not applicable.
66                "form" => form_params.push(param),
67                "body" => body_params.push(param),
68                // Claims come from the principal, not from the request.
69                "claim" => {}
70                _ => {
71                    let required = param.required || source == "path";
72                    parameters.push(json!({
73                        "name": param.name,
74                        "in": source,
75                        "required": required,
76                        "schema": { "type": param.type_hint },
77                    }));
78                }
79            }
80        }
81
82        let operation_id = to_operation_id(entry.handler_type);
83        let summary = localizer.resolve(entry.name).value;
84
85        let mut operation = json!({
86            "operationId": operation_id,
87            "summary": summary,
88            "responses": build_responses(entry.rsp_type, &mut schema_names),
89            "tags": [tag],
90        });
91
92        let description = localizer.resolve(entry.description).value;
93        if !description.is_empty() {
94            operation["description"] = json!(description);
95        }
96
97        if !parameters.is_empty() {
98            operation["parameters"] = json!(parameters);
99        }
100
101        if !form_params.is_empty() {
102            operation["requestBody"] = json!({
103                "required": true,
104                "content": {
105                    "multipart/form-data": {
106                        "schema": form_schema(&form_params),
107                    }
108                }
109            });
110        } else if body_method && !body_params.is_empty() {
111            // Only write methods carry a JSON body. A read method (GET/DELETE/…)
112            // cannot bind these fields at runtime, so no request body is
113            // advertised for it.
114            operation["requestBody"] = json!({
115                "required": body_params.iter().any(|param| param.required),
116                "content": {
117                    "application/json": {
118                        "schema": body_schema(&body_params),
119                    }
120                }
121            });
122        }
123
124        let path_entry = paths
125            .entry(entry.path.to_string())
126            .or_insert_with(|| json!({}));
127        if let Some(obj) = path_entry.as_object_mut() {
128            obj.insert(method, operation);
129        }
130    }
131
132    let tags: Vec<JsonValue> = tags_set.iter().map(|t| json!({"name": t})).collect();
133
134    // The body shape of a DTO is not reflected at runtime (Rust has no
135    // reflection), so a `$ref` would dangle. Emit one placeholder component per
136    // referenced name: the document stays valid, and the endpoint's own
137    // `rsp_type` still names the type a reader should look up in the code.
138    let schemas: serde_json::Map<String, JsonValue> = schema_names
139        .into_iter()
140        .map(|name| {
141            (
142                name.clone(),
143                json!({
144                    "type": "object",
145                    "description": format!(
146                        "Placeholder for `{name}` — see the Rust type for its fields."
147                    ),
148                }),
149            )
150        })
151        .collect();
152
153    json!({
154        "openapi": "3.0.3",
155        "info": {
156            "title": title,
157            "version": version,
158        },
159        "tags": tags,
160        "paths": paths,
161        "components": {
162            "schemas": schemas,
163            "securitySchemes": {
164                "bearerAuth": {
165                    "type": "http",
166                    "scheme": "bearer",
167                    "bearerFormat": "JWT",
168                }
169            }
170        }
171    })
172}
173
174/// Request type → parameter metadata, indexed by `TypeId` first and by bare
175/// name only as a fallback for hand-written entries.
176struct RequestParams {
177    by_type: HashMap<std::any::TypeId, &'static [ParamMeta]>,
178    by_name: HashMap<&'static str, &'static [ParamMeta]>,
179}
180
181impl RequestParams {
182    fn collect() -> Self {
183        let mut by_type = HashMap::new();
184        let mut by_name = HashMap::new();
185        for entry in inventory::iter::<RequestParamEntry> {
186            by_name.insert(entry.request_type, entry.params);
187            // `TypeId::of::<()>()` is the "no type id" sentinel of hand-written
188            // entries (`RouteEntry::new`); it must not shadow a real type.
189            if entry.req_type_id != std::any::TypeId::of::<()>() {
190                by_type.insert(entry.req_type_id, entry.params);
191            }
192        }
193        Self { by_type, by_name }
194    }
195
196    /// The parameter metadata for one route's request type.
197    ///
198    /// The `TypeId` is authoritative: a qualified `self_ty`
199    /// (`contracts::ListOrdersRequest`) and two same-named request types in
200    /// different modules or crates can never resolve to the wrong entry. The
201    /// request type name is a compatibility fallback for hand-written entries,
202    /// whose route entry carries the `()` sentinel instead of a real type id.
203    fn for_route(
204        &self,
205        req_type_id: std::any::TypeId,
206        request_type: &'static str,
207    ) -> Option<&'static [ParamMeta]> {
208        self.by_type
209            .get(&req_type_id)
210            .copied()
211            .or_else(|| self.by_name.get(request_type).copied())
212    }
213}
214
215/// Merge route-level params (path from the route template) with the request
216/// struct's own metadata.
217///
218/// The struct is the richer source: it knows real types and requiredness, while
219/// the template only knows that a `{name}` segment exists. On a collision the
220/// struct's type hint wins, so `{id}` documents as `integer` when the field is
221/// an integer.
222fn merge_params(
223    route_params: &'static [ParamMeta],
224    request_params: Option<&'static [ParamMeta]>,
225) -> Vec<ParamMeta> {
226    let mut merged: Vec<ParamMeta> = route_params.to_vec();
227
228    if let Some(extra) = request_params {
229        for param in extra {
230            match merged
231                .iter_mut()
232                .find(|seen| seen.name == param.name && seen.source == param.source)
233            {
234                Some(existing) => {
235                    if existing.type_hint == "string" && param.type_hint != "string" {
236                        existing.type_hint = param.type_hint;
237                    }
238                    existing.required = existing.required || param.required;
239                }
240                None => merged.push(param.clone()),
241            }
242        }
243    }
244
245    merged
246}
247
248/// Build the `multipart/form-data` object schema for an upload endpoint.
249///
250/// `type_hint` of `binary` / `binary[]` (set by the derive for `FormFile`
251/// fields) maps to OpenAPI's `string`/`format: binary` representation.
252fn form_schema(params: &[ParamMeta]) -> JsonValue {
253    let mut properties = serde_json::Map::new();
254    let mut required = Vec::new();
255
256    for param in params {
257        let schema = match param.type_hint {
258            "binary" => json!({ "type": "string", "format": "binary" }),
259            "binary[]" => json!({
260                "type": "array",
261                "items": { "type": "string", "format": "binary" }
262            }),
263            other => json!({ "type": other }),
264        };
265        properties.insert(param.name.to_string(), schema);
266        if param.required {
267            required.push(json!(param.name));
268        }
269    }
270
271    json!({
272        "type": "object",
273        "properties": JsonValue::Object(properties),
274        "required": required,
275    })
276}
277
278/// Build the JSON request body schema from the field-level metadata.
279///
280/// Only entries carrying a `json_key` become named properties. A body field
281/// without one (`#[serde(flatten)]`) spreads its inner properties into the
282/// object, which this metadata cannot enumerate: those keys are reported via
283/// `additionalProperties: true` while the field's requiredness still counts
284/// towards `requestBody.required`.
285fn body_schema(params: &[ParamMeta]) -> JsonValue {
286    let mut properties = serde_json::Map::new();
287    let mut required = Vec::new();
288    let mut opaque = false;
289
290    for param in params {
291        match param.json_key {
292            Some(key) => {
293                properties.insert(key.to_string(), body_property_schema(param.type_hint));
294                if param.required {
295                    required.push(json!(key));
296                }
297            }
298            None => opaque = true,
299        }
300    }
301
302    let mut schema = json!({ "type": "object" });
303    if !properties.is_empty() {
304        schema["properties"] = JsonValue::Object(properties);
305        schema["required"] = json!(required);
306    }
307    if opaque {
308        schema["additionalProperties"] = json!(true);
309    }
310    schema
311}
312
313/// The schema of one JSON body property, from its type hint.
314fn body_property_schema(type_hint: &str) -> JsonValue {
315    match type_hint {
316        "binary" => json!({ "type": "string", "format": "binary" }),
317        "array" => json!({ "type": "array" }),
318        other => json!({ "type": other }),
319    }
320}
321
322/// Build a responses object based on the response type name.
323///
324/// `schema_names` collects every referenced component so the caller can emit a
325/// placeholder schema for it (this generator cannot reflect DTO fields).
326fn build_responses(rsp_type: &str, schema_names: &mut BTreeSet<String>) -> JsonValue {
327    let schema = response_schema(rsp_type, schema_names);
328
329    json!({
330        "200": {
331            "description": "Successful response",
332            "content": {
333                "application/json": { "schema": schema }
334            }
335        },
336        "400": { "description": "Bad request — validation or deserialization error" },
337        "404": { "description": "Resource not found" },
338        "500": { "description": "Internal server error" },
339    })
340}
341
342/// The success-body schema for one declared response type.
343fn response_schema(rsp_type: &str, schema_names: &mut BTreeSet<String>) -> JsonValue {
344    if rsp_type == "()" || rsp_type == "unknown" {
345        return json!({ "type": "object" });
346    }
347    if rsp_type == "String" {
348        return json!({ "type": "string" });
349    }
350    // Response-control endpoints choose status, headers and body at runtime, so
351    // the document can only say "opaque"; advertising a `$ref` to a type named
352    // `Response` would be a dangling reference.
353    if rsp_type == "Response" || rsp_type.contains("IActionResult") {
354        return json!({
355            "type": "object",
356            "description": "Response written by the handler (IActionResult: status, headers and body are runtime values).",
357        });
358    }
359    if let Some(inner) = rsp_type
360        .strip_prefix("Vec<")
361        .and_then(|rest| rest.strip_suffix('>'))
362    {
363        return json!({
364            "type": "array",
365            "items": schema_ref(inner, schema_names),
366        });
367    }
368    schema_ref(rsp_type, schema_names)
369}
370
371/// A `$ref` to a placeholder component, named after the Rust type.
372fn schema_ref(rsp_type: &str, schema_names: &mut BTreeSet<String>) -> JsonValue {
373    let name = component_name(rsp_type);
374    schema_names.insert(name.clone());
375    json!({ "$ref": format!("#/components/schemas/{}", name) })
376}
377
378/// Turn a Rust type name into an OpenAPI component key.
379///
380/// Component keys allow `[A-Za-z0-9._-]` only, so generic parameters and paths
381/// (`Vec<UserDto>`, `contracts::UserDto`) are flattened to underscores.
382fn component_name(rsp_type: &str) -> String {
383    let mut name = String::with_capacity(rsp_type.len());
384    let mut last_underscore = false;
385    for ch in rsp_type.chars() {
386        if ch.is_ascii_alphanumeric() || ch == '.' || ch == '-' {
387            name.push(ch);
388            last_underscore = false;
389        } else if !last_underscore {
390            name.push('_');
391            last_underscore = true;
392        }
393    }
394    let trimmed = name.trim_matches('_');
395    if trimmed.is_empty() {
396        "Unknown".to_string()
397    } else {
398        trimmed.to_string()
399    }
400}
401
402/// Extract a tag name from a path for controller-style grouping.
403///
404/// Uses the last non-parameter segment as the tag, so routes like
405/// `/api/users/{id}` and `/api/users` both map to tag `"Users"`.
406///
407/// e.g., "/api/users/{id}" →"Users", "/health" →"Health"
408fn extract_tag(path: &str) -> String {
409    let tag = path
410        .trim_start_matches('/')
411        .split('/')
412        .rfind(|s| !s.starts_with('{') && !s.is_empty())
413        .unwrap_or("default");
414    titlecase(tag)
415}
416
417/// Convert a handler type name to a camelCase operation ID.
418/// "GetUserRequest" →"getUserRequest", "ListUsersRequest" →"listUsersRequest"
419fn to_operation_id(handler_type: &str) -> String {
420    let name = handler_type.strip_suffix("Request").unwrap_or(handler_type);
421    let mut chars = name.chars();
422    match chars.next() {
423        None => String::new(),
424        Some(c) => c.to_ascii_lowercase().to_string() + chars.as_str(),
425    }
426}
427
428fn titlecase(s: &str) -> String {
429    let mut chars = s.chars();
430    match chars.next() {
431        None => String::new(),
432        Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
433    }
434}