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!(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        // GET should have owner, repo (from path-level) + state (from operation)
505        assert_eq!(get.params.len(), 3);
506
507        let post = endpoints.iter().find(|e| e.method == "POST").unwrap();
508        // POST should inherit owner, repo from path-level
509        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        // Path params without explicit "required" should be true
570        assert!(endpoints[0].params[0].required);
571    }
572}