rust_webx_openapi/
openapi.rs1use rust_webx_core::route::scan::{ParamMeta, RequestParamEntry, RouteEntry};
8use serde_json::{json, Value as JsonValue};
9use std::collections::{BTreeSet, HashMap};
10
11pub 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
28 for param in merge_params(entry.handler_type, entry.params, &request_params) {
29 if param.source == "body" {
30 has_body = true;
31 } else {
32 let required = param.source == "path";
33 parameters.push(json!({
34 "name": param.name,
35 "in": param.source,
36 "required": required,
37 "schema": { "type": param.type_hint },
38 }));
39 }
40 }
41
42 let operation_id = to_operation_id(entry.handler_type);
43
44 let mut operation = json!({
45 "operationId": operation_id,
46 "summary": entry.summary,
47 "responses": build_responses(entry.rsp_type),
48 "tags": [tag],
49 });
50
51 if !entry.description.is_empty() {
52 operation["description"] = json!(entry.description);
53 }
54
55 if !parameters.is_empty() {
56 operation["parameters"] = json!(parameters);
57 }
58
59 if has_body {
60 operation["requestBody"] = json!({
61 "required": true,
62 "content": {
63 "application/json": {
64 "schema": {
65 "type": "object",
66 }
67 }
68 }
69 });
70 }
71
72 let path_entry = paths
73 .entry(entry.path.to_string())
74 .or_insert_with(|| json!({}));
75 if let Some(obj) = path_entry.as_object_mut() {
76 obj.insert(method, operation);
77 }
78 }
79
80 let tags: Vec<JsonValue> = tags_set.iter().map(|t| json!({"name": t})).collect();
81
82 json!({
83 "openapi": "3.0.3",
84 "info": {
85 "title": title,
86 "version": version,
87 },
88 "tags": tags,
89 "paths": paths,
90 "components": {
91 "securitySchemes": {
92 "bearerAuth": {
93 "type": "http",
94 "scheme": "bearer",
95 "bearerFormat": "JWT",
96 }
97 }
98 }
99 })
100}
101
102fn collect_request_params() -> HashMap<&'static str, &'static [ParamMeta]> {
103 let mut map = HashMap::new();
104 for entry in inventory::iter::<RequestParamEntry> {
105 map.insert(entry.request_type, entry.params);
106 }
107 map
108}
109
110fn merge_params(
112 request_type: &'static str,
113 route_params: &'static [ParamMeta],
114 request_params: &HashMap<&'static str, &'static [ParamMeta]>,
115) -> Vec<ParamMeta> {
116 let mut merged: Vec<ParamMeta> = route_params.to_vec();
117 let mut seen: HashMap<(&str, &str), ()> = merged
118 .iter()
119 .map(|p| ((p.name, p.source), ()))
120 .collect();
121
122 if let Some(extra) = request_params.get(request_type) {
123 for param in *extra {
124 let key = (param.name, param.source);
125 if seen.insert(key, ()).is_none() {
126 merged.push(param.clone());
127 }
128 }
129 }
130
131 merged
132}
133
134fn build_responses(rsp_type: &str) -> JsonValue {
136 let schema = if rsp_type == "()" || rsp_type == "unknown" {
137 json!({ "type": "object" })
138 } else if rsp_type == "String" {
139 json!({ "type": "string" })
140 } else if rsp_type.starts_with("Vec<") {
141 let inner = &rsp_type[4..rsp_type.len() - 1];
142 json!({
143 "type": "array",
144 "items": { "$ref": format!("#/components/schemas/{}", inner) }
145 })
146 } else {
147 json!({ "$ref": format!("#/components/schemas/{}", rsp_type) })
148 };
149
150 json!({
151 "200": {
152 "description": "Successful response",
153 "content": {
154 "application/json": { "schema": schema }
155 }
156 },
157 "400": { "description": "Bad request — validation or deserialization error" },
158 "404": { "description": "Resource not found" },
159 "500": { "description": "Internal server error" },
160 })
161}
162
163fn extract_tag(path: &str) -> String {
170 let tag = path
171 .trim_start_matches('/')
172 .split('/')
173 .rfind(|s| !s.starts_with('{') && !s.is_empty())
174 .unwrap_or("default");
175 titlecase(tag)
176}
177
178fn to_operation_id(handler_type: &str) -> String {
181 let name = handler_type.strip_suffix("Request").unwrap_or(handler_type);
182 let mut chars = name.chars();
183 match chars.next() {
184 None => String::new(),
185 Some(c) => c.to_ascii_lowercase().to_string() + chars.as_str(),
186 }
187}
188
189fn titlecase(s: &str) -> String {
190 let mut chars = s.chars();
191 match chars.next() {
192 None => String::new(),
193 Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
194 }
195}