Skip to main content

composio_sdk/utils/
openapi.rs

1//! OpenAPI schema utilities
2//!
3//! This module provides utilities for working with OpenAPI schemas,
4//! including type conversion, validation, and schema manipulation.
5//!
6//! # Features
7//!
8//! - Convert OpenAPI types to Rust types
9//! - Handle composite types (oneOf, anyOf, allOf)
10//! - Validate JSON values against schemas
11//! - Generate type-safe parameter structures
12
13use crate::error::ComposioError;
14use serde_json::{json, Value};
15use std::collections::HashMap;
16
17/// OpenAPI primitive types
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum OpenApiType {
20    Null,
21    Boolean,
22    Integer,
23    Number,
24    String,
25    Array,
26    Object,
27}
28
29impl OpenApiType {
30    /// Parse OpenAPI type from string
31    pub fn from_str(s: &str) -> Result<Self, ComposioError> {
32        match s {
33            "null" => Ok(Self::Null),
34            "boolean" => Ok(Self::Boolean),
35            "integer" => Ok(Self::Integer),
36            "number" => Ok(Self::Number),
37            "string" => Ok(Self::String),
38            "array" => Ok(Self::Array),
39            "object" => Ok(Self::Object),
40            _ => Err(ComposioError::InvalidSchema(format!(
41                "Unknown OpenAPI type: {}",
42                s
43            ))),
44        }
45    }
46
47    /// Get Rust type name as string
48    pub fn to_rust_type(&self) -> &'static str {
49        match self {
50            Self::Null => "Option<()>",
51            Self::Boolean => "bool",
52            Self::Integer => "i64",
53            Self::Number => "f64",
54            Self::String => "String",
55            Self::Array => "Vec<Value>",
56            Self::Object => "HashMap<String, Value>",
57        }
58    }
59}
60
61/// OpenAPI schema wrapper
62#[derive(Debug, Clone)]
63pub struct OpenApiSchema {
64    schema: Value,
65}
66
67impl OpenApiSchema {
68    /// Create a new schema from JSON value
69    pub fn new(schema: Value) -> Self {
70        Self { schema }
71    }
72
73    /// Get the type of this schema
74    pub fn get_type(&self) -> Result<OpenApiType, ComposioError> {
75        if let Some(type_str) = self.schema.get("type").and_then(|v| v.as_str()) {
76            OpenApiType::from_str(type_str)
77        } else if self.schema.get("oneOf").is_some()
78            || self.schema.get("anyOf").is_some()
79            || self.schema.get("allOf").is_some()
80        {
81            // Composite types are treated as generic objects
82            Ok(OpenApiType::Object)
83        } else {
84            // No type specified - treat as Any (object)
85            Ok(OpenApiType::Object)
86        }
87    }
88
89    /// Check if this schema has an enum constraint
90    pub fn is_enum(&self) -> bool {
91        self.schema.get("enum").is_some()
92    }
93
94    /// Get enum values if this is an enum schema
95    pub fn get_enum_values(&self) -> Option<Vec<Value>> {
96        self.schema
97            .get("enum")
98            .and_then(|v| v.as_array())
99            .map(|arr| arr.clone())
100    }
101
102    /// Check if this is a composite schema (oneOf, anyOf, allOf)
103    pub fn is_composite(&self) -> bool {
104        self.schema.get("oneOf").is_some()
105            || self.schema.get("anyOf").is_some()
106            || self.schema.get("allOf").is_some()
107    }
108
109    /// Get composite schemas
110    pub fn get_composite_schemas(&self) -> Option<(CompositeType, Vec<OpenApiSchema>)> {
111        if let Some(schemas) = self.schema.get("oneOf").and_then(|v| v.as_array()) {
112            return Some((
113                CompositeType::OneOf,
114                schemas.iter().map(|s| OpenApiSchema::new(s.clone())).collect(),
115            ));
116        }
117        if let Some(schemas) = self.schema.get("anyOf").and_then(|v| v.as_array()) {
118            return Some((
119                CompositeType::AnyOf,
120                schemas.iter().map(|s| OpenApiSchema::new(s.clone())).collect(),
121            ));
122        }
123        if let Some(schemas) = self.schema.get("allOf").and_then(|v| v.as_array()) {
124            return Some((
125                CompositeType::AllOf,
126                schemas.iter().map(|s| OpenApiSchema::new(s.clone())).collect(),
127            ));
128        }
129        None
130    }
131
132    /// Get array item schema
133    pub fn get_array_items(&self) -> Option<OpenApiSchema> {
134        self.schema
135            .get("items")
136            .map(|items| OpenApiSchema::new(items.clone()))
137    }
138
139    /// Get object properties
140    pub fn get_properties(&self) -> HashMap<String, OpenApiSchema> {
141        self.schema
142            .get("properties")
143            .and_then(|v| v.as_object())
144            .map(|props| {
145                props
146                    .iter()
147                    .map(|(k, v)| (k.clone(), OpenApiSchema::new(v.clone())))
148                    .collect()
149            })
150            .unwrap_or_default()
151    }
152
153    /// Get required property names
154    pub fn get_required(&self) -> Vec<String> {
155        self.schema
156            .get("required")
157            .and_then(|v| v.as_array())
158            .map(|arr| {
159                arr.iter()
160                    .filter_map(|v| v.as_str().map(String::from))
161                    .collect()
162            })
163            .unwrap_or_default()
164    }
165
166    /// Get default value
167    pub fn get_default(&self) -> Option<Value> {
168        self.schema.get("default").cloned()
169    }
170
171    /// Get description
172    pub fn get_description(&self) -> Option<String> {
173        self.schema
174            .get("description")
175            .and_then(|v| v.as_str())
176            .map(String::from)
177    }
178
179    /// Validate a value against this schema
180    pub fn validate(&self, value: &Value) -> Result<(), ComposioError> {
181        // Basic type validation
182        let schema_type = self.get_type()?;
183        
184        match schema_type {
185            OpenApiType::Null => {
186                if !value.is_null() {
187                    return Err(ComposioError::InvalidSchema(
188                        "Expected null value".to_string(),
189                    ));
190                }
191            }
192            OpenApiType::Boolean => {
193                if !value.is_boolean() {
194                    return Err(ComposioError::InvalidSchema(
195                        "Expected boolean value".to_string(),
196                    ));
197                }
198            }
199            OpenApiType::Integer => {
200                if !value.is_i64() && !value.is_u64() {
201                    return Err(ComposioError::InvalidSchema(
202                        "Expected integer value".to_string(),
203                    ));
204                }
205            }
206            OpenApiType::Number => {
207                if !value.is_number() {
208                    return Err(ComposioError::InvalidSchema(
209                        "Expected number value".to_string(),
210                    ));
211                }
212            }
213            OpenApiType::String => {
214                if !value.is_string() {
215                    return Err(ComposioError::InvalidSchema(
216                        "Expected string value".to_string(),
217                    ));
218                }
219            }
220            OpenApiType::Array => {
221                if !value.is_array() {
222                    return Err(ComposioError::InvalidSchema(
223                        "Expected array value".to_string(),
224                    ));
225                }
226                // Validate array items if schema is provided
227                if let Some(items_schema) = self.get_array_items() {
228                    if let Some(arr) = value.as_array() {
229                        for item in arr {
230                            items_schema.validate(item)?;
231                        }
232                    }
233                }
234            }
235            OpenApiType::Object => {
236                if !value.is_object() {
237                    return Err(ComposioError::InvalidSchema(
238                        "Expected object value".to_string(),
239                    ));
240                }
241            }
242        }
243
244        // Enum validation
245        if self.is_enum() {
246            if let Some(enum_values) = self.get_enum_values() {
247                if !enum_values.contains(value) {
248                    return Err(ComposioError::InvalidSchema(format!(
249                        "Value {:?} not in enum: {:?}",
250                        value, enum_values
251                    )));
252                }
253            }
254        }
255
256        Ok(())
257    }
258
259    /// Convert to a Rust type description string
260    pub fn to_rust_type_string(&self) -> String {
261        if self.is_enum() {
262            if let Some(values) = self.get_enum_values() {
263                let variants: Vec<String> = values
264                    .iter()
265                    .filter_map(|v| {
266                        if let Some(s) = v.as_str() {
267                            Some(format!("\"{}\"", s))
268                        } else {
269                            Some(v.to_string())
270                        }
271                    })
272                    .collect();
273                return format!("Enum({})", variants.join(" | "));
274            }
275        }
276
277        if let Some((composite_type, schemas)) = self.get_composite_schemas() {
278            let type_strings: Vec<String> = schemas
279                .iter()
280                .map(|s| s.to_rust_type_string())
281                .collect();
282            return match composite_type {
283                CompositeType::OneOf | CompositeType::AnyOf => {
284                    format!("Union({})", type_strings.join(" | "))
285                }
286                CompositeType::AllOf => {
287                    format!("Intersection({})", type_strings.join(" & "))
288                }
289            };
290        }
291
292        match self.get_type() {
293            Ok(t) => t.to_rust_type().to_string(),
294            Err(_) => "Value".to_string(),
295        }
296    }
297}
298
299/// Composite schema type
300#[derive(Debug, Clone, Copy, PartialEq, Eq)]
301pub enum CompositeType {
302    OneOf,
303    AnyOf,
304    AllOf,
305}
306
307/// Parameter definition extracted from OpenAPI schema
308#[derive(Debug, Clone)]
309pub struct ParameterDefinition {
310    /// Parameter name
311    pub name: String,
312
313    /// Parameter type description
314    pub type_description: String,
315
316    /// Whether this parameter is required
317    pub required: bool,
318
319    /// Default value if any
320    pub default: Option<Value>,
321
322    /// Parameter description
323    pub description: Option<String>,
324
325    /// Original schema
326    pub schema: OpenApiSchema,
327}
328
329/// Extract parameter definitions from an OpenAPI schema
330///
331/// This function analyzes an OpenAPI schema (typically from a tool's input_parameters)
332/// and extracts structured parameter information.
333///
334/// # Arguments
335///
336/// * `schema` - The OpenAPI schema (should be an object schema with properties)
337///
338/// # Returns
339///
340/// A vector of parameter definitions
341pub fn extract_parameters(schema: &Value) -> Vec<ParameterDefinition> {
342    let schema = OpenApiSchema::new(schema.clone());
343    let properties = schema.get_properties();
344    let required = schema.get_required();
345    let required_set: std::collections::HashSet<_> = required.iter().collect();
346
347    properties
348        .into_iter()
349        .map(|(name, prop_schema)| ParameterDefinition {
350            type_description: prop_schema.to_rust_type_string(),
351            required: required_set.contains(&name),
352            default: prop_schema.get_default(),
353            description: prop_schema.get_description(),
354            schema: prop_schema.clone(),
355            name,
356        })
357        .collect()
358}
359
360/// Merge multiple schemas (for allOf)
361pub fn merge_schemas(schemas: &[Value]) -> Value {
362    let mut merged = json!({
363        "type": "object",
364        "properties": {},
365        "required": []
366    });
367
368    for schema in schemas {
369        if let Some(props) = schema.get("properties").and_then(|v| v.as_object()) {
370            if let Some(merged_props) = merged.get_mut("properties").and_then(|v| v.as_object_mut()) {
371                for (key, value) in props {
372                    merged_props.insert(key.clone(), value.clone());
373                }
374            }
375        }
376
377        if let Some(req) = schema.get("required").and_then(|v| v.as_array()) {
378            if let Some(merged_req) = merged.get_mut("required").and_then(|v| v.as_array_mut()) {
379                for item in req {
380                    if !merged_req.contains(item) {
381                        merged_req.push(item.clone());
382                    }
383                }
384            }
385        }
386    }
387
388    merged
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    #[test]
396    fn test_openapi_type_from_str() {
397        assert_eq!(OpenApiType::from_str("string").unwrap(), OpenApiType::String);
398        assert_eq!(OpenApiType::from_str("integer").unwrap(), OpenApiType::Integer);
399        assert_eq!(OpenApiType::from_str("boolean").unwrap(), OpenApiType::Boolean);
400        assert!(OpenApiType::from_str("invalid").is_err());
401    }
402
403    #[test]
404    fn test_openapi_type_to_rust() {
405        assert_eq!(OpenApiType::String.to_rust_type(), "String");
406        assert_eq!(OpenApiType::Integer.to_rust_type(), "i64");
407        assert_eq!(OpenApiType::Boolean.to_rust_type(), "bool");
408    }
409
410    #[test]
411    fn test_schema_get_type() {
412        let schema = json!({"type": "string"});
413        let openapi_schema = OpenApiSchema::new(schema);
414        assert_eq!(openapi_schema.get_type().unwrap(), OpenApiType::String);
415    }
416
417    #[test]
418    fn test_schema_is_enum() {
419        let schema = json!({
420            "type": "string",
421            "enum": ["option1", "option2"]
422        });
423        let openapi_schema = OpenApiSchema::new(schema);
424        assert!(openapi_schema.is_enum());
425        assert_eq!(openapi_schema.get_enum_values().unwrap().len(), 2);
426    }
427
428    #[test]
429    fn test_schema_validate_string() {
430        let schema = json!({"type": "string"});
431        let openapi_schema = OpenApiSchema::new(schema);
432        
433        assert!(openapi_schema.validate(&json!("hello")).is_ok());
434        assert!(openapi_schema.validate(&json!(123)).is_err());
435    }
436
437    #[test]
438    fn test_schema_validate_enum() {
439        let schema = json!({
440            "type": "string",
441            "enum": ["red", "green", "blue"]
442        });
443        let openapi_schema = OpenApiSchema::new(schema);
444        
445        assert!(openapi_schema.validate(&json!("red")).is_ok());
446        assert!(openapi_schema.validate(&json!("yellow")).is_err());
447    }
448
449    #[test]
450    fn test_schema_get_properties() {
451        let schema = json!({
452            "type": "object",
453            "properties": {
454                "name": {"type": "string"},
455                "age": {"type": "integer"}
456            }
457        });
458        let openapi_schema = OpenApiSchema::new(schema);
459        let props = openapi_schema.get_properties();
460        
461        assert_eq!(props.len(), 2);
462        assert!(props.contains_key("name"));
463        assert!(props.contains_key("age"));
464    }
465
466    #[test]
467    fn test_schema_get_required() {
468        let schema = json!({
469            "type": "object",
470            "properties": {
471                "name": {"type": "string"},
472                "age": {"type": "integer"}
473            },
474            "required": ["name"]
475        });
476        let openapi_schema = OpenApiSchema::new(schema);
477        let required = openapi_schema.get_required();
478        
479        assert_eq!(required.len(), 1);
480        assert_eq!(required[0], "name");
481    }
482
483    #[test]
484    fn test_extract_parameters() {
485        let schema = json!({
486            "type": "object",
487            "properties": {
488                "title": {
489                    "type": "string",
490                    "description": "Issue title"
491                },
492                "body": {
493                    "type": "string",
494                    "default": ""
495                },
496                "priority": {
497                    "type": "string",
498                    "enum": ["low", "medium", "high"]
499                }
500            },
501            "required": ["title"]
502        });
503
504        let params = extract_parameters(&schema);
505        assert_eq!(params.len(), 3);
506        
507        let title_param = params.iter().find(|p| p.name == "title").unwrap();
508        assert!(title_param.required);
509        assert_eq!(title_param.description, Some("Issue title".to_string()));
510        
511        let body_param = params.iter().find(|p| p.name == "body").unwrap();
512        assert!(!body_param.required);
513        assert_eq!(body_param.default, Some(json!("")));
514    }
515
516    #[test]
517    fn test_composite_schema_oneof() {
518        let schema = json!({
519            "oneOf": [
520                {"type": "string"},
521                {"type": "integer"}
522            ]
523        });
524        let openapi_schema = OpenApiSchema::new(schema);
525        
526        assert!(openapi_schema.is_composite());
527        let (composite_type, schemas) = openapi_schema.get_composite_schemas().unwrap();
528        assert_eq!(composite_type, CompositeType::OneOf);
529        assert_eq!(schemas.len(), 2);
530    }
531
532    #[test]
533    fn test_merge_schemas() {
534        let schema1 = json!({
535            "type": "object",
536            "properties": {
537                "name": {"type": "string"}
538            },
539            "required": ["name"]
540        });
541        
542        let schema2 = json!({
543            "type": "object",
544            "properties": {
545                "age": {"type": "integer"}
546            },
547            "required": ["age"]
548        });
549
550        let merged = merge_schemas(&[schema1, schema2]);
551        let props = merged.get("properties").unwrap().as_object().unwrap();
552        let required = merged.get("required").unwrap().as_array().unwrap();
553        
554        assert_eq!(props.len(), 2);
555        assert_eq!(required.len(), 2);
556    }
557
558    #[test]
559    fn test_array_schema() {
560        let schema = json!({
561            "type": "array",
562            "items": {"type": "string"}
563        });
564        let openapi_schema = OpenApiSchema::new(schema);
565        
566        assert_eq!(openapi_schema.get_type().unwrap(), OpenApiType::Array);
567        assert!(openapi_schema.get_array_items().is_some());
568        
569        // Validate array with correct items
570        assert!(openapi_schema.validate(&json!(["a", "b", "c"])).is_ok());
571        // Validate array with incorrect items
572        assert!(openapi_schema.validate(&json!([1, 2, 3])).is_err());
573    }
574}