use std::collections::HashMap;
use std::path::Path;
use serde_json::Value;
use crate::client::commands::{CommandDef, ParamDef, ParamType};
use crate::error::{Result, SxmcError};
#[derive(Debug, Clone)]
pub struct OpenApiOperation {
pub operation_id: String,
pub summary: String,
pub method: String,
pub path: String,
pub parameters: Vec<OpenApiParam>,
pub request_body_schema: Option<Value>,
}
#[derive(Debug, Clone)]
pub struct OpenApiParam {
pub name: String,
pub location: ParamLocation,
pub description: String,
pub required: bool,
pub schema_type: String,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ParamLocation {
Path,
Query,
Header,
Cookie,
}
pub struct OpenApiSpec {
pub title: String,
pub base_url: String,
pub operations: Vec<OpenApiOperation>,
client: reqwest::Client,
}
impl OpenApiSpec {
pub async fn load(source: &str, auth_headers: &[(String, String)]) -> Result<Self> {
let raw = if source.starts_with("http://") || source.starts_with("https://") {
fetch_spec(source, auth_headers).await?
} else {
let path = Path::new(source);
let content = std::fs::read_to_string(path)
.map_err(|e| SxmcError::Other(format!("Failed to read spec file: {}", e)))?;
content
};
let spec: Value = serde_json::from_str(&raw).or_else(|_| {
serde_yaml::from_str(&raw)
.map_err(|e| SxmcError::ParseError(format!("Failed to parse spec: {}", e)))
})?;
let title = spec
.pointer("/info/title")
.and_then(|v| v.as_str())
.unwrap_or("API")
.to_string();
let base_url = extract_base_url(&spec, source);
let operations = extract_operations(&spec);
let mut header_map = reqwest::header::HeaderMap::new();
for (key, value) in auth_headers {
if let (Ok(name), Ok(val)) = (
key.parse::<reqwest::header::HeaderName>(),
value.parse::<reqwest::header::HeaderValue>(),
) {
header_map.insert(name, val);
}
}
let client = reqwest::Client::builder()
.default_headers(header_map)
.build()
.map_err(|e| SxmcError::Other(format!("Failed to build HTTP client: {}", e)))?;
Ok(Self {
title,
base_url,
operations,
client,
})
}
pub fn commands(&self) -> Vec<CommandDef> {
self.operations
.iter()
.map(|op| {
let mut params: Vec<ParamDef> = op
.parameters
.iter()
.map(|p| ParamDef {
name: p.name.clone(),
description: if p.description.is_empty() {
format!("{} parameter ({})", p.location_str(), p.schema_type)
} else {
p.description.clone()
},
param_type: ParamType::from_json_schema(&p.schema_type),
required: p.required,
default: None,
})
.collect();
if op.request_body_schema.is_some() {
params.push(ParamDef {
name: "body".to_string(),
description: "Request body (JSON)".to_string(),
param_type: ParamType::Object,
required: false,
default: None,
});
}
params.sort_by(|a, b| b.required.cmp(&a.required).then(a.name.cmp(&b.name)));
CommandDef {
name: op.operation_id.clone(),
description: if op.summary.is_empty() {
format!("{} {}", op.method.to_uppercase(), op.path)
} else {
op.summary.clone()
},
params,
}
})
.collect()
}
pub async fn execute(
&self,
operation_id: &str,
args: &HashMap<String, String>,
) -> Result<Value> {
let op = self
.operations
.iter()
.find(|o| o.operation_id == operation_id)
.ok_or_else(|| SxmcError::Other(format!("Operation not found: {}", operation_id)))?;
let mut url = format!("{}{}", self.base_url, op.path);
for param in &op.parameters {
if param.location == ParamLocation::Path {
if let Some(value) = args.get(¶m.name) {
url = url.replace(&format!("{{{}}}", param.name), value);
}
}
}
let query_params: Vec<(&str, &str)> = op
.parameters
.iter()
.filter(|p| p.location == ParamLocation::Query)
.filter_map(|p| args.get(&p.name).map(|v| (p.name.as_str(), v.as_str())))
.collect();
let mut request = match op.method.as_str() {
"get" => self.client.get(&url),
"post" => self.client.post(&url),
"put" => self.client.put(&url),
"patch" => self.client.patch(&url),
"delete" => self.client.delete(&url),
"head" => self.client.head(&url),
_ => self.client.get(&url),
};
if !query_params.is_empty() {
request = request.query(&query_params);
}
for param in &op.parameters {
if param.location == ParamLocation::Header {
if let Some(value) = args.get(¶m.name) {
request = request.header(¶m.name, value);
}
}
}
if op.request_body_schema.is_some() {
if let Some(body_str) = args.get("body") {
let body: Value = serde_json::from_str(body_str)
.map_err(|e| SxmcError::Other(format!("Invalid JSON body: {}", e)))?;
request = request.json(&body);
}
}
let response = request
.send()
.await
.map_err(|e| SxmcError::Other(format!("HTTP request failed: {}", e)))?;
let status = response.status();
let text = response
.text()
.await
.map_err(|e| SxmcError::Other(format!("Failed to read response: {}", e)))?;
let value = serde_json::from_str::<Value>(&text).unwrap_or_else(|_| {
serde_json::json!({
"status": status.as_u16(),
"body": text,
})
});
Ok(value)
}
pub fn list_operations(&self, search: Option<&str>) -> Vec<&OpenApiOperation> {
self.operations
.iter()
.filter(|op| {
if let Some(pattern) = search {
let p = pattern.to_lowercase();
op.operation_id.to_lowercase().contains(&p)
|| op.summary.to_lowercase().contains(&p)
|| op.path.to_lowercase().contains(&p)
} else {
true
}
})
.collect()
}
}
impl OpenApiParam {
fn location_str(&self) -> &str {
match self.location {
ParamLocation::Path => "path",
ParamLocation::Query => "query",
ParamLocation::Header => "header",
ParamLocation::Cookie => "cookie",
}
}
}
async fn fetch_spec(url: &str, auth_headers: &[(String, String)]) -> Result<String> {
let mut header_map = reqwest::header::HeaderMap::new();
for (key, value) in auth_headers {
if let (Ok(name), Ok(val)) = (
key.parse::<reqwest::header::HeaderName>(),
value.parse::<reqwest::header::HeaderValue>(),
) {
header_map.insert(name, val);
}
}
let client = reqwest::Client::builder()
.default_headers(header_map)
.build()
.map_err(|e| SxmcError::Other(format!("Failed to build HTTP client: {}", e)))?;
let resp = client
.get(url)
.send()
.await
.map_err(|e| SxmcError::Other(format!("Failed to fetch spec: {}", e)))?;
resp.text()
.await
.map_err(|e| SxmcError::Other(format!("Failed to read spec response: {}", e)))
}
fn extract_base_url(spec: &Value, source: &str) -> String {
if let Some(server_url) = spec.pointer("/servers/0/url").and_then(|v| v.as_str()) {
if server_url.starts_with("http://") || server_url.starts_with("https://") {
return server_url.to_string();
}
if source.starts_with("http://") || source.starts_with("https://") {
if let Ok(base) = url::Url::parse(source) {
if let Ok(resolved) = base.join(server_url) {
return resolved.as_str().trim_end_matches('/').to_string();
}
}
}
return server_url.to_string();
}
let host = spec
.get("host")
.and_then(|v| v.as_str())
.unwrap_or("localhost");
let base_path = spec.get("basePath").and_then(|v| v.as_str()).unwrap_or("");
let scheme = spec
.get("schemes")
.and_then(|v| v.as_array())
.and_then(|arr| arr.first())
.and_then(|v| v.as_str())
.unwrap_or("https");
format!("{}://{}{}", scheme, host, base_path)
}
fn extract_operations(spec: &Value) -> Vec<OpenApiOperation> {
let mut operations = Vec::new();
let paths = match spec.get("paths").and_then(|v| v.as_object()) {
Some(p) => p,
None => return operations,
};
let methods = ["get", "post", "put", "patch", "delete", "head", "options"];
for (path, path_item) in paths {
let path_params = path_item
.get("parameters")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
for method in &methods {
if let Some(operation) = path_item.get(*method).and_then(|v| v.as_object()) {
let operation_id = operation
.get("operationId")
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_else(|| generate_operation_id(method, path));
let summary = operation
.get("summary")
.or_else(|| operation.get("description"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let mut params = extract_params(&path_params, spec);
let op_params = operation
.get("parameters")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let mut op_extracted = extract_params(&op_params, spec);
params.append(&mut op_extracted);
let mut seen = std::collections::HashSet::new();
params.retain(|p| seen.insert((p.name.clone(), p.location_str().to_string())));
let request_body_schema = operation
.get("requestBody")
.and_then(|rb| {
rb.pointer("/content/application~1json/schema").or_else(|| {
rb.pointer("/content/application~1x-www-form-urlencoded/schema")
})
})
.cloned();
operations.push(OpenApiOperation {
operation_id,
summary,
method: method.to_string(),
path: path.clone(),
parameters: params,
request_body_schema,
});
}
}
}
operations.sort_by(|a, b| a.operation_id.cmp(&b.operation_id));
operations
}
fn extract_params(params: &[Value], spec: &Value) -> Vec<OpenApiParam> {
params
.iter()
.filter_map(|p| {
let resolved = resolve_ref(p, spec);
let obj = resolved.as_object()?;
let name = obj.get("name")?.as_str()?.to_string();
let location = match obj.get("in")?.as_str()? {
"path" => ParamLocation::Path,
"query" => ParamLocation::Query,
"header" => ParamLocation::Header,
"cookie" => ParamLocation::Cookie,
_ => return None,
};
let description = obj
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let required = obj
.get("required")
.and_then(|v| v.as_bool())
.unwrap_or(location == ParamLocation::Path);
let schema_type = obj
.get("schema")
.and_then(|s| s.get("type"))
.and_then(|v| v.as_str())
.unwrap_or("string")
.to_string();
Some(OpenApiParam {
name,
location,
description,
required,
schema_type,
})
})
.collect()
}
fn resolve_ref<'a>(value: &'a Value, spec: &'a Value) -> &'a Value {
if let Some(ref_path) = value.get("$ref").and_then(|v| v.as_str()) {
if let Some(path) = ref_path.strip_prefix("#/") {
let pointer = format!("/{}", path);
if let Some(resolved) = spec.pointer(&pointer) {
return resolved;
}
}
}
value
}
fn generate_operation_id(method: &str, path: &str) -> String {
let clean_path = path
.trim_start_matches('/')
.replace('/', "-")
.replace(['{', '}'], "");
if clean_path.is_empty() {
method.to_string()
} else {
format!("{}-{}", method, clean_path)
}
}
pub fn format_operation_list(ops: &[&OpenApiOperation], search: Option<&str>) -> String {
let filtered: Vec<&&OpenApiOperation> = if let Some(pattern) = search {
let p = pattern.to_lowercase();
ops.iter()
.filter(|op| {
op.operation_id.to_lowercase().contains(&p)
|| op.summary.to_lowercase().contains(&p)
})
.collect()
} else {
ops.iter().collect()
};
if filtered.is_empty() {
if search.is_some() {
return "No matching operations found.".to_string();
}
return "No operations available.".to_string();
}
let mut lines = Vec::new();
for op in &filtered {
lines.push(format!(
" {} ({} {})",
op.operation_id,
op.method.to_uppercase(),
op.path
));
if !op.summary.is_empty() {
lines.push(format!(" {}", op.summary));
}
}
format!("Operations ({}):\n{}", filtered.len(), lines.join("\n"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_operation_id() {
assert_eq!(generate_operation_id("get", "/pets"), "get-pets");
assert_eq!(
generate_operation_id("get", "/pets/{petId}"),
"get-pets-petId"
);
assert_eq!(
generate_operation_id("post", "/users/{id}/orders"),
"post-users-id-orders"
);
}
#[test]
fn test_extract_base_url_openapi3() {
let spec: Value = serde_json::json!({
"openapi": "3.0.0",
"servers": [{"url": "https://api.example.com/v1"}]
});
assert_eq!(
extract_base_url(&spec, "https://api.example.com/openapi.json"),
"https://api.example.com/v1"
);
}
#[test]
fn test_extract_base_url_relative_server() {
let spec: Value = serde_json::json!({
"openapi": "3.0.0",
"servers": [{"url": "/api/v3"}]
});
assert_eq!(
extract_base_url(&spec, "https://petstore3.swagger.io/api/v3/openapi.json"),
"https://petstore3.swagger.io/api/v3"
);
}
#[test]
fn test_extract_base_url_swagger2() {
let spec: Value = serde_json::json!({
"swagger": "2.0",
"host": "petstore.swagger.io",
"basePath": "/v2",
"schemes": ["https"]
});
assert_eq!(
extract_base_url(&spec, "https://petstore.swagger.io/v2/swagger.json"),
"https://petstore.swagger.io/v2"
);
}
#[test]
fn test_extract_operations() {
let spec: Value = serde_json::json!({
"openapi": "3.0.0",
"paths": {
"/pets": {
"get": {
"operationId": "listPets",
"summary": "List all pets",
"parameters": [
{
"name": "limit",
"in": "query",
"description": "Maximum number of items",
"required": false,
"schema": {"type": "integer"}
}
]
},
"post": {
"operationId": "createPet",
"summary": "Create a pet",
"requestBody": {
"content": {
"application/json": {
"schema": {"type": "object"}
}
}
}
}
},
"/pets/{petId}": {
"get": {
"operationId": "getPet",
"summary": "Get a pet by ID",
"parameters": [
{
"name": "petId",
"in": "path",
"required": true,
"schema": {"type": "string"}
}
]
}
}
}
});
let ops = extract_operations(&spec);
assert_eq!(ops.len(), 3);
let list_pets = ops.iter().find(|o| o.operation_id == "listPets").unwrap();
assert_eq!(list_pets.method, "get");
assert_eq!(list_pets.parameters.len(), 1);
assert_eq!(list_pets.parameters[0].name, "limit");
let create_pet = ops.iter().find(|o| o.operation_id == "createPet").unwrap();
assert!(create_pet.request_body_schema.is_some());
let get_pet = ops.iter().find(|o| o.operation_id == "getPet").unwrap();
assert_eq!(get_pet.parameters[0].location, ParamLocation::Path);
}
}