1use serde_json::Value;
7
8#[derive(Debug, Clone)]
10pub struct Endpoint {
11 pub name: String,
13 pub method: String,
15 pub path: String,
17 pub description: String,
19 pub params: Vec<Param>,
21}
22
23#[derive(Debug, Clone)]
25pub struct Param {
26 pub name: String,
27 pub location: ParamLocation,
29 pub required: bool,
30 pub param_type: String,
31 pub description: String,
32}
33
34#[derive(Debug, Clone, PartialEq)]
35pub enum ParamLocation {
36 Path,
37 Query,
38}
39
40pub fn parse_spec(spec: &Value) -> Vec<Endpoint> {
45 let paths = match spec.get("paths").and_then(|p| p.as_object()) {
46 Some(p) => p,
47 None => return Vec::new(),
48 };
49
50 let methods = ["get", "post", "put", "delete", "patch", "head", "options"];
51
52 let mut path_method_count: std::collections::HashMap<&str, usize> =
54 std::collections::HashMap::new();
55 for (path, item) in paths {
56 let item_obj = match item.as_object() {
57 Some(o) => o,
58 None => continue,
59 };
60 let count = methods
61 .iter()
62 .filter(|m| item_obj.contains_key(**m))
63 .count();
64 path_method_count.insert(path.as_str(), count);
65 }
66
67 let mut endpoints = Vec::new();
68
69 for (path, item) in paths {
70 let item_obj = match item.as_object() {
71 Some(o) => o,
72 None => continue,
73 };
74
75 let multiple_methods = path_method_count.get(path.as_str()).copied().unwrap_or(0) > 1;
76
77 let path_params = item_obj
79 .get("parameters")
80 .map(|p| extract_params_with_refs(p, spec))
81 .unwrap_or_default();
82
83 for method in &methods {
84 let operation = match item_obj.get(*method) {
85 Some(op) => op,
86 None => continue,
87 };
88
89 let base_name = path_to_command_name(path);
90 let name = if multiple_methods {
91 format!("{}_{}", base_name, method)
92 } else {
93 base_name
94 };
95
96 let description = operation
97 .get("summary")
98 .or_else(|| operation.get("description"))
99 .and_then(|v| v.as_str())
100 .unwrap_or("")
101 .to_string();
102
103 let op_params =
105 extract_params_with_refs(operation.get("parameters").unwrap_or(&Value::Null), spec);
106 let params = merge_params(&path_params, &op_params);
107
108 endpoints.push(Endpoint {
109 name,
110 method: method.to_uppercase(),
111 path: path.clone(),
112 description,
113 params,
114 });
115 }
116 }
117
118 endpoints
119}
120
121fn path_to_command_name(path: &str) -> String {
123 path.split('/')
124 .filter(|s| !s.is_empty())
125 .map(|s| {
126 if s.starts_with('{') && s.ends_with('}') {
127 &s[1..s.len() - 1]
128 } else {
129 s
130 }
131 })
132 .collect::<Vec<_>>()
133 .join("_")
134}
135
136fn extract_params_with_refs(params_val: &Value, root: &Value) -> Vec<Param> {
138 let params_arr = match params_val.as_array() {
139 Some(a) => a,
140 None => return Vec::new(),
141 };
142
143 params_arr
144 .iter()
145 .filter_map(|p| {
146 let resolved = if let Some(ref_str) = p.get("$ref").and_then(|r| r.as_str()) {
148 resolve_ref(root, ref_str)?
149 } else {
150 p
151 };
152
153 let name = resolved.get("name")?.as_str()?.to_string();
154 let location_str = resolved.get("in")?.as_str()?;
155 let location = match location_str {
156 "path" => ParamLocation::Path,
157 "query" => ParamLocation::Query,
158 _ => return None, };
160 let required = resolved
161 .get("required")
162 .and_then(|r| r.as_bool())
163 .unwrap_or(location == ParamLocation::Path); let param_type = resolved
165 .get("schema")
166 .and_then(|s| {
167 if let Some(sr) = s.get("$ref").and_then(|r| r.as_str()) {
169 resolve_ref(root, sr)
170 .and_then(|rs| rs.get("type"))
171 .and_then(|t| t.as_str())
172 } else {
173 s.get("type").and_then(|t| t.as_str())
174 }
175 })
176 .unwrap_or("string")
177 .to_string();
178 let description = resolved
179 .get("description")
180 .and_then(|d| d.as_str())
181 .unwrap_or("")
182 .to_string();
183
184 Some(Param {
185 name,
186 location,
187 required,
188 param_type,
189 description,
190 })
191 })
192 .collect()
193}
194
195fn resolve_ref<'a>(root: &'a Value, ref_str: &str) -> Option<&'a Value> {
197 let path = ref_str.strip_prefix("#/")?;
198 let mut current = root;
199 for segment in path.split('/') {
200 let unescaped = segment.replace("~1", "/").replace("~0", "~");
202 current = current.get(&unescaped)?;
203 }
204 Some(current)
205}
206
207fn merge_params(path_params: &[Param], op_params: &[Param]) -> Vec<Param> {
210 let mut result: Vec<Param> = Vec::new();
211
212 for pp in path_params {
214 let overridden = op_params
216 .iter()
217 .any(|op| op.name == pp.name && op.location == pp.location);
218 if !overridden {
219 result.push(pp.clone());
220 }
221 }
222
223 result.extend(op_params.iter().cloned());
225 result
226}
227
228pub fn filter_endpoints(
231 endpoints: Vec<Endpoint>,
232 include: &[String],
233 exclude: &[String],
234) -> Vec<Endpoint> {
235 let exclude_set: std::collections::HashSet<&str> = exclude.iter().map(|s| s.as_str()).collect();
236
237 endpoints
238 .into_iter()
239 .filter(|ep| {
240 let key = format!("{}:{}", ep.method.to_lowercase(), ep.path);
241 if exclude_set.contains(key.as_str()) {
242 return false;
243 }
244 if include.is_empty() {
245 return true;
246 }
247 include.iter().any(|i| i == &key)
248 })
249 .collect()
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255 use serde_json::json;
256
257 fn sample_spec() -> Value {
258 json!({
259 "openapi": "3.0.0",
260 "info": { "title": "Test API", "version": "1.0" },
261 "paths": {
262 "/users": {
263 "get": {
264 "summary": "List users",
265 "parameters": [
266 {
267 "name": "page",
268 "in": "query",
269 "required": false,
270 "schema": { "type": "integer" },
271 "description": "Page number"
272 },
273 {
274 "name": "limit",
275 "in": "query",
276 "schema": { "type": "integer" }
277 }
278 ]
279 },
280 "post": {
281 "summary": "Create user",
282 "parameters": []
283 }
284 },
285 "/users/{id}": {
286 "get": {
287 "summary": "Get user by ID",
288 "parameters": [
289 {
290 "name": "id",
291 "in": "path",
292 "required": true,
293 "schema": { "type": "integer" },
294 "description": "User ID"
295 }
296 ]
297 }
298 },
299 "/repos/{owner}/{repo}/issues": {
300 "get": {
301 "summary": "List issues",
302 "parameters": [
303 { "name": "owner", "in": "path", "required": true, "schema": { "type": "string" } },
304 { "name": "repo", "in": "path", "required": true, "schema": { "type": "string" } },
305 { "name": "state", "in": "query", "schema": { "type": "string" }, "description": "open/closed/all" }
306 ]
307 },
308 "post": {
309 "description": "Create an issue",
310 "parameters": [
311 { "name": "owner", "in": "path", "required": true, "schema": { "type": "string" } },
312 { "name": "repo", "in": "path", "required": true, "schema": { "type": "string" } }
313 ]
314 }
315 }
316 }
317 })
318 }
319
320 #[test]
321 fn parse_extracts_all_endpoints() {
322 let endpoints = parse_spec(&sample_spec());
323 assert_eq!(endpoints.len(), 5);
324 }
325
326 #[test]
327 fn single_method_path_no_suffix() {
328 let endpoints = parse_spec(&sample_spec());
329 let user_by_id = endpoints.iter().find(|e| e.path == "/users/{id}").unwrap();
330 assert_eq!(user_by_id.name, "users_id");
331 assert_eq!(user_by_id.method, "GET");
332 }
333
334 #[test]
335 fn multiple_methods_get_suffix() {
336 let endpoints = parse_spec(&sample_spec());
337 let users: Vec<_> = endpoints.iter().filter(|e| e.path == "/users").collect();
338 assert_eq!(users.len(), 2);
339 let names: Vec<&str> = users.iter().map(|e| e.name.as_str()).collect();
340 assert!(names.contains(&"users_get"));
341 assert!(names.contains(&"users_post"));
342 }
343
344 #[test]
345 fn nested_path_command_name() {
346 let endpoints = parse_spec(&sample_spec());
347 let issues: Vec<_> = endpoints
348 .iter()
349 .filter(|e| e.path == "/repos/{owner}/{repo}/issues")
350 .collect();
351 assert!(issues
352 .iter()
353 .any(|e| e.name == "repos_owner_repo_issues_get"));
354 assert!(issues
355 .iter()
356 .any(|e| e.name == "repos_owner_repo_issues_post"));
357 }
358
359 #[test]
360 fn params_extracted() {
361 let endpoints = parse_spec(&sample_spec());
362 let user_by_id = endpoints.iter().find(|e| e.path == "/users/{id}").unwrap();
363 assert_eq!(user_by_id.params.len(), 1);
364 assert_eq!(user_by_id.params[0].name, "id");
365 assert_eq!(user_by_id.params[0].location, ParamLocation::Path);
366 assert!(user_by_id.params[0].required);
367 }
368
369 #[test]
370 fn description_from_summary_or_description() {
371 let endpoints = parse_spec(&sample_spec());
372 let list_users = endpoints.iter().find(|e| e.name == "users_get").unwrap();
373 assert_eq!(list_users.description, "List users");
374
375 let create_issue = endpoints
376 .iter()
377 .find(|e| e.name == "repos_owner_repo_issues_post")
378 .unwrap();
379 assert_eq!(create_issue.description, "Create an issue");
380 }
381
382 #[test]
383 fn path_to_name_strips_braces() {
384 assert_eq!(path_to_command_name("/a/{b}/c"), "a_b_c");
385 assert_eq!(path_to_command_name("/"), "");
386 assert_eq!(path_to_command_name("/simple"), "simple");
387 }
388
389 #[test]
390 fn filter_exclude() {
391 let endpoints = parse_spec(&sample_spec());
392 let filtered = filter_endpoints(endpoints, &[], &["post:/users".to_string()]);
393 assert!(!filtered.iter().any(|e| e.name == "users_post"));
394 assert!(filtered.iter().any(|e| e.name == "users_get"));
395 }
396
397 #[test]
398 fn filter_include() {
399 let endpoints = parse_spec(&sample_spec());
400 let filtered = filter_endpoints(endpoints, &["get:/users".to_string()], &[]);
401 assert_eq!(filtered.len(), 1);
402 assert_eq!(filtered[0].name, "users_get");
403 }
404
405 #[test]
406 fn empty_spec_returns_empty() {
407 let endpoints = parse_spec(&json!({}));
408 assert!(endpoints.is_empty());
409 }
410
411 #[test]
412 fn header_params_skipped() {
413 let spec = json!({
414 "paths": {
415 "/test": {
416 "get": {
417 "parameters": [
418 { "name": "X-Token", "in": "header", "schema": { "type": "string" } },
419 { "name": "q", "in": "query", "schema": { "type": "string" } }
420 ]
421 }
422 }
423 }
424 });
425 let endpoints = parse_spec(&spec);
426 assert_eq!(endpoints[0].params.len(), 1);
427 assert_eq!(endpoints[0].params[0].name, "q");
428 }
429
430 #[test]
431 fn ref_params_resolved() {
432 let spec = json!({
433 "components": {
434 "parameters": {
435 "owner": {
436 "name": "owner",
437 "in": "path",
438 "required": true,
439 "schema": { "type": "string" },
440 "description": "The account owner"
441 },
442 "repo": {
443 "name": "repo",
444 "in": "path",
445 "required": true,
446 "schema": { "type": "string" },
447 "description": "The repository name"
448 },
449 "per_page": {
450 "name": "per_page",
451 "in": "query",
452 "schema": { "type": "integer" },
453 "description": "Results per page (max 100)"
454 }
455 }
456 },
457 "paths": {
458 "/repos/{owner}/{repo}": {
459 "get": {
460 "summary": "Get a repository",
461 "parameters": [
462 { "$ref": "#/components/parameters/owner" },
463 { "$ref": "#/components/parameters/repo" },
464 { "$ref": "#/components/parameters/per_page" }
465 ]
466 }
467 }
468 }
469 });
470 let endpoints = parse_spec(&spec);
471 assert_eq!(endpoints.len(), 1);
472 assert_eq!(endpoints[0].params.len(), 3);
473 assert_eq!(endpoints[0].params[0].name, "owner");
474 assert_eq!(endpoints[0].params[0].description, "The account owner");
475 assert!(endpoints[0].params[0].required);
476 assert_eq!(endpoints[0].params[1].name, "repo");
477 assert_eq!(endpoints[0].params[2].name, "per_page");
478 assert_eq!(endpoints[0].params[2].param_type, "integer");
479 }
480
481 #[test]
482 fn path_level_params_merged() {
483 let spec = json!({
484 "paths": {
485 "/repos/{owner}/{repo}/issues": {
486 "parameters": [
487 { "name": "owner", "in": "path", "required": true, "schema": { "type": "string" } },
488 { "name": "repo", "in": "path", "required": true, "schema": { "type": "string" } }
489 ],
490 "get": {
491 "summary": "List issues",
492 "parameters": [
493 { "name": "state", "in": "query", "schema": { "type": "string" } }
494 ]
495 },
496 "post": {
497 "summary": "Create issue"
498 }
499 }
500 }
501 });
502 let endpoints = parse_spec(&spec);
503 let get = endpoints.iter().find(|e| e.method == "GET").unwrap();
504 assert_eq!(get.params.len(), 3);
506
507 let post = endpoints.iter().find(|e| e.method == "POST").unwrap();
508 assert_eq!(post.params.len(), 2);
510 assert_eq!(post.params[0].name, "owner");
511 }
512
513 #[test]
514 fn operation_params_override_path_params() {
515 let spec = json!({
516 "paths": {
517 "/items/{id}": {
518 "parameters": [
519 { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "generic" }
520 ],
521 "get": {
522 "parameters": [
523 { "name": "id", "in": "path", "required": true, "schema": { "type": "string" }, "description": "overridden" }
524 ]
525 }
526 }
527 }
528 });
529 let endpoints = parse_spec(&spec);
530 assert_eq!(endpoints[0].params.len(), 1);
531 assert_eq!(endpoints[0].params[0].description, "overridden");
532 assert_eq!(endpoints[0].params[0].param_type, "string");
533 }
534
535 #[test]
536 fn resolve_ref_basic() {
537 let root = json!({
538 "components": {
539 "parameters": {
540 "foo": { "name": "foo", "in": "query" }
541 }
542 }
543 });
544 let resolved = resolve_ref(&root, "#/components/parameters/foo");
545 assert!(resolved.is_some());
546 assert_eq!(resolved.unwrap().get("name").unwrap().as_str(), Some("foo"));
547 }
548
549 #[test]
550 fn resolve_ref_missing() {
551 let root = json!({});
552 assert!(resolve_ref(&root, "#/components/parameters/missing").is_none());
553 }
554
555 #[test]
556 fn path_params_implicitly_required() {
557 let spec = json!({
558 "paths": {
559 "/items/{id}": {
560 "get": {
561 "parameters": [
562 { "name": "id", "in": "path", "schema": { "type": "integer" } }
563 ]
564 }
565 }
566 }
567 });
568 let endpoints = parse_spec(&spec);
569 assert!(endpoints[0].params[0].required);
571 }
572}