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#[derive(Debug, Clone)]
15pub struct UtcpManual {
16 pub version: String,
17 pub tools: Vec<Tool>,
18}
19
20pub struct OpenApiConverter {
22 spec: Value,
23 spec_url: Option<String>,
24 provider_name: String,
25}
26
27impl OpenApiConverter {
28 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 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 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
630pub 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}