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 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 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 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 "form" => form_params.push(param),
67 "body" => body_params.push(param),
68 "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 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 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
174struct 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 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 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
215fn 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
248fn 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
278fn 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
313fn 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
322fn 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
342fn 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 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
371fn 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
378fn 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
402fn 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
417fn 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}