1use std::collections::HashMap;
38
39use anyhow::{Result, anyhow};
40use openapiv3::{
41 OpenAPI, Operation, Parameter, ParameterSchemaOrContent, PathItem, ReferenceOr, Schema,
42 SchemaKind, Type as OApiType,
43};
44use serde::{Deserialize, Serialize};
45use serde_json::{Value, json};
46
47use brainwires_core::{Tool, ToolInputSchema};
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53pub enum HttpMethod {
54 Get,
56 Post,
58 Put,
60 Patch,
62 Delete,
64}
65
66impl std::fmt::Display for HttpMethod {
67 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68 match self {
69 HttpMethod::Get => write!(f, "GET"),
70 HttpMethod::Post => write!(f, "POST"),
71 HttpMethod::Put => write!(f, "PUT"),
72 HttpMethod::Patch => write!(f, "PATCH"),
73 HttpMethod::Delete => write!(f, "DELETE"),
74 }
75 }
76}
77
78#[derive(Debug, Clone)]
80pub enum OpenApiAuth {
81 Bearer(String),
83 ApiKey {
85 header: String,
87 key: String,
89 },
90 Basic {
92 username: String,
94 password: String,
96 },
97}
98
99#[derive(Debug, Clone)]
101pub struct OpenApiTool {
102 pub tool: Tool,
104 pub endpoint: OpenApiEndpoint,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct OpenApiEndpoint {
111 pub method: HttpMethod,
113 pub path: String,
115 pub base_url: String,
117 pub path_params: Vec<OpenApiParam>,
119 pub query_params: Vec<OpenApiParam>,
121 pub header_params: Vec<OpenApiParam>,
123 pub has_body: bool,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct OpenApiParam {
130 pub name: String,
132 pub description: Option<String>,
134 pub required: bool,
136 pub schema_type: String,
138}
139
140pub fn openapi_to_tools(spec: &str) -> Result<Vec<OpenApiTool>> {
148 let openapi: OpenAPI = serde_json::from_str(spec)
149 .or_else(|_| serde_yml::from_str(spec))
150 .map_err(|e| anyhow!("Failed to parse OpenAPI spec: {}", e))?;
151
152 let base_url = openapi
153 .servers
154 .first()
155 .map(|s| s.url.trim_end_matches('/').to_string())
156 .unwrap_or_default();
157
158 let mut tools = Vec::new();
159
160 for (path, path_item) in &openapi.paths.paths {
161 if let ReferenceOr::Item(item) = path_item {
162 let methods = [
163 (HttpMethod::Get, &item.get),
164 (HttpMethod::Post, &item.post),
165 (HttpMethod::Put, &item.put),
166 (HttpMethod::Patch, &item.patch),
167 (HttpMethod::Delete, &item.delete),
168 ];
169
170 for (method, operation) in methods {
171 if let Some(op) = operation
172 && let Some(tool) = parse_operation(&openapi, &base_url, path, method, item, op)
173 {
174 tools.push(tool);
175 }
176 }
177 }
178 }
179
180 Ok(tools)
181}
182
183fn parse_operation(
184 _spec: &OpenAPI,
185 base_url: &str,
186 path: &str,
187 method: HttpMethod,
188 path_item: &PathItem,
189 operation: &Operation,
190) -> Option<OpenApiTool> {
191 let tool_name = operation.operation_id.clone().unwrap_or_else(|| {
193 let clean_path = path
194 .replace('/', "_")
195 .replace(['{', '}'], "")
196 .trim_matches('_')
197 .to_string();
198 format!("{}_{}", method.to_string().to_lowercase(), clean_path)
199 });
200
201 let description = operation
203 .summary
204 .clone()
205 .or_else(|| operation.description.clone())
206 .unwrap_or_else(|| format!("{} {}", method, path));
207
208 let mut path_params = Vec::new();
210 let mut query_params = Vec::new();
211 let mut header_params = Vec::new();
212 let mut properties: HashMap<String, Value> = HashMap::new();
213 let mut required_params: Vec<String> = Vec::new();
214
215 for param_ref in &path_item.parameters {
217 if let ReferenceOr::Item(param) = param_ref {
218 process_parameter(
219 param,
220 &mut path_params,
221 &mut query_params,
222 &mut header_params,
223 &mut properties,
224 &mut required_params,
225 );
226 }
227 }
228
229 for param_ref in &operation.parameters {
231 if let ReferenceOr::Item(param) = param_ref {
232 process_parameter(
233 param,
234 &mut path_params,
235 &mut query_params,
236 &mut header_params,
237 &mut properties,
238 &mut required_params,
239 );
240 }
241 }
242
243 let has_body = operation.request_body.is_some();
245 if has_body {
246 properties.insert(
247 "body".to_string(),
248 json!({
249 "type": "object",
250 "description": "Request body (JSON object)"
251 }),
252 );
253 }
254
255 let input_schema = ToolInputSchema {
256 schema_type: "object".to_string(),
257 properties: if properties.is_empty() {
258 None
259 } else {
260 Some(properties)
261 },
262 required: if required_params.is_empty() {
263 None
264 } else {
265 Some(required_params)
266 },
267 };
268
269 let tool = Tool {
270 name: tool_name,
271 description,
272 input_schema,
273 requires_approval: false,
274 defer_loading: true, allowed_callers: Vec::new(),
276 input_examples: Vec::new(),
277 serialize: false,
278 };
279
280 let endpoint = OpenApiEndpoint {
281 method,
282 path: path.to_string(),
283 base_url: base_url.to_string(),
284 path_params,
285 query_params,
286 header_params,
287 has_body,
288 };
289
290 Some(OpenApiTool { tool, endpoint })
291}
292
293fn process_parameter(
294 param: &Parameter,
295 path_params: &mut Vec<OpenApiParam>,
296 query_params: &mut Vec<OpenApiParam>,
297 header_params: &mut Vec<OpenApiParam>,
298 properties: &mut HashMap<String, Value>,
299 required_params: &mut Vec<String>,
300) {
301 let (name, required, location, schema_or_content, description) = match param {
302 Parameter::Query {
303 parameter_data,
304 style: _,
305 allow_reserved: _,
306 allow_empty_value: _,
307 } => (
308 ¶meter_data.name,
309 parameter_data.required,
310 "query",
311 ¶meter_data.format,
312 ¶meter_data.description,
313 ),
314 Parameter::Header {
315 parameter_data,
316 style: _,
317 } => (
318 ¶meter_data.name,
319 parameter_data.required,
320 "header",
321 ¶meter_data.format,
322 ¶meter_data.description,
323 ),
324 Parameter::Path {
325 parameter_data,
326 style: _,
327 } => {
328 (
329 ¶meter_data.name,
330 true, "path",
332 ¶meter_data.format,
333 ¶meter_data.description,
334 )
335 }
336 Parameter::Cookie { .. } => return, };
338
339 let schema_type = extract_schema_type(schema_or_content);
340
341 let api_param = OpenApiParam {
342 name: name.clone(),
343 description: description.clone(),
344 required,
345 schema_type: schema_type.clone(),
346 };
347
348 match location {
349 "path" => path_params.push(api_param),
350 "query" => query_params.push(api_param),
351 "header" => header_params.push(api_param),
352 _ => {}
353 }
354
355 let mut prop = json!({ "type": schema_type });
357 if let Some(desc) = description {
358 prop["description"] = json!(desc);
359 }
360 properties.insert(name.clone(), prop);
361
362 if required && !required_params.contains(name) {
363 required_params.push(name.clone());
364 }
365}
366
367fn extract_schema_type(format: &ParameterSchemaOrContent) -> String {
368 match format {
369 ParameterSchemaOrContent::Schema(schema_ref) => {
370 if let ReferenceOr::Item(schema) = schema_ref {
371 schema_to_type_string(schema)
372 } else {
373 "string".to_string()
374 }
375 }
376 ParameterSchemaOrContent::Content(_) => "string".to_string(),
377 }
378}
379
380fn schema_to_type_string(schema: &Schema) -> String {
381 match &schema.schema_kind {
382 SchemaKind::Type(t) => match t {
383 OApiType::String(_) => "string".to_string(),
384 OApiType::Number(_) => "number".to_string(),
385 OApiType::Integer(_) => "integer".to_string(),
386 OApiType::Boolean(_) => "boolean".to_string(),
387 OApiType::Array(_) => "array".to_string(),
388 OApiType::Object(_) => "object".to_string(),
389 },
390 _ => "string".to_string(),
391 }
392}
393
394pub async fn execute_openapi_tool(
401 api_tool: &OpenApiTool,
402 args: &Value,
403 client: &reqwest::Client,
404 auth: Option<&OpenApiAuth>,
405) -> Result<String> {
406 let endpoint = &api_tool.endpoint;
407
408 let mut url_path = endpoint.path.clone();
410 for param in &endpoint.path_params {
411 if let Some(value) = args.get(¶m.name) {
412 let value_str = match value {
413 Value::String(s) => s.clone(),
414 _ => value.to_string(),
415 };
416 url_path = url_path.replace(&format!("{{{}}}", param.name), &value_str);
417 } else if param.required {
418 return Err(anyhow!("Missing required path parameter: {}", param.name));
419 }
420 }
421
422 let url = format!("{}{}", endpoint.base_url, url_path);
423 let mut request = match endpoint.method {
424 HttpMethod::Get => client.get(&url),
425 HttpMethod::Post => client.post(&url),
426 HttpMethod::Put => client.put(&url),
427 HttpMethod::Patch => client.patch(&url),
428 HttpMethod::Delete => client.delete(&url),
429 };
430
431 let mut query_pairs: Vec<(String, String)> = Vec::new();
433 for param in &endpoint.query_params {
434 if let Some(value) = args.get(¶m.name) {
435 let value_str = match value {
436 Value::String(s) => s.clone(),
437 _ => value.to_string(),
438 };
439 query_pairs.push((param.name.clone(), value_str));
440 } else if param.required {
441 return Err(anyhow!("Missing required query parameter: {}", param.name));
442 }
443 }
444 if !query_pairs.is_empty() {
445 request = request.query(&query_pairs);
446 }
447
448 for param in &endpoint.header_params {
450 if let Some(value) = args.get(¶m.name) {
451 let value_str = match value {
452 Value::String(s) => s.clone(),
453 _ => value.to_string(),
454 };
455 request = request.header(¶m.name, &value_str);
456 }
457 }
458
459 if endpoint.has_body
461 && let Some(body) = args.get("body")
462 {
463 request = request.json(body);
464 }
465
466 if let Some(auth) = auth {
468 request = match auth {
469 OpenApiAuth::Bearer(token) => request.bearer_auth(token),
470 OpenApiAuth::ApiKey { header, key } => request.header(header.as_str(), key.as_str()),
471 OpenApiAuth::Basic { username, password } => {
472 request.basic_auth(username, Some(password))
473 }
474 };
475 }
476
477 let response = request
479 .send()
480 .await
481 .map_err(|e| anyhow!("HTTP request failed: {}", e))?;
482 let status = response.status();
483 let body = response.text().await.unwrap_or_default();
484
485 if status.is_success() {
486 Ok(body)
487 } else {
488 Err(anyhow!(
489 "HTTP {} {}: {}",
490 status.as_u16(),
491 status.canonical_reason().unwrap_or(""),
492 body
493 ))
494 }
495}
496
497#[cfg(test)]
500mod tests {
501 use super::*;
502
503 fn petstore_spec() -> &'static str {
504 r#"{
505 "openapi": "3.0.0",
506 "info": { "title": "Petstore", "version": "1.0.0" },
507 "servers": [{ "url": "https://petstore.example.com/v1" }],
508 "paths": {
509 "/pets": {
510 "get": {
511 "operationId": "listPets",
512 "summary": "List all pets",
513 "parameters": [
514 {
515 "name": "limit",
516 "in": "query",
517 "required": false,
518 "schema": { "type": "integer" },
519 "description": "How many items to return"
520 }
521 ],
522 "responses": { "200": { "description": "OK" } }
523 },
524 "post": {
525 "operationId": "createPet",
526 "summary": "Create a pet",
527 "requestBody": {
528 "required": true,
529 "content": {
530 "application/json": {
531 "schema": {
532 "type": "object",
533 "properties": {
534 "name": { "type": "string" },
535 "tag": { "type": "string" }
536 }
537 }
538 }
539 }
540 },
541 "responses": { "201": { "description": "Created" } }
542 }
543 },
544 "/pets/{petId}": {
545 "get": {
546 "operationId": "showPetById",
547 "summary": "Info for a specific pet",
548 "parameters": [
549 {
550 "name": "petId",
551 "in": "path",
552 "required": true,
553 "schema": { "type": "string" },
554 "description": "The id of the pet"
555 }
556 ],
557 "responses": { "200": { "description": "OK" } }
558 }
559 }
560 }
561 }"#
562 }
563
564 #[test]
565 fn test_parse_petstore_spec() {
566 let tools = openapi_to_tools(petstore_spec()).unwrap();
567 assert_eq!(tools.len(), 3);
568
569 let list = tools.iter().find(|t| t.tool.name == "listPets").unwrap();
571 assert_eq!(list.endpoint.method, HttpMethod::Get);
572 assert_eq!(list.endpoint.path, "/pets");
573 assert_eq!(list.endpoint.base_url, "https://petstore.example.com/v1");
574 assert_eq!(list.endpoint.query_params.len(), 1);
575 assert_eq!(list.endpoint.query_params[0].name, "limit");
576 assert!(!list.endpoint.query_params[0].required);
577
578 let create = tools.iter().find(|t| t.tool.name == "createPet").unwrap();
580 assert_eq!(create.endpoint.method, HttpMethod::Post);
581 assert!(create.endpoint.has_body);
582
583 let show = tools.iter().find(|t| t.tool.name == "showPetById").unwrap();
585 assert_eq!(show.endpoint.method, HttpMethod::Get);
586 assert_eq!(show.endpoint.path_params.len(), 1);
587 assert_eq!(show.endpoint.path_params[0].name, "petId");
588 assert!(show.endpoint.path_params[0].required);
589 }
590
591 #[test]
592 fn test_tool_schema_generation() {
593 let tools = openapi_to_tools(petstore_spec()).unwrap();
594 let list = tools.iter().find(|t| t.tool.name == "listPets").unwrap();
595
596 let props = list.tool.input_schema.properties.as_ref().unwrap();
598 assert!(props.contains_key("limit"));
599 assert_eq!(props["limit"]["type"], "integer");
600
601 assert!(list.tool.input_schema.required.is_none());
603 }
604
605 #[test]
606 fn test_path_param_required() {
607 let tools = openapi_to_tools(petstore_spec()).unwrap();
608 let show = tools.iter().find(|t| t.tool.name == "showPetById").unwrap();
609
610 let required = show.tool.input_schema.required.as_ref().unwrap();
611 assert!(required.contains(&"petId".to_string()));
612 }
613
614 #[test]
615 fn test_operation_id_fallback() {
616 let spec = r#"{
617 "openapi": "3.0.0",
618 "info": { "title": "Test", "version": "1.0.0" },
619 "servers": [{ "url": "https://api.example.com" }],
620 "paths": {
621 "/users/{id}/posts": {
622 "get": {
623 "summary": "Get user posts",
624 "responses": { "200": { "description": "OK" } }
625 }
626 }
627 }
628 }"#;
629
630 let tools = openapi_to_tools(spec).unwrap();
631 assert_eq!(tools.len(), 1);
632 assert_eq!(tools[0].tool.name, "get_users_id_posts");
634 }
635
636 #[test]
637 fn test_empty_spec() {
638 let spec = r#"{
639 "openapi": "3.0.0",
640 "info": { "title": "Empty", "version": "1.0.0" },
641 "paths": {}
642 }"#;
643
644 let tools = openapi_to_tools(spec).unwrap();
645 assert!(tools.is_empty());
646 }
647
648 #[test]
649 fn test_invalid_spec() {
650 let result = openapi_to_tools("not valid json or yaml");
651 assert!(result.is_err());
652 }
653}