1use 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
13pub 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 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 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 "form" => form_params.push(param),
65 "body" => has_body = true,
66 "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
159fn 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
193fn 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
223fn 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
252fn 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
267fn 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}