use std::collections::HashMap;
use anyhow::{Result, anyhow};
use openapiv3::{
OpenAPI, Operation, Parameter, ParameterSchemaOrContent, PathItem, ReferenceOr, Schema,
SchemaKind, Type as OApiType,
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use brainwires_core::{Tool, ToolInputSchema};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum HttpMethod {
Get,
Post,
Put,
Patch,
Delete,
}
impl std::fmt::Display for HttpMethod {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
HttpMethod::Get => write!(f, "GET"),
HttpMethod::Post => write!(f, "POST"),
HttpMethod::Put => write!(f, "PUT"),
HttpMethod::Patch => write!(f, "PATCH"),
HttpMethod::Delete => write!(f, "DELETE"),
}
}
}
#[derive(Debug, Clone)]
pub enum OpenApiAuth {
Bearer(String),
ApiKey {
header: String,
key: String,
},
Basic {
username: String,
password: String,
},
}
#[derive(Debug, Clone)]
pub struct OpenApiTool {
pub tool: Tool,
pub endpoint: OpenApiEndpoint,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenApiEndpoint {
pub method: HttpMethod,
pub path: String,
pub base_url: String,
pub path_params: Vec<OpenApiParam>,
pub query_params: Vec<OpenApiParam>,
pub header_params: Vec<OpenApiParam>,
pub has_body: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenApiParam {
pub name: String,
pub description: Option<String>,
pub required: bool,
pub schema_type: String,
}
pub fn openapi_to_tools(spec: &str) -> Result<Vec<OpenApiTool>> {
let openapi: OpenAPI = serde_json::from_str(spec)
.or_else(|_| serde_yml::from_str(spec))
.map_err(|e| anyhow!("Failed to parse OpenAPI spec: {}", e))?;
let base_url = openapi
.servers
.first()
.map(|s| s.url.trim_end_matches('/').to_string())
.unwrap_or_default();
let mut tools = Vec::new();
for (path, path_item) in &openapi.paths.paths {
if let ReferenceOr::Item(item) = path_item {
let methods = [
(HttpMethod::Get, &item.get),
(HttpMethod::Post, &item.post),
(HttpMethod::Put, &item.put),
(HttpMethod::Patch, &item.patch),
(HttpMethod::Delete, &item.delete),
];
for (method, operation) in methods {
if let Some(op) = operation
&& let Some(tool) = parse_operation(&openapi, &base_url, path, method, item, op)
{
tools.push(tool);
}
}
}
}
Ok(tools)
}
fn parse_operation(
_spec: &OpenAPI,
base_url: &str,
path: &str,
method: HttpMethod,
path_item: &PathItem,
operation: &Operation,
) -> Option<OpenApiTool> {
let tool_name = operation.operation_id.clone().unwrap_or_else(|| {
let clean_path = path
.replace('/', "_")
.replace(['{', '}'], "")
.trim_matches('_')
.to_string();
format!("{}_{}", method.to_string().to_lowercase(), clean_path)
});
let description = operation
.summary
.clone()
.or_else(|| operation.description.clone())
.unwrap_or_else(|| format!("{} {}", method, path));
let mut path_params = Vec::new();
let mut query_params = Vec::new();
let mut header_params = Vec::new();
let mut properties: HashMap<String, Value> = HashMap::new();
let mut required_params: Vec<String> = Vec::new();
for param_ref in &path_item.parameters {
if let ReferenceOr::Item(param) = param_ref {
process_parameter(
param,
&mut path_params,
&mut query_params,
&mut header_params,
&mut properties,
&mut required_params,
);
}
}
for param_ref in &operation.parameters {
if let ReferenceOr::Item(param) = param_ref {
process_parameter(
param,
&mut path_params,
&mut query_params,
&mut header_params,
&mut properties,
&mut required_params,
);
}
}
let has_body = operation.request_body.is_some();
if has_body {
properties.insert(
"body".to_string(),
json!({
"type": "object",
"description": "Request body (JSON object)"
}),
);
}
let input_schema = ToolInputSchema {
schema_type: "object".to_string(),
properties: if properties.is_empty() {
None
} else {
Some(properties)
},
required: if required_params.is_empty() {
None
} else {
Some(required_params)
},
};
let tool = Tool {
name: tool_name,
description,
input_schema,
requires_approval: false,
defer_loading: true, allowed_callers: Vec::new(),
input_examples: Vec::new(),
};
let endpoint = OpenApiEndpoint {
method,
path: path.to_string(),
base_url: base_url.to_string(),
path_params,
query_params,
header_params,
has_body,
};
Some(OpenApiTool { tool, endpoint })
}
fn process_parameter(
param: &Parameter,
path_params: &mut Vec<OpenApiParam>,
query_params: &mut Vec<OpenApiParam>,
header_params: &mut Vec<OpenApiParam>,
properties: &mut HashMap<String, Value>,
required_params: &mut Vec<String>,
) {
let (name, required, location, schema_or_content, description) = match param {
Parameter::Query {
parameter_data,
style: _,
allow_reserved: _,
allow_empty_value: _,
} => (
¶meter_data.name,
parameter_data.required,
"query",
¶meter_data.format,
¶meter_data.description,
),
Parameter::Header {
parameter_data,
style: _,
} => (
¶meter_data.name,
parameter_data.required,
"header",
¶meter_data.format,
¶meter_data.description,
),
Parameter::Path {
parameter_data,
style: _,
} => {
(
¶meter_data.name,
true, "path",
¶meter_data.format,
¶meter_data.description,
)
}
Parameter::Cookie { .. } => return, };
let schema_type = extract_schema_type(schema_or_content);
let api_param = OpenApiParam {
name: name.clone(),
description: description.clone(),
required,
schema_type: schema_type.clone(),
};
match location {
"path" => path_params.push(api_param),
"query" => query_params.push(api_param),
"header" => header_params.push(api_param),
_ => {}
}
let mut prop = json!({ "type": schema_type });
if let Some(desc) = description {
prop["description"] = json!(desc);
}
properties.insert(name.clone(), prop);
if required && !required_params.contains(name) {
required_params.push(name.clone());
}
}
fn extract_schema_type(format: &ParameterSchemaOrContent) -> String {
match format {
ParameterSchemaOrContent::Schema(schema_ref) => {
if let ReferenceOr::Item(schema) = schema_ref {
schema_to_type_string(schema)
} else {
"string".to_string()
}
}
ParameterSchemaOrContent::Content(_) => "string".to_string(),
}
}
fn schema_to_type_string(schema: &Schema) -> String {
match &schema.schema_kind {
SchemaKind::Type(t) => match t {
OApiType::String(_) => "string".to_string(),
OApiType::Number(_) => "number".to_string(),
OApiType::Integer(_) => "integer".to_string(),
OApiType::Boolean(_) => "boolean".to_string(),
OApiType::Array(_) => "array".to_string(),
OApiType::Object(_) => "object".to_string(),
},
_ => "string".to_string(),
}
}
pub async fn execute_openapi_tool(
api_tool: &OpenApiTool,
args: &Value,
client: &reqwest::Client,
auth: Option<&OpenApiAuth>,
) -> Result<String> {
let endpoint = &api_tool.endpoint;
let mut url_path = endpoint.path.clone();
for param in &endpoint.path_params {
if let Some(value) = args.get(¶m.name) {
let value_str = match value {
Value::String(s) => s.clone(),
_ => value.to_string(),
};
url_path = url_path.replace(&format!("{{{}}}", param.name), &value_str);
} else if param.required {
return Err(anyhow!("Missing required path parameter: {}", param.name));
}
}
let url = format!("{}{}", endpoint.base_url, url_path);
let mut request = match endpoint.method {
HttpMethod::Get => client.get(&url),
HttpMethod::Post => client.post(&url),
HttpMethod::Put => client.put(&url),
HttpMethod::Patch => client.patch(&url),
HttpMethod::Delete => client.delete(&url),
};
let mut query_pairs: Vec<(String, String)> = Vec::new();
for param in &endpoint.query_params {
if let Some(value) = args.get(¶m.name) {
let value_str = match value {
Value::String(s) => s.clone(),
_ => value.to_string(),
};
query_pairs.push((param.name.clone(), value_str));
} else if param.required {
return Err(anyhow!("Missing required query parameter: {}", param.name));
}
}
if !query_pairs.is_empty() {
request = request.query(&query_pairs);
}
for param in &endpoint.header_params {
if let Some(value) = args.get(¶m.name) {
let value_str = match value {
Value::String(s) => s.clone(),
_ => value.to_string(),
};
request = request.header(¶m.name, &value_str);
}
}
if endpoint.has_body
&& let Some(body) = args.get("body")
{
request = request.json(body);
}
if let Some(auth) = auth {
request = match auth {
OpenApiAuth::Bearer(token) => request.bearer_auth(token),
OpenApiAuth::ApiKey { header, key } => request.header(header.as_str(), key.as_str()),
OpenApiAuth::Basic { username, password } => {
request.basic_auth(username, Some(password))
}
};
}
let response = request
.send()
.await
.map_err(|e| anyhow!("HTTP request failed: {}", e))?;
let status = response.status();
let body = response.text().await.unwrap_or_default();
if status.is_success() {
Ok(body)
} else {
Err(anyhow!(
"HTTP {} {}: {}",
status.as_u16(),
status.canonical_reason().unwrap_or(""),
body
))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn petstore_spec() -> &'static str {
r#"{
"openapi": "3.0.0",
"info": { "title": "Petstore", "version": "1.0.0" },
"servers": [{ "url": "https://petstore.example.com/v1" }],
"paths": {
"/pets": {
"get": {
"operationId": "listPets",
"summary": "List all pets",
"parameters": [
{
"name": "limit",
"in": "query",
"required": false,
"schema": { "type": "integer" },
"description": "How many items to return"
}
],
"responses": { "200": { "description": "OK" } }
},
"post": {
"operationId": "createPet",
"summary": "Create a pet",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"name": { "type": "string" },
"tag": { "type": "string" }
}
}
}
}
},
"responses": { "201": { "description": "Created" } }
}
},
"/pets/{petId}": {
"get": {
"operationId": "showPetById",
"summary": "Info for a specific pet",
"parameters": [
{
"name": "petId",
"in": "path",
"required": true,
"schema": { "type": "string" },
"description": "The id of the pet"
}
],
"responses": { "200": { "description": "OK" } }
}
}
}
}"#
}
#[test]
fn test_parse_petstore_spec() {
let tools = openapi_to_tools(petstore_spec()).unwrap();
assert_eq!(tools.len(), 3);
let list = tools.iter().find(|t| t.tool.name == "listPets").unwrap();
assert_eq!(list.endpoint.method, HttpMethod::Get);
assert_eq!(list.endpoint.path, "/pets");
assert_eq!(list.endpoint.base_url, "https://petstore.example.com/v1");
assert_eq!(list.endpoint.query_params.len(), 1);
assert_eq!(list.endpoint.query_params[0].name, "limit");
assert!(!list.endpoint.query_params[0].required);
let create = tools.iter().find(|t| t.tool.name == "createPet").unwrap();
assert_eq!(create.endpoint.method, HttpMethod::Post);
assert!(create.endpoint.has_body);
let show = tools.iter().find(|t| t.tool.name == "showPetById").unwrap();
assert_eq!(show.endpoint.method, HttpMethod::Get);
assert_eq!(show.endpoint.path_params.len(), 1);
assert_eq!(show.endpoint.path_params[0].name, "petId");
assert!(show.endpoint.path_params[0].required);
}
#[test]
fn test_tool_schema_generation() {
let tools = openapi_to_tools(petstore_spec()).unwrap();
let list = tools.iter().find(|t| t.tool.name == "listPets").unwrap();
let props = list.tool.input_schema.properties.as_ref().unwrap();
assert!(props.contains_key("limit"));
assert_eq!(props["limit"]["type"], "integer");
assert!(list.tool.input_schema.required.is_none());
}
#[test]
fn test_path_param_required() {
let tools = openapi_to_tools(petstore_spec()).unwrap();
let show = tools.iter().find(|t| t.tool.name == "showPetById").unwrap();
let required = show.tool.input_schema.required.as_ref().unwrap();
assert!(required.contains(&"petId".to_string()));
}
#[test]
fn test_operation_id_fallback() {
let spec = r#"{
"openapi": "3.0.0",
"info": { "title": "Test", "version": "1.0.0" },
"servers": [{ "url": "https://api.example.com" }],
"paths": {
"/users/{id}/posts": {
"get": {
"summary": "Get user posts",
"responses": { "200": { "description": "OK" } }
}
}
}
}"#;
let tools = openapi_to_tools(spec).unwrap();
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].tool.name, "get_users_id_posts");
}
#[test]
fn test_empty_spec() {
let spec = r#"{
"openapi": "3.0.0",
"info": { "title": "Empty", "version": "1.0.0" },
"paths": {}
}"#;
let tools = openapi_to_tools(spec).unwrap();
assert!(tools.is_empty());
}
#[test]
fn test_invalid_spec() {
let result = openapi_to_tools("not valid json or yaml");
assert!(result.is_err());
}
}