use serde_json::Value;
#[derive(Debug, Clone)]
pub struct Endpoint {
pub name: String,
pub method: String,
pub path: String,
pub description: String,
pub params: Vec<Param>,
}
#[derive(Debug, Clone)]
pub struct Param {
pub name: String,
pub location: ParamLocation,
pub required: bool,
pub param_type: String,
pub description: String,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ParamLocation {
Path,
Query,
}
pub fn parse_spec(spec: &Value) -> Vec<Endpoint> {
let paths = match spec.get("paths").and_then(|p| p.as_object()) {
Some(p) => p,
None => return Vec::new(),
};
let methods = ["get", "post", "put", "delete", "patch", "head", "options"];
let mut path_method_count: std::collections::HashMap<&str, usize> =
std::collections::HashMap::new();
for (path, item) in paths {
let item_obj = match item.as_object() {
Some(o) => o,
None => continue,
};
let count = methods
.iter()
.filter(|m| item_obj.contains_key(**m))
.count();
path_method_count.insert(path.as_str(), count);
}
let mut endpoints = Vec::new();
for (path, item) in paths {
let item_obj = match item.as_object() {
Some(o) => o,
None => continue,
};
let multiple_methods = path_method_count.get(path.as_str()).copied().unwrap_or(0) > 1;
let path_params = item_obj
.get("parameters")
.map(|p| extract_params_with_refs(p, spec))
.unwrap_or_default();
for method in &methods {
let operation = match item_obj.get(*method) {
Some(op) => op,
None => continue,
};
let base_name = path_to_command_name(path);
let name = if multiple_methods {
format!("{}_{}", base_name, method)
} else {
base_name
};
let description = operation
.get("summary")
.or_else(|| operation.get("description"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let op_params =
extract_params_with_refs(operation.get("parameters").unwrap_or(&Value::Null), spec);
let params = merge_params(&path_params, &op_params);
endpoints.push(Endpoint {
name,
method: method.to_uppercase(),
path: path.clone(),
description,
params,
});
}
}
endpoints
}
fn path_to_command_name(path: &str) -> String {
path.split('/')
.filter(|s| !s.is_empty())
.map(|s| {
if s.starts_with('{') && s.ends_with('}') {
&s[1..s.len() - 1]
} else {
s
}
})
.collect::<Vec<_>>()
.join("_")
}
fn extract_params_with_refs(params_val: &Value, root: &Value) -> Vec<Param> {
let params_arr = match params_val.as_array() {
Some(a) => a,
None => return Vec::new(),
};
params_arr
.iter()
.filter_map(|p| {
let resolved = if let Some(ref_str) = p.get("$ref").and_then(|r| r.as_str()) {
resolve_ref(root, ref_str)?
} else {
p
};
let name = resolved.get("name")?.as_str()?.to_string();
let location_str = resolved.get("in")?.as_str()?;
let location = match location_str {
"path" => ParamLocation::Path,
"query" => ParamLocation::Query,
_ => return None, };
let required = resolved
.get("required")
.and_then(|r| r.as_bool())
.unwrap_or(location == ParamLocation::Path); let param_type = resolved
.get("schema")
.and_then(|s| {
if let Some(sr) = s.get("$ref").and_then(|r| r.as_str()) {
resolve_ref(root, sr)
.and_then(|rs| rs.get("type"))
.and_then(|t| t.as_str())
} else {
s.get("type").and_then(|t| t.as_str())
}
})
.unwrap_or("string")
.to_string();
let description = resolved
.get("description")
.and_then(|d| d.as_str())
.unwrap_or("")
.to_string();
Some(Param {
name,
location,
required,
param_type,
description,
})
})
.collect()
}
fn resolve_ref<'a>(root: &'a Value, ref_str: &str) -> Option<&'a Value> {
let path = ref_str.strip_prefix("#/")?;
let mut current = root;
for segment in path.split('/') {
let unescaped = segment.replace("~1", "/").replace("~0", "~");
current = current.get(&unescaped)?;
}
Some(current)
}
fn merge_params(path_params: &[Param], op_params: &[Param]) -> Vec<Param> {
let mut result: Vec<Param> = Vec::new();
for pp in path_params {
let overridden = op_params
.iter()
.any(|op| op.name == pp.name && op.location == pp.location);
if !overridden {
result.push(pp.clone());
}
}
result.extend(op_params.iter().cloned());
result
}
pub fn filter_endpoints(
endpoints: Vec<Endpoint>,
include: &[String],
exclude: &[String],
) -> Vec<Endpoint> {
let exclude_set: std::collections::HashSet<&str> = exclude.iter().map(|s| s.as_str()).collect();
endpoints
.into_iter()
.filter(|ep| {
let key = format!("{}:{}", ep.method.to_lowercase(), ep.path);
if exclude_set.contains(key.as_str()) {
return false;
}
if include.is_empty() {
return true;
}
include.iter().any(|i| i == &key)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn sample_spec() -> Value {
json!({
"openapi": "3.0.0",
"info": { "title": "Test API", "version": "1.0" },
"paths": {
"/users": {
"get": {
"summary": "List users",
"parameters": [
{
"name": "page",
"in": "query",
"required": false,
"schema": { "type": "integer" },
"description": "Page number"
},
{
"name": "limit",
"in": "query",
"schema": { "type": "integer" }
}
]
},
"post": {
"summary": "Create user",
"parameters": []
}
},
"/users/{id}": {
"get": {
"summary": "Get user by ID",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": { "type": "integer" },
"description": "User ID"
}
]
}
},
"/repos/{owner}/{repo}/issues": {
"get": {
"summary": "List issues",
"parameters": [
{ "name": "owner", "in": "path", "required": true, "schema": { "type": "string" } },
{ "name": "repo", "in": "path", "required": true, "schema": { "type": "string" } },
{ "name": "state", "in": "query", "schema": { "type": "string" }, "description": "open/closed/all" }
]
},
"post": {
"description": "Create an issue",
"parameters": [
{ "name": "owner", "in": "path", "required": true, "schema": { "type": "string" } },
{ "name": "repo", "in": "path", "required": true, "schema": { "type": "string" } }
]
}
}
}
})
}
#[test]
fn parse_extracts_all_endpoints() {
let endpoints = parse_spec(&sample_spec());
assert_eq!(endpoints.len(), 5);
}
#[test]
fn single_method_path_no_suffix() {
let endpoints = parse_spec(&sample_spec());
let user_by_id = endpoints.iter().find(|e| e.path == "/users/{id}").unwrap();
assert_eq!(user_by_id.name, "users_id");
assert_eq!(user_by_id.method, "GET");
}
#[test]
fn multiple_methods_get_suffix() {
let endpoints = parse_spec(&sample_spec());
let users: Vec<_> = endpoints.iter().filter(|e| e.path == "/users").collect();
assert_eq!(users.len(), 2);
let names: Vec<&str> = users.iter().map(|e| e.name.as_str()).collect();
assert!(names.contains(&"users_get"));
assert!(names.contains(&"users_post"));
}
#[test]
fn nested_path_command_name() {
let endpoints = parse_spec(&sample_spec());
let issues: Vec<_> = endpoints
.iter()
.filter(|e| e.path == "/repos/{owner}/{repo}/issues")
.collect();
assert!(
issues
.iter()
.any(|e| e.name == "repos_owner_repo_issues_get")
);
assert!(
issues
.iter()
.any(|e| e.name == "repos_owner_repo_issues_post")
);
}
#[test]
fn params_extracted() {
let endpoints = parse_spec(&sample_spec());
let user_by_id = endpoints.iter().find(|e| e.path == "/users/{id}").unwrap();
assert_eq!(user_by_id.params.len(), 1);
assert_eq!(user_by_id.params[0].name, "id");
assert_eq!(user_by_id.params[0].location, ParamLocation::Path);
assert!(user_by_id.params[0].required);
}
#[test]
fn description_from_summary_or_description() {
let endpoints = parse_spec(&sample_spec());
let list_users = endpoints.iter().find(|e| e.name == "users_get").unwrap();
assert_eq!(list_users.description, "List users");
let create_issue = endpoints
.iter()
.find(|e| e.name == "repos_owner_repo_issues_post")
.unwrap();
assert_eq!(create_issue.description, "Create an issue");
}
#[test]
fn path_to_name_strips_braces() {
assert_eq!(path_to_command_name("/a/{b}/c"), "a_b_c");
assert_eq!(path_to_command_name("/"), "");
assert_eq!(path_to_command_name("/simple"), "simple");
}
#[test]
fn filter_exclude() {
let endpoints = parse_spec(&sample_spec());
let filtered = filter_endpoints(endpoints, &[], &["post:/users".to_string()]);
assert!(!filtered.iter().any(|e| e.name == "users_post"));
assert!(filtered.iter().any(|e| e.name == "users_get"));
}
#[test]
fn filter_include() {
let endpoints = parse_spec(&sample_spec());
let filtered = filter_endpoints(endpoints, &["get:/users".to_string()], &[]);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].name, "users_get");
}
#[test]
fn empty_spec_returns_empty() {
let endpoints = parse_spec(&json!({}));
assert!(endpoints.is_empty());
}
#[test]
fn header_params_skipped() {
let spec = json!({
"paths": {
"/test": {
"get": {
"parameters": [
{ "name": "X-Token", "in": "header", "schema": { "type": "string" } },
{ "name": "q", "in": "query", "schema": { "type": "string" } }
]
}
}
}
});
let endpoints = parse_spec(&spec);
assert_eq!(endpoints[0].params.len(), 1);
assert_eq!(endpoints[0].params[0].name, "q");
}
#[test]
fn ref_params_resolved() {
let spec = json!({
"components": {
"parameters": {
"owner": {
"name": "owner",
"in": "path",
"required": true,
"schema": { "type": "string" },
"description": "The account owner"
},
"repo": {
"name": "repo",
"in": "path",
"required": true,
"schema": { "type": "string" },
"description": "The repository name"
},
"per_page": {
"name": "per_page",
"in": "query",
"schema": { "type": "integer" },
"description": "Results per page (max 100)"
}
}
},
"paths": {
"/repos/{owner}/{repo}": {
"get": {
"summary": "Get a repository",
"parameters": [
{ "$ref": "#/components/parameters/owner" },
{ "$ref": "#/components/parameters/repo" },
{ "$ref": "#/components/parameters/per_page" }
]
}
}
}
});
let endpoints = parse_spec(&spec);
assert_eq!(endpoints.len(), 1);
assert_eq!(endpoints[0].params.len(), 3);
assert_eq!(endpoints[0].params[0].name, "owner");
assert_eq!(endpoints[0].params[0].description, "The account owner");
assert!(endpoints[0].params[0].required);
assert_eq!(endpoints[0].params[1].name, "repo");
assert_eq!(endpoints[0].params[2].name, "per_page");
assert_eq!(endpoints[0].params[2].param_type, "integer");
}
#[test]
fn path_level_params_merged() {
let spec = json!({
"paths": {
"/repos/{owner}/{repo}/issues": {
"parameters": [
{ "name": "owner", "in": "path", "required": true, "schema": { "type": "string" } },
{ "name": "repo", "in": "path", "required": true, "schema": { "type": "string" } }
],
"get": {
"summary": "List issues",
"parameters": [
{ "name": "state", "in": "query", "schema": { "type": "string" } }
]
},
"post": {
"summary": "Create issue"
}
}
}
});
let endpoints = parse_spec(&spec);
let get = endpoints.iter().find(|e| e.method == "GET").unwrap();
assert_eq!(get.params.len(), 3);
let post = endpoints.iter().find(|e| e.method == "POST").unwrap();
assert_eq!(post.params.len(), 2);
assert_eq!(post.params[0].name, "owner");
}
#[test]
fn operation_params_override_path_params() {
let spec = json!({
"paths": {
"/items/{id}": {
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "generic" }
],
"get": {
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" }, "description": "overridden" }
]
}
}
}
});
let endpoints = parse_spec(&spec);
assert_eq!(endpoints[0].params.len(), 1);
assert_eq!(endpoints[0].params[0].description, "overridden");
assert_eq!(endpoints[0].params[0].param_type, "string");
}
#[test]
fn resolve_ref_basic() {
let root = json!({
"components": {
"parameters": {
"foo": { "name": "foo", "in": "query" }
}
}
});
let resolved = resolve_ref(&root, "#/components/parameters/foo");
assert!(resolved.is_some());
assert_eq!(resolved.unwrap().get("name").unwrap().as_str(), Some("foo"));
}
#[test]
fn resolve_ref_missing() {
let root = json!({});
assert!(resolve_ref(&root, "#/components/parameters/missing").is_none());
}
#[test]
fn path_params_implicitly_required() {
let spec = json!({
"paths": {
"/items/{id}": {
"get": {
"parameters": [
{ "name": "id", "in": "path", "schema": { "type": "integer" } }
]
}
}
}
});
let endpoints = parse_spec(&spec);
assert!(endpoints[0].params[0].required);
}
}