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!(
352 issues
353 .iter()
354 .any(|e| e.name == "repos_owner_repo_issues_get")
355 );
356 assert!(
357 issues
358 .iter()
359 .any(|e| e.name == "repos_owner_repo_issues_post")
360 );
361 }
362
363 #[test]
364 fn params_extracted() {
365 let endpoints = parse_spec(&sample_spec());
366 let user_by_id = endpoints.iter().find(|e| e.path == "/users/{id}").unwrap();
367 assert_eq!(user_by_id.params.len(), 1);
368 assert_eq!(user_by_id.params[0].name, "id");
369 assert_eq!(user_by_id.params[0].location, ParamLocation::Path);
370 assert!(user_by_id.params[0].required);
371 }
372
373 #[test]
374 fn description_from_summary_or_description() {
375 let endpoints = parse_spec(&sample_spec());
376 let list_users = endpoints.iter().find(|e| e.name == "users_get").unwrap();
377 assert_eq!(list_users.description, "List users");
378
379 let create_issue = endpoints
380 .iter()
381 .find(|e| e.name == "repos_owner_repo_issues_post")
382 .unwrap();
383 assert_eq!(create_issue.description, "Create an issue");
384 }
385
386 #[test]
387 fn path_to_name_strips_braces() {
388 assert_eq!(path_to_command_name("/a/{b}/c"), "a_b_c");
389 assert_eq!(path_to_command_name("/"), "");
390 assert_eq!(path_to_command_name("/simple"), "simple");
391 }
392
393 #[test]
394 fn filter_exclude() {
395 let endpoints = parse_spec(&sample_spec());
396 let filtered = filter_endpoints(endpoints, &[], &["post:/users".to_string()]);
397 assert!(!filtered.iter().any(|e| e.name == "users_post"));
398 assert!(filtered.iter().any(|e| e.name == "users_get"));
399 }
400
401 #[test]
402 fn filter_include() {
403 let endpoints = parse_spec(&sample_spec());
404 let filtered = filter_endpoints(endpoints, &["get:/users".to_string()], &[]);
405 assert_eq!(filtered.len(), 1);
406 assert_eq!(filtered[0].name, "users_get");
407 }
408
409 #[test]
410 fn empty_spec_returns_empty() {
411 let endpoints = parse_spec(&json!({}));
412 assert!(endpoints.is_empty());
413 }
414
415 #[test]
416 fn header_params_skipped() {
417 let spec = json!({
418 "paths": {
419 "/test": {
420 "get": {
421 "parameters": [
422 { "name": "X-Token", "in": "header", "schema": { "type": "string" } },
423 { "name": "q", "in": "query", "schema": { "type": "string" } }
424 ]
425 }
426 }
427 }
428 });
429 let endpoints = parse_spec(&spec);
430 assert_eq!(endpoints[0].params.len(), 1);
431 assert_eq!(endpoints[0].params[0].name, "q");
432 }
433
434 #[test]
435 fn ref_params_resolved() {
436 let spec = json!({
437 "components": {
438 "parameters": {
439 "owner": {
440 "name": "owner",
441 "in": "path",
442 "required": true,
443 "schema": { "type": "string" },
444 "description": "The account owner"
445 },
446 "repo": {
447 "name": "repo",
448 "in": "path",
449 "required": true,
450 "schema": { "type": "string" },
451 "description": "The repository name"
452 },
453 "per_page": {
454 "name": "per_page",
455 "in": "query",
456 "schema": { "type": "integer" },
457 "description": "Results per page (max 100)"
458 }
459 }
460 },
461 "paths": {
462 "/repos/{owner}/{repo}": {
463 "get": {
464 "summary": "Get a repository",
465 "parameters": [
466 { "$ref": "#/components/parameters/owner" },
467 { "$ref": "#/components/parameters/repo" },
468 { "$ref": "#/components/parameters/per_page" }
469 ]
470 }
471 }
472 }
473 });
474 let endpoints = parse_spec(&spec);
475 assert_eq!(endpoints.len(), 1);
476 assert_eq!(endpoints[0].params.len(), 3);
477 assert_eq!(endpoints[0].params[0].name, "owner");
478 assert_eq!(endpoints[0].params[0].description, "The account owner");
479 assert!(endpoints[0].params[0].required);
480 assert_eq!(endpoints[0].params[1].name, "repo");
481 assert_eq!(endpoints[0].params[2].name, "per_page");
482 assert_eq!(endpoints[0].params[2].param_type, "integer");
483 }
484
485 #[test]
486 fn path_level_params_merged() {
487 let spec = json!({
488 "paths": {
489 "/repos/{owner}/{repo}/issues": {
490 "parameters": [
491 { "name": "owner", "in": "path", "required": true, "schema": { "type": "string" } },
492 { "name": "repo", "in": "path", "required": true, "schema": { "type": "string" } }
493 ],
494 "get": {
495 "summary": "List issues",
496 "parameters": [
497 { "name": "state", "in": "query", "schema": { "type": "string" } }
498 ]
499 },
500 "post": {
501 "summary": "Create issue"
502 }
503 }
504 }
505 });
506 let endpoints = parse_spec(&spec);
507 let get = endpoints.iter().find(|e| e.method == "GET").unwrap();
508 assert_eq!(get.params.len(), 3);
510
511 let post = endpoints.iter().find(|e| e.method == "POST").unwrap();
512 assert_eq!(post.params.len(), 2);
514 assert_eq!(post.params[0].name, "owner");
515 }
516
517 #[test]
518 fn operation_params_override_path_params() {
519 let spec = json!({
520 "paths": {
521 "/items/{id}": {
522 "parameters": [
523 { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "generic" }
524 ],
525 "get": {
526 "parameters": [
527 { "name": "id", "in": "path", "required": true, "schema": { "type": "string" }, "description": "overridden" }
528 ]
529 }
530 }
531 }
532 });
533 let endpoints = parse_spec(&spec);
534 assert_eq!(endpoints[0].params.len(), 1);
535 assert_eq!(endpoints[0].params[0].description, "overridden");
536 assert_eq!(endpoints[0].params[0].param_type, "string");
537 }
538
539 #[test]
540 fn resolve_ref_basic() {
541 let root = json!({
542 "components": {
543 "parameters": {
544 "foo": { "name": "foo", "in": "query" }
545 }
546 }
547 });
548 let resolved = resolve_ref(&root, "#/components/parameters/foo");
549 assert!(resolved.is_some());
550 assert_eq!(resolved.unwrap().get("name").unwrap().as_str(), Some("foo"));
551 }
552
553 #[test]
554 fn resolve_ref_missing() {
555 let root = json!({});
556 assert!(resolve_ref(&root, "#/components/parameters/missing").is_none());
557 }
558
559 #[test]
560 fn path_params_implicitly_required() {
561 let spec = json!({
562 "paths": {
563 "/items/{id}": {
564 "get": {
565 "parameters": [
566 { "name": "id", "in": "path", "schema": { "type": "integer" } }
567 ]
568 }
569 }
570 }
571 });
572 let endpoints = parse_spec(&spec);
573 assert!(endpoints[0].params[0].required);
575 }
576}