Skip to main content

sgr_agent/openapi/
spec.rs

1//! OpenAPI spec parser — extract endpoints from JSON/YAML specs.
2//!
3//! Parses OpenAPI 3.x specs into a flat list of [`Endpoint`]s.
4//! Each endpoint = one HTTP method + path + parameters + description.
5
6use serde_json::Value;
7
8/// A single API endpoint extracted from an OpenAPI spec.
9#[derive(Debug, Clone)]
10pub struct Endpoint {
11    /// CLI-friendly name: `repos_owner_repo_issues_post`
12    pub name: String,
13    /// HTTP method (uppercase): GET, POST, PUT, DELETE, PATCH
14    pub method: String,
15    /// Path template: `/repos/{owner}/{repo}/issues`
16    pub path: String,
17    /// Human-readable description (from summary or description)
18    pub description: String,
19    /// Parameters (path + query)
20    pub params: Vec<Param>,
21}
22
23/// A single parameter for an endpoint.
24#[derive(Debug, Clone)]
25pub struct Param {
26    pub name: String,
27    /// "path" or "query"
28    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
40/// Parse an OpenAPI spec (as JSON Value) into a list of endpoints.
41///
42/// Supports OpenAPI 3.x format. Extracts paths, methods, parameters.
43/// Resolves `$ref` references for parameters and path items.
44pub 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    // Collect all path→methods mapping first (to decide if we need method suffix)
53    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        // Path-level parameters (shared across all methods on this path)
78        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            // Merge path-level + operation-level params (operation overrides path)
104            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
121/// Convert path `/repos/{owner}/{repo}/issues` → `repos_owner_repo_issues`
122fn 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
136/// Extract parameters from a Value, resolving `$ref` references.
137fn 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            // Resolve $ref if present
147            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, // skip header, cookie params
159            };
160            let required = resolved
161                .get("required")
162                .and_then(|r| r.as_bool())
163                .unwrap_or(location == ParamLocation::Path); // path params are implicitly required
164            let param_type = resolved
165                .get("schema")
166                .and_then(|s| {
167                    // Also resolve schema $ref
168                    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
195/// Resolve a JSON `$ref` pointer like `#/components/parameters/owner`.
196fn 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        // Handle JSON Pointer escaping: ~1 → /, ~0 → ~
201        let unescaped = segment.replace("~1", "/").replace("~0", "~");
202        current = current.get(&unescaped)?;
203    }
204    Some(current)
205}
206
207/// Merge path-level and operation-level params.
208/// Operation params override path params with the same name+location.
209fn merge_params(path_params: &[Param], op_params: &[Param]) -> Vec<Param> {
210    let mut result: Vec<Param> = Vec::new();
211
212    // Start with path-level params
213    for pp in path_params {
214        // Check if operation overrides this param
215        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    // Add all operation-level params
224    result.extend(op_params.iter().cloned());
225    result
226}
227
228/// Filter endpoints by include/exclude patterns.
229/// Patterns are `method:path` (e.g. `get:/repos/{owner}/{repo}`).
230pub 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        // GET should have owner, repo (from path-level) + state (from operation)
509        assert_eq!(get.params.len(), 3);
510
511        let post = endpoints.iter().find(|e| e.method == "POST").unwrap();
512        // POST should inherit owner, repo from path-level
513        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        // Path params without explicit "required" should be true
574        assert!(endpoints[0].params[0].required);
575    }
576}