1use std::collections::HashMap;
7
8use serde_json::Value;
9
10#[derive(Debug, Clone)]
12#[non_exhaustive]
13pub struct ApiOperation {
14 pub operation_id: String,
16 pub method: String,
18 pub path: String,
20 pub group: String,
22 pub summary: String,
24 pub path_params: Vec<Param>,
26 pub query_params: Vec<Param>,
28 pub header_params: Vec<Param>,
30 pub body_schema: Option<Value>,
32 pub body_required: bool,
34}
35
36#[derive(Debug, Clone)]
38#[non_exhaustive]
39pub struct Param {
40 pub name: String,
41 pub description: String,
42 pub required: bool,
43 pub schema: Value,
44}
45
46pub fn extract_operations(spec: &Value) -> Vec<ApiOperation> {
48 let mut ops = Vec::new();
49
50 let paths = match spec.get("paths").and_then(|p| p.as_object()) {
51 Some(p) => p,
52 None => return ops,
53 };
54
55 for (path, path_item) in paths {
56 let path_level_params = path_item.get("parameters");
57
58 for method in &[
59 "get", "post", "put", "patch", "delete", "head", "options", "trace",
60 ] {
61 let operation = match path_item.get(*method) {
62 Some(op) => op,
63 None => continue,
64 };
65
66 if let Some(op) = extract_single_operation(path, method, operation, path_level_params) {
67 ops.push(op);
68 }
69 }
70 }
71
72 ops
73}
74
75fn extract_single_operation(
76 path: &str,
77 method: &str,
78 operation: &Value,
79 path_level_params: Option<&Value>,
80) -> Option<ApiOperation> {
81 let operation_id = operation
82 .get("operationId")
83 .and_then(|v| v.as_str())
84 .unwrap_or("");
85
86 if operation_id.is_empty() {
87 return None;
88 }
89
90 let summary = operation
91 .get("summary")
92 .or_else(|| operation.get("description"))
93 .and_then(|v| v.as_str())
94 .unwrap_or("")
95 .to_string();
96
97 let group = operation
98 .get("tags")
99 .and_then(|v| v.as_array())
100 .and_then(|arr| arr.first())
101 .and_then(|v| v.as_str())
102 .unwrap_or("other")
103 .to_string();
104
105 let (mut path_params, query_params, header_params) =
106 collect_params(path_level_params, operation.get("parameters"));
107
108 path_params.sort_by_cached_key(|p| path.find(&format!("{{{}}}", p.name)).unwrap_or(usize::MAX));
110
111 let (body_schema, body_required) = extract_body(operation);
112
113 Some(ApiOperation {
114 operation_id: operation_id.to_string(),
115 method: method.to_uppercase(),
116 path: path.to_string(),
117 summary,
118 group,
119 path_params,
120 query_params,
121 header_params,
122 body_schema,
123 body_required,
124 })
125}
126
127fn collect_params(
130 path_level: Option<&Value>,
131 operation_level: Option<&Value>,
132) -> (Vec<Param>, Vec<Param>, Vec<Param>) {
133 let mut param_map: HashMap<(String, String), Param> = HashMap::new();
134
135 for source in [path_level, operation_level].iter().flatten() {
136 if let Some(params) = source.as_array() {
137 for param in params {
138 if let Some((p, location)) = parse_param(param) {
139 param_map.insert((p.name.clone(), location), p);
140 }
141 }
142 }
143 }
144
145 let mut path_params = Vec::new();
146 let mut query_params = Vec::new();
147 let mut header_params = Vec::new();
148
149 for ((_, location), p) in param_map {
150 match location.as_str() {
151 "path" => path_params.push(p),
152 "query" => query_params.push(p),
153 "header" => header_params.push(p),
154 _ => {}
155 }
156 }
157
158 query_params.sort_by(|a, b| a.name.cmp(&b.name));
159 header_params.sort_by(|a, b| a.name.cmp(&b.name));
160
161 (path_params, query_params, header_params)
162}
163
164fn extract_body(operation: &Value) -> (Option<Value>, bool) {
165 let request_body = operation.get("requestBody");
166 let body_required = request_body
167 .and_then(|rb| rb.get("required"))
168 .and_then(|v| v.as_bool())
169 .unwrap_or(false);
170 let body_schema = request_body
171 .and_then(|rb| rb.get("content"))
172 .and_then(|c| c.get("application/json"))
173 .and_then(|ct| ct.get("schema"))
174 .cloned();
175
176 (body_schema, body_required)
177}
178
179pub fn is_bool_schema(schema: &Value) -> bool {
181 schema.get("type").and_then(|v| v.as_str()) == Some("boolean")
182}
183
184fn parse_param(param: &Value) -> Option<(Param, String)> {
186 let name = param.get("name")?.as_str()?.to_string();
187 let location = param.get("in")?.as_str()?.to_string();
188 let description = param
189 .get("description")
190 .and_then(|v| v.as_str())
191 .unwrap_or("")
192 .to_string();
193 let required = param
194 .get("required")
195 .and_then(|v| v.as_bool())
196 .unwrap_or(false);
197 let schema = param
198 .get("schema")
199 .cloned()
200 .unwrap_or(serde_json::json!({"type": "string"}));
201
202 Some((
203 Param {
204 name,
205 description,
206 required,
207 schema,
208 },
209 location,
210 ))
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216 use serde_json::json;
217
218 #[test]
219 fn extract_operations_valid_spec_with_get_and_post() {
220 let spec = json!({
221 "openapi": "3.0.0",
222 "paths": {
223 "/pods/{podId}": {
224 "get": {
225 "operationId": "GetPod",
226 "summary": "Get a pod",
227 "tags": ["Pods"],
228 "parameters": [
229 {
230 "name": "podId",
231 "in": "path",
232 "required": true,
233 "description": "Pod identifier",
234 "schema": { "type": "string" }
235 },
236 {
237 "name": "verbose",
238 "in": "query",
239 "required": false,
240 "description": "Verbose output",
241 "schema": { "type": "boolean" }
242 },
243 {
244 "name": "X-Request-Id",
245 "in": "header",
246 "required": false,
247 "description": "Request tracking ID",
248 "schema": { "type": "string" }
249 }
250 ]
251 },
252 "post": {
253 "operationId": "CreatePod",
254 "summary": "Create a pod",
255 "tags": ["Pods"],
256 "requestBody": {
257 "required": true,
258 "content": {
259 "application/json": {
260 "schema": {
261 "type": "object",
262 "properties": {
263 "name": { "type": "string" }
264 }
265 }
266 }
267 }
268 }
269 }
270 }
271 }
272 });
273
274 let ops = extract_operations(&spec);
275 assert_eq!(ops.len(), 2);
276
277 let get_op = ops.iter().find(|o| o.operation_id == "GetPod").unwrap();
278 assert_eq!(get_op.method, "GET");
279 assert_eq!(get_op.path, "/pods/{podId}");
280 assert_eq!(get_op.group, "Pods");
281 assert_eq!(get_op.summary, "Get a pod");
282 assert_eq!(get_op.path_params.len(), 1);
283 assert_eq!(get_op.path_params[0].name, "podId");
284 assert!(get_op.path_params[0].required);
285 assert_eq!(get_op.path_params[0].description, "Pod identifier");
286 assert_eq!(get_op.query_params.len(), 1);
287 assert_eq!(get_op.query_params[0].name, "verbose");
288 assert!(!get_op.query_params[0].required);
289 assert_eq!(get_op.header_params.len(), 1);
290 assert_eq!(get_op.header_params[0].name, "X-Request-Id");
291 assert!(get_op.body_schema.is_none());
292 assert!(!get_op.body_required);
293
294 let post_op = ops.iter().find(|o| o.operation_id == "CreatePod").unwrap();
295 assert_eq!(post_op.method, "POST");
296 assert_eq!(post_op.path, "/pods/{podId}");
297 assert_eq!(post_op.group, "Pods");
298 assert!(post_op.body_schema.is_some());
299 assert!(post_op.body_required);
300 assert!(post_op.path_params.is_empty());
301 assert!(post_op.query_params.is_empty());
302 }
303
304 #[test]
305 fn extract_operations_skips_operations_without_operation_id() {
306 let spec = json!({
307 "openapi": "3.0.0",
308 "paths": {
309 "/health": {
310 "get": {
311 "summary": "Health check"
312 }
313 },
314 "/pods": {
315 "get": {
316 "operationId": "ListPods",
317 "summary": "List pods",
318 "tags": ["Pods"]
319 }
320 }
321 }
322 });
323
324 let ops = extract_operations(&spec);
325 assert_eq!(ops.len(), 1);
326 assert_eq!(ops[0].operation_id, "ListPods");
327 }
328
329 #[test]
330 fn extract_operations_returns_empty_for_empty_paths() {
331 let spec = json!({
332 "openapi": "3.0.0",
333 "paths": {}
334 });
335
336 let ops = extract_operations(&spec);
337 assert!(ops.is_empty());
338 }
339
340 #[test]
341 fn extract_operations_returns_empty_when_no_paths_key() {
342 let spec = json!({
343 "openapi": "3.0.0"
344 });
345
346 let ops = extract_operations(&spec);
347 assert!(ops.is_empty());
348 }
349
350 #[test]
351 fn extract_operations_merges_path_and_operation_params_with_override() {
352 let spec = json!({
353 "openapi": "3.0.0",
354 "paths": {
355 "/items/{itemId}": {
356 "parameters": [
357 {
358 "name": "itemId",
359 "in": "path",
360 "required": true,
361 "description": "Path-level description",
362 "schema": { "type": "string" }
363 },
364 {
365 "name": "shared",
366 "in": "query",
367 "required": false,
368 "description": "Path-level shared param",
369 "schema": { "type": "string" }
370 }
371 ],
372 "get": {
373 "operationId": "GetItem",
374 "tags": ["Items"],
375 "parameters": [
376 {
377 "name": "shared",
378 "in": "query",
379 "required": true,
380 "description": "Operation-level override",
381 "schema": { "type": "integer" }
382 }
383 ]
384 }
385 }
386 }
387 });
388
389 let ops = extract_operations(&spec);
390 assert_eq!(ops.len(), 1);
391
392 let op = &ops[0];
393 assert_eq!(op.path_params.len(), 1);
395 assert_eq!(op.path_params[0].name, "itemId");
396 assert_eq!(op.path_params[0].description, "Path-level description");
397
398 assert_eq!(op.query_params.len(), 1);
400 assert_eq!(op.query_params[0].name, "shared");
401 assert_eq!(op.query_params[0].description, "Operation-level override");
402 assert!(op.query_params[0].required);
403 assert_eq!(op.query_params[0].schema, json!({ "type": "integer" }));
404 }
405
406 #[test]
407 fn extract_operations_uses_description_when_no_summary() {
408 let spec = json!({
409 "openapi": "3.0.0",
410 "paths": {
411 "/pods": {
412 "get": {
413 "operationId": "ListPods",
414 "description": "Fallback description",
415 "tags": ["Pods"]
416 }
417 }
418 }
419 });
420
421 let ops = extract_operations(&spec);
422 assert_eq!(ops[0].summary, "Fallback description");
423 }
424
425 #[test]
426 fn extract_operations_defaults_group_to_other() {
427 let spec = json!({
428 "openapi": "3.0.0",
429 "paths": {
430 "/untagged": {
431 "get": {
432 "operationId": "UntaggedOp",
433 "summary": "No tags"
434 }
435 }
436 }
437 });
438
439 let ops = extract_operations(&spec);
440 assert_eq!(ops[0].group, "other");
441 }
442
443 #[test]
444 fn is_bool_schema_returns_true_for_boolean() {
445 let schema = json!({ "type": "boolean" });
446 assert!(is_bool_schema(&schema));
447 }
448
449 #[test]
450 fn is_bool_schema_returns_false_for_string() {
451 let schema = json!({ "type": "string" });
452 assert!(!is_bool_schema(&schema));
453 }
454
455 #[test]
456 fn is_bool_schema_returns_false_for_no_type() {
457 let schema = json!({});
458 assert!(!is_bool_schema(&schema));
459 }
460}