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