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};
11
12pub fn generate_openapi_spec(
20 title: &str,
21 version: &str,
22 localizer: &dyn IStringLocalizer,
23) -> JsonValue {
24 let request_params = collect_request_params();
25 let mut paths = serde_json::Map::new();
26 let mut tags_set = BTreeSet::new();
27
28 for entry in inventory::iter::<RouteEntry> {
29 let entry = entry.clone();
30 let method = entry.method.as_str().to_lowercase();
31 let category = localizer.resolve(entry.category).value;
34 let tag = if category.is_empty() {
35 extract_tag(entry.path)
36 } else {
37 category
38 };
39 tags_set.insert(tag.clone());
40
41 let mut parameters = Vec::new();
42 let mut has_body = false;
43 let mut form_params: Vec<ParamMeta> = Vec::new();
44
45 for param in merge_params(entry.handler_type, entry.params, &request_params) {
46 match param.source {
47 "form" => form_params.push(param),
50 "body" => has_body = true,
51 _ => {
52 let required = param.source == "path";
53 parameters.push(json!({
54 "name": param.name,
55 "in": param.source,
56 "required": required,
57 "schema": { "type": param.type_hint },
58 }));
59 }
60 }
61 }
62
63 let operation_id = to_operation_id(entry.handler_type);
64 let summary = localizer.resolve(entry.name).value;
65
66 let mut operation = json!({
67 "operationId": operation_id,
68 "summary": summary,
69 "responses": build_responses(entry.rsp_type),
70 "tags": [tag],
71 });
72
73 let description = localizer.resolve(entry.description).value;
74 if !description.is_empty() {
75 operation["description"] = json!(description);
76 }
77
78 if !parameters.is_empty() {
79 operation["parameters"] = json!(parameters);
80 }
81
82 if !form_params.is_empty() {
83 operation["requestBody"] = json!({
84 "required": true,
85 "content": {
86 "multipart/form-data": {
87 "schema": form_schema(&form_params),
88 }
89 }
90 });
91 } else if has_body {
92 operation["requestBody"] = json!({
93 "required": true,
94 "content": {
95 "application/json": {
96 "schema": {
97 "type": "object",
98 }
99 }
100 }
101 });
102 }
103
104 let path_entry = paths
105 .entry(entry.path.to_string())
106 .or_insert_with(|| json!({}));
107 if let Some(obj) = path_entry.as_object_mut() {
108 obj.insert(method, operation);
109 }
110 }
111
112 let tags: Vec<JsonValue> = tags_set.iter().map(|t| json!({"name": t})).collect();
113
114 json!({
115 "openapi": "3.0.3",
116 "info": {
117 "title": title,
118 "version": version,
119 },
120 "tags": tags,
121 "paths": paths,
122 "components": {
123 "securitySchemes": {
124 "bearerAuth": {
125 "type": "http",
126 "scheme": "bearer",
127 "bearerFormat": "JWT",
128 }
129 }
130 }
131 })
132}
133
134fn collect_request_params() -> HashMap<&'static str, &'static [ParamMeta]> {
135 let mut map = HashMap::new();
136 for entry in inventory::iter::<RequestParamEntry> {
137 map.insert(entry.request_type, entry.params);
138 }
139 map
140}
141
142fn merge_params(
144 request_type: &'static str,
145 route_params: &'static [ParamMeta],
146 request_params: &HashMap<&'static str, &'static [ParamMeta]>,
147) -> Vec<ParamMeta> {
148 let mut merged: Vec<ParamMeta> = route_params.to_vec();
149 let mut seen: HashMap<(&str, &str), ()> =
150 merged.iter().map(|p| ((p.name, p.source), ())).collect();
151
152 if let Some(extra) = request_params.get(request_type) {
153 for param in *extra {
154 let key = (param.name, param.source);
155 if seen.insert(key, ()).is_none() {
156 merged.push(param.clone());
157 }
158 }
159 }
160
161 merged
162}
163
164fn form_schema(params: &[ParamMeta]) -> JsonValue {
169 let mut properties = serde_json::Map::new();
170 let mut required = Vec::new();
171
172 for param in params {
173 let schema = match param.type_hint {
174 "binary" => json!({ "type": "string", "format": "binary" }),
175 "binary[]" => json!({
176 "type": "array",
177 "items": { "type": "string", "format": "binary" }
178 }),
179 other => json!({ "type": other }),
180 };
181 properties.insert(param.name.to_string(), schema);
182 required.push(json!(param.name));
183 }
184
185 json!({
186 "type": "object",
187 "properties": JsonValue::Object(properties),
188 "required": required,
189 })
190}
191
192fn build_responses(rsp_type: &str) -> JsonValue {
194 let schema = if rsp_type == "()" || rsp_type == "unknown" {
195 json!({ "type": "object" })
196 } else if rsp_type == "String" {
197 json!({ "type": "string" })
198 } else if rsp_type.starts_with("Vec<") {
199 let inner = &rsp_type[4..rsp_type.len() - 1];
200 json!({
201 "type": "array",
202 "items": { "$ref": format!("#/components/schemas/{}", inner) }
203 })
204 } else {
205 json!({ "$ref": format!("#/components/schemas/{}", rsp_type) })
206 };
207
208 json!({
209 "200": {
210 "description": "Successful response",
211 "content": {
212 "application/json": { "schema": schema }
213 }
214 },
215 "400": { "description": "Bad request — validation or deserialization error" },
216 "404": { "description": "Resource not found" },
217 "500": { "description": "Internal server error" },
218 })
219}
220
221fn extract_tag(path: &str) -> String {
228 let tag = path
229 .trim_start_matches('/')
230 .split('/')
231 .rfind(|s| !s.starts_with('{') && !s.is_empty())
232 .unwrap_or("default");
233 titlecase(tag)
234}
235
236fn to_operation_id(handler_type: &str) -> String {
239 let name = handler_type.strip_suffix("Request").unwrap_or(handler_type);
240 let mut chars = name.chars();
241 match chars.next() {
242 None => String::new(),
243 Some(c) => c.to_ascii_lowercase().to_string() + chars.as_str(),
244 }
245}
246
247fn titlecase(s: &str) -> String {
248 let mut chars = s.chars();
249 match chars.next() {
250 None => String::new(),
251 Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
252 }
253}