Skip to main content

brainwires_tool_runtime/
openapi.rs

1//! OpenAPI Tool Generation — Automatically create tools from OpenAPI 3.x specs
2//!
3//! Parses OpenAPI specifications and generates [`Tool`] definitions that can
4//! be registered in a [`ToolRegistry`] and executed by agents.
5//!
6//! # Feature Gate
7//!
8//! This module requires the `openapi` feature:
9//!
10//! ```toml
11//! brainwires-tools = { version = "0.10", features = ["openapi"] }
12//! ```
13//!
14//! # Usage
15//!
16//! ```rust,ignore
17//! use brainwires_tool_runtime::openapi::{openapi_to_tools, execute_openapi_tool, OpenApiAuth};
18//!
19//! // Parse spec and get tools
20//! let spec_json = std::fs::read_to_string("openapi.json")?;
21//! let api_tools = openapi_to_tools(&spec_json)?;
22//!
23//! // Register tools
24//! for api_tool in &api_tools {
25//!     registry.register(api_tool.tool.clone());
26//! }
27//!
28//! // Execute a tool call
29//! let result = execute_openapi_tool(
30//!     &api_tools[0],
31//!     &args,
32//!     &reqwest::Client::new(),
33//!     Some(&OpenApiAuth::Bearer("token".into())),
34//! ).await?;
35//! ```
36
37use 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// ── Public types ─────────────────────────────────────────────────────────────
50
51/// HTTP method for an OpenAPI endpoint.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53pub enum HttpMethod {
54    /// GET request.
55    Get,
56    /// POST request.
57    Post,
58    /// PUT request.
59    Put,
60    /// PATCH request.
61    Patch,
62    /// DELETE request.
63    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/// Authentication configuration for OpenAPI tool execution.
79#[derive(Debug, Clone)]
80pub enum OpenApiAuth {
81    /// Bearer token authentication.
82    Bearer(String),
83    /// API key in a header.
84    ApiKey {
85        /// Header name.
86        header: String,
87        /// API key value.
88        key: String,
89    },
90    /// HTTP Basic authentication.
91    Basic {
92        /// Username.
93        username: String,
94        /// Password.
95        password: String,
96    },
97}
98
99/// A parsed OpenAPI endpoint with its corresponding tool definition.
100#[derive(Debug, Clone)]
101pub struct OpenApiTool {
102    /// The generated tool definition for AI consumption.
103    pub tool: Tool,
104    /// The endpoint details for HTTP execution.
105    pub endpoint: OpenApiEndpoint,
106}
107
108/// HTTP endpoint details extracted from an OpenAPI spec.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct OpenApiEndpoint {
111    /// HTTP method.
112    pub method: HttpMethod,
113    /// URL path template (e.g., "/users/{id}").
114    pub path: String,
115    /// Base URL for the API.
116    pub base_url: String,
117    /// Path parameters.
118    pub path_params: Vec<OpenApiParam>,
119    /// Query parameters.
120    pub query_params: Vec<OpenApiParam>,
121    /// Header parameters.
122    pub header_params: Vec<OpenApiParam>,
123    /// Whether a request body is expected.
124    pub has_body: bool,
125}
126
127/// A single parameter from an OpenAPI spec.
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct OpenApiParam {
130    /// Parameter name.
131    pub name: String,
132    /// Parameter description.
133    pub description: Option<String>,
134    /// Whether the parameter is required.
135    pub required: bool,
136    /// JSON Schema type (e.g., "string", "integer").
137    pub schema_type: String,
138}
139
140// ── Parsing ──────────────────────────────────────────────────────────────────
141
142/// Parse an OpenAPI 3.x JSON or YAML spec and generate tool definitions.
143///
144/// Each endpoint (method + path combination) becomes a separate [`OpenApiTool`].
145/// Tool names are derived from `operationId` if present, or generated from
146/// the HTTP method and path.
147pub 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    // Generate tool name from operationId or method+path
192    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    // Generate description
202    let description = operation
203        .summary
204        .clone()
205        .or_else(|| operation.description.clone())
206        .unwrap_or_else(|| format!("{} {}", method, path));
207
208    // Collect parameters from both path-level and operation-level
209    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    // Process path-level parameters
216    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    // Process operation-level parameters (override path-level)
230    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    // Check for request body
244    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, // Lazy-load by default
275        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            &parameter_data.name,
309            parameter_data.required,
310            "query",
311            &parameter_data.format,
312            &parameter_data.description,
313        ),
314        Parameter::Header {
315            parameter_data,
316            style: _,
317        } => (
318            &parameter_data.name,
319            parameter_data.required,
320            "header",
321            &parameter_data.format,
322            &parameter_data.description,
323        ),
324        Parameter::Path {
325            parameter_data,
326            style: _,
327        } => {
328            (
329                &parameter_data.name,
330                true, // Path params are always required
331                "path",
332                &parameter_data.format,
333                &parameter_data.description,
334            )
335        }
336        Parameter::Cookie { .. } => return, // Skip cookie params
337    };
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    // Add to tool input schema properties
356    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
394// ── Execution ────────────────────────────────────────────────────────────────
395
396/// Execute an OpenAPI tool by making the HTTP request.
397///
398/// Substitutes path parameters, adds query parameters, attaches auth,
399/// and returns the response body as a string.
400pub 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    // Build URL with path parameter substitution
409    let mut url_path = endpoint.path.clone();
410    for param in &endpoint.path_params {
411        if let Some(value) = args.get(&param.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    // Add query parameters
432    let mut query_pairs: Vec<(String, String)> = Vec::new();
433    for param in &endpoint.query_params {
434        if let Some(value) = args.get(&param.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    // Add header parameters
449    for param in &endpoint.header_params {
450        if let Some(value) = args.get(&param.name) {
451            let value_str = match value {
452                Value::String(s) => s.clone(),
453                _ => value.to_string(),
454            };
455            request = request.header(&param.name, &value_str);
456        }
457    }
458
459    // Add request body
460    if endpoint.has_body
461        && let Some(body) = args.get("body")
462    {
463        request = request.json(body);
464    }
465
466    // Add authentication
467    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    // Execute request
478    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// ── Tests ────────────────────────────────────────────────────────────────────
498
499#[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        // Check listPets
570        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        // Check createPet
579        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        // Check showPetById
584        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        // Should have "limit" in properties
597        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        // limit is not required
602        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        // Without operationId, name should be generated from method + path
633        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}