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_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 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 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 "form" => form_params.push(param),
66 "body" => body_params.push(param),
67 "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 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
153struct 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 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 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
194fn 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
227fn 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
257fn 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
292fn 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
301fn 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
330fn 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
345fn 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}