rs_utcp/openapi/
mod.rs

1use anyhow::{anyhow, Result};
2use reqwest::Url;
3use serde_json::{Map, Value};
4use std::collections::HashMap;
5
6use crate::auth::{ApiKeyAuth, AuthConfig, AuthType, BasicAuth, OAuth2Auth};
7use crate::providers::base::{BaseProvider, ProviderType};
8use crate::providers::http::HttpProvider;
9use crate::tools::{Tool, ToolInputOutputSchema};
10
11pub const VERSION: &str = "1.0";
12
13/// Representation of a generated UTCP manual derived from an OpenAPI spec.
14#[derive(Debug, Clone)]
15pub struct UtcpManual {
16    pub version: String,
17    pub tools: Vec<Tool>,
18}
19
20/// Converts OpenAPI v2/v3 documents into UTCP tool definitions.
21pub struct OpenApiConverter {
22    spec: Value,
23    spec_url: Option<String>,
24    provider_name: String,
25}
26
27impl OpenApiConverter {
28    /// Build a converter from an already loaded spec value.
29    pub fn new(
30        openapi_spec: Value,
31        spec_url: Option<String>,
32        provider_name: Option<String>,
33    ) -> Self {
34        let provider_name = provider_name
35            .filter(|name| !name.is_empty())
36            .unwrap_or_else(|| derive_provider_name(&openapi_spec));
37
38        Self {
39            spec: openapi_spec,
40            spec_url,
41            provider_name,
42        }
43    }
44
45    /// Fetch and parse a remote OpenAPI document, inferring a provider name when missing.
46    pub async fn new_from_url(spec_url: &str, provider_name: Option<String>) -> Result<Self> {
47        let (spec, final_url) = load_spec_from_url(spec_url).await?;
48        Ok(Self::new(spec, Some(final_url), provider_name))
49    }
50
51    /// Convert the OpenAPI document into a UTCP manual containing tools and metadata.
52    pub fn convert(&self) -> UtcpManual {
53        let mut tools = Vec::new();
54        let base_url = self.base_url();
55
56        if let Some(paths) = self.spec.get("paths").and_then(|v| v.as_object()) {
57            for (raw_path, raw_item) in paths {
58                if let Some(path_item) = raw_item.as_object() {
59                    for (method, raw_op) in path_item {
60                        let lower = method.to_ascii_lowercase();
61                        if !matches!(lower.as_str(), "get" | "post" | "put" | "delete" | "patch") {
62                            continue;
63                        }
64
65                        if let Some(op) = raw_op.as_object() {
66                            if let Ok(Some(tool)) =
67                                self.create_tool(raw_path, &lower, op, &base_url)
68                            {
69                                tools.push(tool);
70                            }
71                        }
72                    }
73                }
74            }
75        }
76
77        UtcpManual {
78            version: VERSION.to_string(),
79            tools,
80        }
81    }
82
83    fn base_url(&self) -> String {
84        if let Some(servers) = self.spec.get("servers").and_then(|v| v.as_array()) {
85            if let Some(first) = servers.first().and_then(|v| v.as_object()) {
86                if let Some(url) = first.get("url").and_then(|v| v.as_str()) {
87                    if !url.is_empty() {
88                        return url.to_string();
89                    }
90                }
91            }
92        }
93
94        if let Some(host) = self.spec.get("host").and_then(|v| v.as_str()) {
95            let scheme = self
96                .spec
97                .get("schemes")
98                .and_then(|v| v.as_array())
99                .and_then(|arr| arr.first())
100                .and_then(|v| v.as_str())
101                .unwrap_or("https");
102            let base_path = self
103                .spec
104                .get("basePath")
105                .and_then(|v| v.as_str())
106                .unwrap_or("");
107            return format!("{}://{}{}", scheme, host, base_path);
108        }
109
110        if let Some(spec_url) = &self.spec_url {
111            if let Ok(parsed) = Url::parse(spec_url) {
112                if let Some(host) = parsed.host_str() {
113                    return format!("{}://{}", parsed.scheme(), host);
114                }
115            }
116        }
117
118        "/".to_string()
119    }
120
121    fn resolve_ref(&self, reference: &str) -> Result<Value> {
122        if !reference.starts_with("#/") {
123            return Err(anyhow!("only local refs supported, got {}", reference));
124        }
125        let pointer = &reference[1..];
126        self.spec
127            .pointer(pointer)
128            .cloned()
129            .ok_or_else(|| anyhow!("ref {} not found", reference))
130    }
131
132    fn resolve_schema(&self, schema: Value) -> Value {
133        match schema {
134            Value::Object(map) => {
135                if let Some(Value::String(reference)) = map.get("$ref").cloned() {
136                    if let Ok(resolved) = self.resolve_ref(&reference) {
137                        return self.resolve_schema(resolved);
138                    }
139                    return Value::Object(map);
140                }
141
142                let mut out = Map::new();
143                for (k, v) in map {
144                    out.insert(k, self.resolve_schema(v));
145                }
146                Value::Object(out)
147            }
148            Value::Array(arr) => Value::Array(
149                arr.into_iter()
150                    .map(|item| self.resolve_schema(item))
151                    .collect(),
152            ),
153            other => other,
154        }
155    }
156
157    fn extract_auth(&self, operation: &Map<String, Value>) -> Option<AuthConfig> {
158        let mut reqs = Vec::new();
159        if let Some(op_sec) = operation.get("security").and_then(|v| v.as_array()) {
160            if !op_sec.is_empty() {
161                reqs = op_sec.clone();
162            }
163        }
164        if reqs.is_empty() {
165            if let Some(global) = self.spec.get("security").and_then(|v| v.as_array()) {
166                reqs = global.clone();
167            }
168        }
169        if reqs.is_empty() {
170            return None;
171        }
172
173        let schemes = self.get_security_schemes().unwrap_or_default();
174        for raw in reqs {
175            if let Some(sec_map) = raw.as_object() {
176                for name in sec_map.keys() {
177                    if let Some(Value::Object(scheme)) = schemes.get(name) {
178                        if let Some(auth) = self.create_auth_from_scheme(scheme) {
179                            return Some(auth);
180                        }
181                    }
182                }
183            }
184        }
185        None
186    }
187
188    fn get_security_schemes(&self) -> Option<Map<String, Value>> {
189        if let Some(components) = self.spec.get("components").and_then(|v| v.as_object()) {
190            if let Some(security_schemes) = components
191                .get("securitySchemes")
192                .and_then(|v| v.as_object())
193            {
194                return Some(security_schemes.clone());
195            }
196        }
197        if let Some(defs) = self
198            .spec
199            .get("securityDefinitions")
200            .and_then(|v| v.as_object())
201        {
202            return Some(defs.clone());
203        }
204        None
205    }
206
207    fn create_auth_from_scheme(&self, scheme: &Map<String, Value>) -> Option<AuthConfig> {
208        let typ = scheme
209            .get("type")
210            .and_then(|v| v.as_str())
211            .unwrap_or("")
212            .to_ascii_lowercase();
213
214        match typ.as_str() {
215            "apikey" => {
216                let location = scheme
217                    .get("in")
218                    .and_then(|v| v.as_str())
219                    .unwrap_or("")
220                    .to_string();
221                let name = scheme
222                    .get("name")
223                    .and_then(|v| v.as_str())
224                    .unwrap_or("")
225                    .to_string();
226                if location.is_empty() || name.is_empty() {
227                    return None;
228                }
229                let auth = ApiKeyAuth {
230                    auth_type: AuthType::ApiKey,
231                    api_key: format!("${{{}_API_KEY}}", self.provider_name.to_uppercase()),
232                    var_name: name,
233                    location,
234                };
235                Some(AuthConfig::ApiKey(auth))
236            }
237            "basic" => {
238                let auth = BasicAuth {
239                    auth_type: AuthType::Basic,
240                    username: format!("${{{}_USERNAME}}", self.provider_name.to_uppercase()),
241                    password: format!("${{{}_PASSWORD}}", self.provider_name.to_uppercase()),
242                };
243                Some(AuthConfig::Basic(auth))
244            }
245            "http" => {
246                let scheme_name = scheme
247                    .get("scheme")
248                    .and_then(|v| v.as_str())
249                    .unwrap_or("")
250                    .to_ascii_lowercase();
251                match scheme_name.as_str() {
252                    "basic" => {
253                        let auth = BasicAuth {
254                            auth_type: AuthType::Basic,
255                            username: format!(
256                                "${{{}_USERNAME}}",
257                                self.provider_name.to_uppercase()
258                            ),
259                            password: format!(
260                                "${{{}_PASSWORD}}",
261                                self.provider_name.to_uppercase()
262                            ),
263                        };
264                        Some(AuthConfig::Basic(auth))
265                    }
266                    "bearer" => {
267                        let auth = ApiKeyAuth {
268                            auth_type: AuthType::ApiKey,
269                            api_key: format!(
270                                "Bearer ${{{}_API_KEY}}",
271                                self.provider_name.to_uppercase()
272                            ),
273                            var_name: "Authorization".to_string(),
274                            location: "header".to_string(),
275                        };
276                        Some(AuthConfig::ApiKey(auth))
277                    }
278                    _ => None,
279                }
280            }
281            "oauth2" => {
282                if let Some(flows) = scheme.get("flows").and_then(|v| v.as_object()) {
283                    for raw_flow in flows.values() {
284                        if let Some(flow) = raw_flow.as_object() {
285                            if let Some(token_url) = flow.get("tokenUrl").and_then(|v| v.as_str()) {
286                                let scope = flow
287                                    .get("scopes")
288                                    .and_then(|v| v.as_object())
289                                    .map(|m| m.keys().cloned().collect::<Vec<_>>().join(" "));
290                                let auth = OAuth2Auth {
291                                    auth_type: AuthType::OAuth2,
292                                    token_url: token_url.to_string(),
293                                    client_id: format!(
294                                        "${{{}_CLIENT_ID}}",
295                                        self.provider_name.to_uppercase()
296                                    ),
297                                    client_secret: format!(
298                                        "${{{}_CLIENT_SECRET}}",
299                                        self.provider_name.to_uppercase()
300                                    ),
301                                    scope: optional_string(scope.unwrap_or_default()),
302                                };
303                                return Some(AuthConfig::OAuth2(auth));
304                            }
305                        }
306                    }
307                }
308
309                if let Some(token_url) = scheme.get("tokenUrl").and_then(|v| v.as_str()) {
310                    let scope = scheme
311                        .get("scopes")
312                        .and_then(|v| v.as_object())
313                        .map(|m| m.keys().cloned().collect::<Vec<_>>().join(" "));
314                    let auth = OAuth2Auth {
315                        auth_type: AuthType::OAuth2,
316                        token_url: token_url.to_string(),
317                        client_id: format!("${{{}_CLIENT_ID}}", self.provider_name.to_uppercase()),
318                        client_secret: format!(
319                            "${{{}_CLIENT_SECRET}}",
320                            self.provider_name.to_uppercase()
321                        ),
322                        scope: optional_string(scope.unwrap_or_default()),
323                    };
324                    return Some(AuthConfig::OAuth2(auth));
325                }
326
327                None
328            }
329            _ => None,
330        }
331    }
332
333    fn create_tool(
334        &self,
335        path: &str,
336        method: &str,
337        op: &Map<String, Value>,
338        base_url: &str,
339    ) -> Result<Option<Tool>> {
340        let op_id = op
341            .get("operationId")
342            .and_then(|v| v.as_str())
343            .map(|s| s.to_string())
344            .unwrap_or_else(|| {
345                let sanitized_path = path.trim_matches('/').replace('/', "_");
346                format!("{}_{}", method.to_ascii_lowercase(), sanitized_path)
347            });
348
349        let description = op
350            .get("summary")
351            .and_then(|v| v.as_str())
352            .filter(|s| !s.is_empty())
353            .map(|s| s.to_string())
354            .or_else(|| {
355                op.get("description")
356                    .and_then(|v| v.as_str())
357                    .map(|s| s.to_string())
358            })
359            .unwrap_or_default();
360
361        let tags = op
362            .get("tags")
363            .and_then(|v| v.as_array())
364            .map(|arr| {
365                arr.iter()
366                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
367                    .collect::<Vec<_>>()
368            })
369            .unwrap_or_default();
370
371        let (input_schema, headers, body_field) = self.extract_inputs(op);
372        let output_schema = self.extract_outputs(op);
373        let auth = self.extract_auth(op);
374
375        let provider = HttpProvider {
376            base: BaseProvider {
377                name: self.provider_name.clone(),
378                provider_type: ProviderType::Http,
379                auth,
380                allowed_communication_protocols: None,
381            },
382            http_method: method.to_ascii_uppercase(),
383            url: join_url(base_url, path),
384            content_type: Some("application/json".to_string()),
385            headers: None,
386            body_field,
387            header_fields: if headers.is_empty() {
388                None
389            } else {
390                Some(headers)
391            },
392        };
393
394        let provider_value = serde_json::to_value(provider)?;
395        Ok(Some(Tool {
396            name: op_id,
397            description,
398            inputs: input_schema,
399            outputs: output_schema,
400            tags,
401            average_response_size: None,
402            provider: Some(provider_value),
403        }))
404    }
405
406    fn extract_inputs(
407        &self,
408        op: &Map<String, Value>,
409    ) -> (ToolInputOutputSchema, Vec<String>, Option<String>) {
410        let mut props: HashMap<String, Value> = HashMap::new();
411        let mut required: Vec<String> = Vec::new();
412        let mut headers = Vec::new();
413        let mut body_field: Option<String> = None;
414
415        if let Some(parameters) = op.get("parameters").and_then(|v| v.as_array()) {
416            for raw_param in parameters {
417                let param = self.resolve_schema(raw_param.clone());
418                if let Some(param_obj) = param.as_object() {
419                    let name = param_obj
420                        .get("name")
421                        .and_then(|v| v.as_str())
422                        .unwrap_or("")
423                        .to_string();
424                    let location = param_obj
425                        .get("in")
426                        .and_then(|v| v.as_str())
427                        .unwrap_or("")
428                        .to_string();
429                    if name.is_empty() {
430                        continue;
431                    }
432                    if location == "header" {
433                        headers.push(name.clone());
434                    }
435                    if location == "body" {
436                        body_field = Some(name.clone());
437                    }
438
439                    let schema_val = param_obj
440                        .get("schema")
441                        .cloned()
442                        .unwrap_or_else(|| Value::Object(Map::new()));
443                    let schema_obj = self.resolve_schema(schema_val);
444                    let schema_map = schema_obj.as_object().cloned().unwrap_or_default();
445                    let mut entry = Map::new();
446
447                    if let Some(desc) = param_obj.get("description") {
448                        entry.insert("description".to_string(), desc.clone());
449                    }
450                    if let Some(typ) = schema_map.get("type").or_else(|| param_obj.get("type")) {
451                        entry.insert("type".to_string(), typ.clone());
452                    }
453                    for (k, v) in schema_map {
454                        entry.insert(k, v);
455                    }
456                    if !entry.contains_key("type") {
457                        entry.insert("type".to_string(), Value::String("object".to_string()));
458                    }
459
460                    props.insert(name.clone(), Value::Object(entry));
461                    if param_obj
462                        .get("required")
463                        .and_then(|v| v.as_bool())
464                        .unwrap_or(false)
465                    {
466                        required.push(name);
467                    }
468                }
469            }
470        }
471
472        if let Some(request_body) = op.get("requestBody") {
473            let rb = self.resolve_schema(request_body.clone());
474            if let Some(rb_obj) = rb.as_object() {
475                if let Some(content) = rb_obj.get("content").and_then(|v| v.as_object()) {
476                    if let Some(app_json) =
477                        content.get("application/json").and_then(|v| v.as_object())
478                    {
479                        if let Some(schema) = app_json.get("schema") {
480                            let name = "body".to_string();
481                            body_field = Some(name.clone());
482                            let schema_obj = self.resolve_schema(schema.clone());
483                            let schema_map = schema_obj.as_object().cloned().unwrap_or_default();
484                            let mut entry = Map::new();
485                            if let Some(desc) = rb_obj.get("description") {
486                                entry.insert("description".to_string(), desc.clone());
487                            }
488                            for (k, v) in schema_map {
489                                entry.insert(k, v);
490                            }
491                            if !entry.contains_key("type") {
492                                entry.insert(
493                                    "type".to_string(),
494                                    Value::String("object".to_string()),
495                                );
496                            }
497                            props.insert(name.clone(), Value::Object(entry));
498                            if rb_obj
499                                .get("required")
500                                .and_then(|v| v.as_bool())
501                                .unwrap_or(false)
502                            {
503                                required.push(name);
504                            }
505                        }
506                    }
507                }
508            }
509        }
510
511        let schema = ToolInputOutputSchema {
512            type_: "object".to_string(),
513            properties: if props.is_empty() { None } else { Some(props) },
514            required: if required.is_empty() {
515                None
516            } else {
517                Some(required)
518            },
519            description: None,
520            title: None,
521            items: None,
522            enum_: None,
523            minimum: None,
524            maximum: None,
525            format: None,
526        };
527
528        (schema, headers, body_field)
529    }
530
531    fn extract_outputs(&self, op: &Map<String, Value>) -> ToolInputOutputSchema {
532        let default_schema = ToolInputOutputSchema {
533            type_: "object".to_string(),
534            properties: None,
535            required: None,
536            description: None,
537            title: None,
538            items: None,
539            enum_: None,
540            minimum: None,
541            maximum: None,
542            format: None,
543        };
544
545        let responses = match op.get("responses").and_then(|v| v.as_object()) {
546            Some(r) => r,
547            None => return default_schema,
548        };
549        let resp = match responses
550            .get("200")
551            .or_else(|| responses.get("201"))
552            .cloned()
553        {
554            Some(r) => r,
555            None => return default_schema,
556        };
557
558        let resp = self.resolve_schema(resp);
559        if let Some(resp_obj) = resp.as_object() {
560            if let Some(content) = resp_obj.get("content").and_then(|v| v.as_object()) {
561                if let Some(app_json) = content.get("application/json").and_then(|v| v.as_object())
562                {
563                    if let Some(schema) = app_json.get("schema") {
564                        let fallback = resp_obj
565                            .get("description")
566                            .and_then(|v| v.as_str())
567                            .map(|s| s.to_string());
568                        return self.build_schema_from_value(schema, fallback);
569                    }
570                }
571            }
572            if let Some(schema) = resp_obj.get("schema") {
573                let fallback = resp_obj
574                    .get("description")
575                    .and_then(|v| v.as_str())
576                    .map(|s| s.to_string());
577                return self.build_schema_from_value(schema, fallback);
578            }
579        }
580
581        default_schema
582    }
583
584    fn build_schema_from_value(
585        &self,
586        schema: &Value,
587        fallback_description: Option<String>,
588    ) -> ToolInputOutputSchema {
589        let resolved = self.resolve_schema(schema.clone());
590        let map = resolved.as_object().cloned().unwrap_or_default();
591
592        let mut out = ToolInputOutputSchema {
593            type_: map
594                .get("type")
595                .and_then(|v| v.as_str())
596                .unwrap_or("object")
597                .to_string(),
598            properties: map_from_value(map.get("properties")),
599            required: string_slice(map.get("required")),
600            description: match map
601                .get("description")
602                .and_then(|v| v.as_str())
603                .map(|s| s.to_string())
604            {
605                Some(desc) if !desc.is_empty() => Some(desc),
606                _ => fallback_description.filter(|s| !s.is_empty()),
607            },
608            title: map
609                .get("title")
610                .and_then(|v| v.as_str())
611                .map(|s| s.to_string()),
612            items: None,
613            enum_: map.get("enum").and_then(|v| interface_slice(v)),
614            minimum: cast_float(map.get("minimum")),
615            maximum: cast_float(map.get("maximum")),
616            format: map
617                .get("format")
618                .and_then(|v| v.as_str())
619                .map(|s| s.to_string()),
620        };
621
622        if out.type_ == "array" {
623            out.items = map_from_value(map.get("items"));
624        }
625
626        out
627    }
628}
629
630/// Load an OpenAPI/Swagger document from a URL, handling JSON or YAML.
631pub async fn load_spec_from_url(raw_url: &str) -> Result<(Value, String)> {
632    let resp = reqwest::get(raw_url).await?;
633    let status = resp.status();
634    if !status.is_success() {
635        return Err(anyhow!("unexpected HTTP status: {}", status));
636    }
637
638    let final_url = resp.url().to_string();
639    let bytes = resp.bytes().await?;
640
641    if let Ok(json_spec) = serde_json::from_slice::<Value>(&bytes) {
642        return Ok((json_spec, final_url));
643    }
644
645    let yaml_value: serde_yaml::Value = serde_yaml::from_slice(&bytes)
646        .map_err(|err| anyhow!("failed to parse as JSON or YAML: {}", err))?;
647    let json_value = serde_json::to_value(yaml_value)?;
648    Ok((json_value, final_url))
649}
650
651fn derive_provider_name(spec: &Value) -> String {
652    let title = spec
653        .get("info")
654        .and_then(|v| v.as_object())
655        .and_then(|info| info.get("title"))
656        .and_then(|v| v.as_str())
657        .unwrap_or("")
658        .to_string();
659    let base = if title.is_empty() {
660        "openapi_provider".to_string()
661    } else {
662        title
663    };
664    let invalid = " -.,!?'\"\\\\/()[]{}#@$%^&*+=~`|;:<>";
665
666    let mut output = String::new();
667    for ch in base.chars() {
668        if invalid.contains(ch) {
669            output.push('_');
670        } else {
671            output.push(ch);
672        }
673    }
674    if output.is_empty() {
675        "openapi_provider".to_string()
676    } else {
677        output
678    }
679}
680
681fn optional_string(s: String) -> Option<String> {
682    if s.is_empty() {
683        None
684    } else {
685        Some(s)
686    }
687}
688
689fn join_url(base: &str, path: &str) -> String {
690    let trimmed_base = base.trim_end_matches('/');
691    let trimmed_path = path.trim_start_matches('/');
692    if trimmed_base.is_empty() {
693        format!("/{}", trimmed_path)
694    } else if trimmed_path.is_empty() {
695        trimmed_base.to_string()
696    } else {
697        format!("{}/{}", trimmed_base, trimmed_path)
698    }
699}
700
701fn map_from_value(value: Option<&Value>) -> Option<HashMap<String, Value>> {
702    value.and_then(|v| v.as_object()).map(|obj| {
703        obj.iter()
704            .map(|(k, v)| (k.clone(), v.clone()))
705            .collect::<HashMap<_, _>>()
706    })
707}
708
709fn string_slice(value: Option<&Value>) -> Option<Vec<String>> {
710    value.and_then(|v| v.as_array()).map(|arr| {
711        let collected = arr
712            .iter()
713            .filter_map(|v| v.as_str().map(|s| s.to_string()))
714            .collect::<Vec<_>>();
715        if collected.is_empty() {
716            None
717        } else {
718            Some(collected)
719        }
720    })?
721}
722
723fn interface_slice(value: &Value) -> Option<Vec<Value>> {
724    value.as_array().map(|arr| {
725        if arr.is_empty() {
726            None
727        } else {
728            Some(arr.to_vec())
729        }
730    })?
731}
732
733fn cast_float(value: Option<&Value>) -> Option<f64> {
734    value.and_then(|v| {
735        if let Some(n) = v.as_f64() {
736            Some(n)
737        } else if let Some(i) = v.as_i64() {
738            Some(i as f64)
739        } else {
740            None
741        }
742    })
743}
744
745#[cfg(test)]
746mod tests {
747    use super::*;
748    use serde_json::json;
749
750    fn build_test_converter() -> OpenApiConverter {
751        let spec = json!({
752            "info": {"title": "Test"},
753            "components": {
754                "schemas": {
755                    "Obj": {
756                        "type": "object",
757                        "properties": { "name": { "type": "string" } }
758                    }
759                },
760                "securitySchemes": {
761                    "apiKey": { "type": "apiKey", "name": "X-Token", "in": "header" },
762                    "basicAuth": { "type": "http", "scheme": "basic" }
763                }
764            },
765            "security": [ { "apiKey": [] } ]
766        });
767
768        OpenApiConverter::new(
769            spec,
770            Some("https://api.example.com/spec.json".to_string()),
771            Some("test".to_string()),
772        )
773    }
774
775    #[test]
776    fn resolve_ref_and_schema() {
777        let converter = build_test_converter();
778        let obj = converter.resolve_ref("#/components/schemas/Obj").unwrap();
779        assert_eq!(obj.get("type").and_then(|v| v.as_str()), Some("object"));
780        assert!(converter.resolve_ref("#/bad/ref").is_err());
781
782        let resolved = converter.resolve_schema(json!({"$ref": "#/components/schemas/Obj"}));
783        assert_eq!(
784            resolved
785                .get("properties")
786                .and_then(|v| v.get("name"))
787                .and_then(|v| v.get("type"))
788                .and_then(|v| v.as_str()),
789            Some("string")
790        );
791    }
792
793    #[test]
794    fn create_auth_from_scheme_and_extract() {
795        let converter = build_test_converter();
796        let api_key = converter
797            .create_auth_from_scheme(
798                &json!({"type": "apiKey", "in": "header", "name": "X"})
799                    .as_object()
800                    .unwrap(),
801            )
802            .unwrap();
803        match api_key {
804            AuthConfig::ApiKey(auth) => {
805                assert_eq!(auth.var_name, "X");
806                assert_eq!(auth.location, "header");
807                assert_eq!(auth.api_key, "${TEST_API_KEY}");
808            }
809            _ => panic!("expected ApiKey auth"),
810        }
811
812        let basic = converter
813            .create_auth_from_scheme(
814                &json!({"type": "http", "scheme": "basic"})
815                    .as_object()
816                    .unwrap(),
817            )
818            .unwrap();
819        matches!(basic, AuthConfig::Basic(_));
820
821        let bearer = converter
822            .create_auth_from_scheme(
823                &json!({"type": "http", "scheme": "bearer"})
824                    .as_object()
825                    .unwrap(),
826            )
827            .unwrap();
828        match bearer {
829            AuthConfig::ApiKey(auth) => {
830                assert_eq!(auth.var_name, "Authorization");
831                assert!(auth.api_key.contains("${TEST_API_KEY}"));
832            }
833            _ => panic!("expected bearer api key auth"),
834        }
835
836        let mut op = Map::new();
837        op.insert("security".to_string(), json!([{"basicAuth": []}]));
838        let auth = converter.extract_auth(&op).unwrap();
839        matches!(auth, AuthConfig::Basic(_));
840    }
841
842    #[test]
843    fn inputs_outputs_and_create_tool() {
844        let converter = build_test_converter();
845        let op_value = json!({
846            "operationId": "ping",
847            "summary": "Ping",
848            "tags": ["t"],
849            "parameters": [
850                { "name": "id", "in": "query", "required": true, "schema": { "type": "string" }},
851                { "name": "X", "in": "header", "schema": { "type": "string" }}
852            ],
853            "requestBody": {
854                "required": true,
855                "content": {
856                    "application/json": {
857                        "schema": {
858                            "type": "object",
859                            "properties": { "foo": { "type": "string" } }
860                        }
861                    }
862                }
863            },
864            "responses": {
865                "200": {
866                    "content": {
867                        "application/json": {
868                            "schema": {
869                                "type": "object",
870                                "description": "desc",
871                                "properties": { "ok": { "type": "boolean" } }
872                            }
873                        }
874                    }
875                }
876            }
877        });
878        let op = op_value.as_object().unwrap().clone();
879
880        let (schema, headers, body) = converter.extract_inputs(&op);
881        assert_eq!(schema.properties.as_ref().map(|m| m.len()), Some(3));
882        assert_eq!(headers, vec!["X".to_string()]);
883        assert_eq!(body.as_deref(), Some("body"));
884
885        let out = converter.extract_outputs(&op);
886        assert_eq!(out.type_, "object");
887        assert!(out.properties.unwrap().contains_key("ok"));
888
889        let tool = converter
890            .create_tool("/ping", "get", &op, "https://api.example.com")
891            .unwrap()
892            .unwrap();
893        assert_eq!(tool.name, "ping");
894        let prov: HttpProvider = serde_json::from_value(tool.provider.unwrap()).unwrap();
895        assert_eq!(prov.url, "https://api.example.com/ping");
896    }
897
898    #[test]
899    fn convert_basic() {
900        let spec = json!({
901            "info": {"title": "Test API"},
902            "servers": [{"url": "https://api.example.com"}],
903            "paths": {
904                "/ping": {
905                    "get": {
906                        "operationId": "ping",
907                        "summary": "Ping",
908                        "responses": {
909                            "200": {
910                                "content": {
911                                    "application/json": {
912                                        "schema": { "type": "object" }
913                                    }
914                                }
915                            }
916                        }
917                    }
918                }
919            }
920        });
921        let converter = OpenApiConverter::new(spec, None, None);
922        let manual = converter.convert();
923        assert_eq!(manual.version, VERSION);
924        assert_eq!(manual.tools.len(), 1);
925        assert_eq!(manual.tools[0].name, "ping");
926    }
927}