1use openapiv3::{
12 OpenAPI, Operation, Parameter, ParameterData, ParameterSchemaOrContent, QueryStyle,
13 ReferenceOr, Schema, SchemaKind, Type as OAType,
14};
15use serde_json::{json, Map, Value};
16use std::collections::HashMap;
17use std::path::Path;
18
19use crate::core::manifest::{
20 HttpMethod, OpenApiToolOverride, Provider, ResponseConfig, ResponseFormat, Tool,
21};
22
23#[derive(Debug, thiserror::Error)]
25pub enum OpenApiError {
26 #[error("Failed to read spec file {0}: {1}")]
27 Io(String, std::io::Error),
28 #[error("Failed to parse spec as YAML: {0}")]
29 YamlParse(String),
30 #[error("Unsupported spec format: {0}")]
31 UnsupportedFormat(String),
32}
33
34pub struct OpenApiFilters {
36 pub include_tags: Vec<String>,
37 pub exclude_tags: Vec<String>,
38 pub include_operations: Vec<String>,
39 pub exclude_operations: Vec<String>,
40 pub max_operations: Option<usize>,
41}
42
43impl OpenApiFilters {
44 pub fn from_provider(provider: &Provider) -> Self {
45 OpenApiFilters {
46 include_tags: provider.openapi_include_tags.clone(),
47 exclude_tags: provider.openapi_exclude_tags.clone(),
48 include_operations: provider.openapi_include_operations.clone(),
49 exclude_operations: provider.openapi_exclude_operations.clone(),
50 max_operations: provider.openapi_max_operations,
51 }
52 }
53}
54
55#[derive(Debug, Clone)]
57pub struct OpenApiToolDef {
58 pub operation_id: String,
59 pub description: String,
60 pub method: HttpMethod,
61 pub endpoint: String,
62 pub input_schema: Value,
63 pub tags: Vec<String>,
64}
65
66pub fn load_and_register(
69 provider: &Provider,
70 spec_ref: &str,
71 specs_dir: Option<&Path>,
72) -> Result<Vec<Tool>, OpenApiError> {
73 let spec = load_spec(spec_ref, specs_dir)?;
74 let filters = OpenApiFilters::from_provider(provider);
75 let defs = extract_tools(&spec, &filters);
76 let tools: Vec<Tool> = defs
77 .into_iter()
78 .map(|def| to_ati_tool(def, &provider.name, &provider.openapi_overrides))
79 .collect();
80 Ok(tools)
81}
82
83pub fn load_spec(spec_ref: &str, specs_dir: Option<&Path>) -> Result<OpenAPI, OpenApiError> {
86 let content = if spec_ref.starts_with("http://") || spec_ref.starts_with("https://") {
87 return Err(OpenApiError::UnsupportedFormat(
90 "URL specs must be downloaded first with `ati provider import-openapi`. Use a local file path.".into(),
91 ));
92 } else {
93 let path = if Path::new(spec_ref).is_absolute() {
95 std::path::PathBuf::from(spec_ref)
96 } else if let Some(dir) = specs_dir {
97 dir.join(spec_ref)
98 } else {
99 std::path::PathBuf::from(spec_ref)
100 };
101 std::fs::read_to_string(&path)
102 .map_err(|e| OpenApiError::Io(path.display().to_string(), e))?
103 };
104
105 parse_spec(&content)
106}
107
108pub fn parse_spec(content: &str) -> Result<OpenAPI, OpenApiError> {
110 if let Ok(spec) = serde_json::from_str::<OpenAPI>(content) {
112 return Ok(spec);
113 }
114 serde_yaml::from_str::<OpenAPI>(content).map_err(|e| OpenApiError::YamlParse(e.to_string()))
115}
116
117pub fn extract_tools(spec: &OpenAPI, filters: &OpenApiFilters) -> Vec<OpenApiToolDef> {
119 let mut tools = Vec::new();
120
121 for (path_str, path_item_ref) in &spec.paths.paths {
122 let path_item = match path_item_ref {
123 ReferenceOr::Item(item) => item,
124 ReferenceOr::Reference { .. } => continue, };
126
127 let methods: Vec<(&str, Option<&Operation>)> = vec![
129 ("get", path_item.get.as_ref()),
130 ("post", path_item.post.as_ref()),
131 ("put", path_item.put.as_ref()),
132 ("delete", path_item.delete.as_ref()),
133 ("patch", path_item.patch.as_ref()),
134 ];
135
136 for (method_str, maybe_op) in methods {
137 let operation = match maybe_op {
138 Some(op) => op,
139 None => continue,
140 };
141
142 let operation_id = operation
144 .operation_id
145 .clone()
146 .unwrap_or_else(|| auto_generate_operation_id(method_str, path_str));
147
148 if !filters.include_operations.is_empty()
150 && !filters.include_operations.contains(&operation_id)
151 {
152 continue;
153 }
154 if filters.exclude_operations.contains(&operation_id) {
155 continue;
156 }
157
158 let op_tags: Vec<String> = operation.tags.clone();
159
160 if !filters.include_tags.is_empty() {
161 let has_included = op_tags.iter().any(|t| filters.include_tags.contains(t));
162 if !has_included {
163 continue;
164 }
165 }
166 if op_tags.iter().any(|t| filters.exclude_tags.contains(t)) {
167 continue;
168 }
169
170 if is_multipart(operation) {
172 continue;
173 }
174
175 let method = match method_str {
176 "get" => HttpMethod::Get,
177 "post" => HttpMethod::Post,
178 "put" => HttpMethod::Put,
179 "delete" => HttpMethod::Delete,
180 "patch" => HttpMethod::Put,
182 _ => continue,
183 };
184
185 let description = build_description(operation);
187
188 let input_schema = build_input_schema_with_locations(
190 &path_item.parameters,
191 &operation.parameters,
192 &operation.request_body,
193 spec,
194 );
195
196 tools.push(OpenApiToolDef {
197 operation_id,
198 description,
199 method,
200 endpoint: path_str.clone(),
201 input_schema,
202 tags: op_tags,
203 });
204 }
205 }
206
207 if let Some(max) = filters.max_operations {
209 tools.truncate(max);
210 }
211
212 tools
213}
214
215pub fn to_ati_tool(
217 def: OpenApiToolDef,
218 provider_name: &str,
219 overrides: &HashMap<String, OpenApiToolOverride>,
220) -> Tool {
221 let prefixed_name = format!(
222 "{}{}{}",
223 provider_name,
224 crate::core::manifest::TOOL_SEP_STR,
225 def.operation_id
226 );
227 let override_cfg = overrides.get(&def.operation_id);
228
229 let description = override_cfg
230 .and_then(|o| o.description.clone())
231 .unwrap_or(def.description);
232
233 let hint = override_cfg.and_then(|o| o.hint.clone());
234
235 let mut tags = def.tags;
236 if let Some(extra) = override_cfg.map(|o| &o.tags) {
237 tags.extend(extra.iter().cloned());
238 }
239 tags.sort();
241 tags.dedup();
242
243 let examples = override_cfg.map(|o| o.examples.clone()).unwrap_or_default();
244
245 let scope = override_cfg
246 .and_then(|o| o.scope.clone())
247 .unwrap_or_else(|| format!("tool:{prefixed_name}"));
248
249 let response = override_cfg.and_then(|o| {
250 if o.response_extract.is_some() || o.response_format.is_some() {
251 Some(ResponseConfig {
252 extract: o.response_extract.clone(),
253 format: match o.response_format.as_deref() {
254 Some("markdown_table") => ResponseFormat::MarkdownTable,
255 Some("json") => ResponseFormat::Json,
256 Some("raw") => ResponseFormat::Raw,
257 _ => ResponseFormat::Text,
258 },
259 })
260 } else {
261 None
262 }
263 });
264
265 Tool {
266 name: prefixed_name,
267 description,
268 endpoint: def.endpoint,
269 method: def.method,
270 scope: Some(scope),
271 input_schema: Some(def.input_schema),
272 response,
273 tags,
274 hint,
275 examples,
276 }
277}
278
279fn auto_generate_operation_id(method: &str, path: &str) -> String {
286 let slug = path
287 .trim_matches('/')
288 .replace('/', "_")
289 .replace(['{', '}'], "");
290 format!("{}_{}", method, slug)
291}
292
293fn build_description(op: &Operation) -> String {
295 match (&op.summary, &op.description) {
296 (Some(s), Some(d)) if s != d => format!("{s} — {d}"),
297 (Some(s), _) => s.clone(),
298 (_, Some(d)) => d.clone(),
299 (None, None) => String::new(),
300 }
301}
302
303fn is_multipart(op: &Operation) -> bool {
305 if let Some(ReferenceOr::Item(body)) = &op.request_body {
306 return body.content.contains_key("multipart/form-data");
307 }
308 false
309}
310
311fn parameter_data(param: &Parameter) -> Option<&ParameterData> {
313 match param {
314 Parameter::Query { parameter_data, .. } => Some(parameter_data),
315 Parameter::Header { parameter_data, .. } => Some(parameter_data),
316 Parameter::Path { parameter_data, .. } => Some(parameter_data),
317 Parameter::Cookie { parameter_data, .. } => Some(parameter_data),
318 }
319}
320
321fn parameter_location(param: &Parameter) -> &'static str {
323 match param {
324 Parameter::Query { .. } => "query",
325 Parameter::Header { .. } => "header",
326 Parameter::Path { .. } => "path",
327 Parameter::Cookie { .. } => "query", }
329}
330
331#[allow(dead_code)]
333fn resolve_parameter_ref<'a>(reference: &str, spec: &'a OpenAPI) -> Option<&'a ParameterData> {
334 let name = reference.strip_prefix("#/components/parameters/")?;
335 let param = spec.components.as_ref()?.parameters.get(name)?;
336 match param {
337 ReferenceOr::Item(p) => parameter_data(p),
338 _ => None,
339 }
340}
341
342fn param_location_from_ref(param_ref: &ReferenceOr<Parameter>, spec: &OpenAPI) -> &'static str {
344 match param_ref {
345 ReferenceOr::Item(param) => parameter_location(param),
346 ReferenceOr::Reference { reference } => {
347 let name = reference.strip_prefix("#/components/parameters/");
349 if let Some(name) = name {
350 if let Some(components) = &spec.components {
351 if let Some(ReferenceOr::Item(param)) = components.parameters.get(name) {
352 return parameter_location(param);
353 }
354 }
355 }
356 "query" }
358 }
359}
360
361fn collection_format_for_param(param: &Parameter) -> Option<&'static str> {
370 let (style, data) = match param {
371 Parameter::Query {
372 style,
373 parameter_data,
374 ..
375 } => (style, parameter_data),
376 _ => return None,
377 };
378
379 let is_array = match &data.format {
381 ParameterSchemaOrContent::Schema(schema_ref) => match schema_ref {
382 ReferenceOr::Item(schema) => {
383 matches!(&schema.schema_kind, SchemaKind::Type(OAType::Array(_)))
384 }
385 ReferenceOr::Reference { .. } => false, },
387 _ => false,
388 };
389
390 if !is_array {
391 return None;
392 }
393
394 match style {
395 QueryStyle::Form => {
396 let explode = data.explode.unwrap_or(true);
398 if explode {
399 Some("multi")
400 } else {
401 Some("csv")
402 }
403 }
404 QueryStyle::SpaceDelimited => Some("ssv"),
405 QueryStyle::PipeDelimited => Some("pipes"),
406 QueryStyle::DeepObject => None, }
408}
409
410fn resolve_parameter_full_ref<'a>(reference: &str, spec: &'a OpenAPI) -> Option<&'a Parameter> {
413 let name = reference.strip_prefix("#/components/parameters/")?;
414 let param = spec.components.as_ref()?.parameters.get(name)?;
415 match param {
416 ReferenceOr::Item(p) => Some(p),
417 _ => None,
418 }
419}
420
421pub fn build_input_schema_with_locations(
424 path_params: &[ReferenceOr<Parameter>],
425 op_params: &[ReferenceOr<Parameter>],
426 request_body: &Option<ReferenceOr<openapiv3::RequestBody>>,
427 spec: &OpenAPI,
428) -> Value {
429 let mut properties = Map::new();
430 let mut required_fields: Vec<String> = Vec::new();
431
432 let all_param_refs: Vec<&ReferenceOr<Parameter>> =
434 path_params.iter().chain(op_params.iter()).collect();
435
436 for param_ref in &all_param_refs {
437 let location = param_location_from_ref(param_ref, spec);
438 let (data, collection_fmt) = match param_ref {
439 ReferenceOr::Item(p) => (parameter_data(p), collection_format_for_param(p)),
440 ReferenceOr::Reference { reference } => {
441 let full = resolve_parameter_full_ref(reference, spec);
442 (
443 full.and_then(parameter_data),
444 full.and_then(collection_format_for_param),
445 )
446 }
447 };
448 if let Some(data) = data {
449 let mut prop = parameter_data_to_schema(data);
450 if let Some(obj) = prop.as_object_mut() {
452 obj.insert("x-ati-param-location".into(), json!(location));
453 if let Some(cf) = collection_fmt {
454 obj.insert("x-ati-collection-format".into(), json!(cf));
455 }
456 }
457 properties.insert(data.name.clone(), prop);
458 if data.required {
459 required_fields.push(data.name.clone());
460 }
461 }
462 }
463
464 let mut body_encoding = "json";
466
467 if let Some(body_ref) = request_body {
468 let body = match body_ref {
469 ReferenceOr::Item(b) => Some(b),
470 ReferenceOr::Reference { reference } => resolve_request_body_ref(reference, spec),
471 };
472
473 if let Some(body) = body {
474 let (media_type, detected_encoding) =
476 if let Some(mt) = body.content.get("application/json") {
477 (Some(mt), "json")
478 } else if let Some(mt) = body.content.get("application/x-www-form-urlencoded") {
479 (Some(mt), "form")
480 } else {
481 (body.content.values().next(), "json")
482 };
483 body_encoding = detected_encoding;
484
485 if let Some(mt) = media_type {
486 if let Some(schema_ref) = &mt.schema {
487 let body_schema = resolve_schema_to_json(schema_ref, spec);
488 if let Some(body_props) =
489 body_schema.get("properties").and_then(|p| p.as_object())
490 {
491 let body_required: Vec<String> = body_schema
492 .get("required")
493 .and_then(|r| r.as_array())
494 .map(|arr| {
495 arr.iter()
496 .filter_map(|v| v.as_str().map(String::from))
497 .collect()
498 })
499 .unwrap_or_default();
500
501 for (k, v) in body_props {
502 let mut prop = v.clone();
503 if let Some(obj) = prop.as_object_mut() {
504 obj.insert("x-ati-param-location".into(), json!("body"));
505 }
506 properties.insert(k.clone(), prop);
507 if body.required && body_required.contains(k) {
508 required_fields.push(k.clone());
509 }
510 }
511 }
512 }
513 }
514 }
515 }
516
517 let mut schema = json!({
518 "type": "object",
519 "properties": Value::Object(properties),
520 });
521
522 if !required_fields.is_empty() {
523 schema
524 .as_object_mut()
525 .unwrap()
526 .insert("required".into(), json!(required_fields));
527 }
528
529 if body_encoding == "form" {
531 schema
532 .as_object_mut()
533 .unwrap()
534 .insert("x-ati-body-encoding".into(), json!("form"));
535 }
536
537 schema
538}
539
540fn parameter_data_to_schema(data: &ParameterData) -> Value {
542 let mut prop = Map::new();
543
544 match &data.format {
546 ParameterSchemaOrContent::Schema(schema_ref) => {
547 let resolved = match schema_ref {
548 ReferenceOr::Item(schema) => schema_to_json_type(schema),
549 ReferenceOr::Reference { .. } => json!({"type": "string"}),
550 };
551 if let Some(obj) = resolved.as_object() {
552 for (k, v) in obj {
553 prop.insert(k.clone(), v.clone());
554 }
555 }
556 }
557 ParameterSchemaOrContent::Content(_) => {
558 prop.insert("type".into(), json!("string"));
559 }
560 }
561
562 if let Some(desc) = &data.description {
564 prop.insert("description".into(), json!(desc));
565 }
566
567 if let Some(example) = &data.example {
569 prop.insert("example".into(), example.clone());
570 }
571
572 Value::Object(prop)
573}
574
575fn schema_to_json_type(schema: &Schema) -> Value {
577 let mut result = Map::new();
578
579 match &schema.schema_kind {
580 SchemaKind::Type(t) => match t {
581 OAType::String(s) => {
582 result.insert("type".into(), json!("string"));
583 if !s.enumeration.is_empty() {
584 let enums: Vec<Value> = s
585 .enumeration
586 .iter()
587 .filter_map(|e| e.as_ref().map(|v| json!(v)))
588 .collect();
589 result.insert("enum".into(), json!(enums));
590 }
591 }
592 OAType::Number(_) => {
593 result.insert("type".into(), json!("number"));
594 }
595 OAType::Integer(_) => {
596 result.insert("type".into(), json!("integer"));
597 }
598 OAType::Boolean { .. } => {
599 result.insert("type".into(), json!("boolean"));
600 }
601 OAType::Object(_) => {
602 result.insert("type".into(), json!("object"));
603 }
604 OAType::Array(a) => {
605 result.insert("type".into(), json!("array"));
606 if let Some(items_ref) = &a.items {
607 match items_ref {
608 ReferenceOr::Item(items_schema) => {
609 let items_type = schema_to_json_type(items_schema);
610 result.insert("items".into(), items_type);
611 }
612 ReferenceOr::Reference { .. } => {
613 result.insert("items".into(), json!({"type": "object"}));
614 }
615 }
616 }
617 }
618 },
619 SchemaKind::OneOf { .. }
620 | SchemaKind::AnyOf { .. }
621 | SchemaKind::AllOf { .. }
622 | SchemaKind::Not { .. }
623 | SchemaKind::Any(_) => {
624 result.insert("type".into(), json!("string"));
626 }
627 }
628
629 if let Some(desc) = &schema.schema_data.description {
631 result.insert("description".into(), json!(desc));
632 }
633 if let Some(def) = &schema.schema_data.default {
634 result.insert("default".into(), def.clone());
635 }
636 if let Some(example) = &schema.schema_data.example {
637 result.insert("example".into(), example.clone());
638 }
639
640 Value::Object(result)
641}
642
643const MAX_SCHEMA_DEPTH: usize = 32;
645
646fn resolve_schema_to_json(schema_ref: &ReferenceOr<Schema>, spec: &OpenAPI) -> Value {
648 resolve_schema_to_json_depth(schema_ref, spec, 0)
649}
650
651fn resolve_schema_to_json_depth(
652 schema_ref: &ReferenceOr<Schema>,
653 spec: &OpenAPI,
654 depth: usize,
655) -> Value {
656 if depth >= MAX_SCHEMA_DEPTH {
657 return json!({"type": "object", "description": "(schema too deeply nested)"});
658 }
659
660 match schema_ref {
661 ReferenceOr::Item(schema) => {
662 let mut result = schema_to_json_type(schema);
664
665 if let SchemaKind::Type(OAType::Object(obj)) = &schema.schema_kind {
667 let mut props = Map::new();
668 for (name, prop_ref) in &obj.properties {
669 let prop_schema = match prop_ref {
670 ReferenceOr::Item(s) => schema_to_json_type(s.as_ref()),
671 ReferenceOr::Reference { reference } => {
672 resolve_schema_ref_to_json_depth(reference, spec, depth + 1)
673 }
674 };
675 props.insert(name.clone(), prop_schema);
676 }
677 if !props.is_empty() {
678 if let Some(obj) = result.as_object_mut() {
679 obj.insert("properties".into(), Value::Object(props));
680 }
681 }
682 if !obj.required.is_empty() {
683 if let Some(obj_map) = result.as_object_mut() {
684 obj_map.insert("required".into(), json!(obj.required));
685 }
686 }
687 }
688
689 result
690 }
691 ReferenceOr::Reference { reference } => {
692 resolve_schema_ref_to_json_depth(reference, spec, depth + 1)
693 }
694 }
695}
696
697#[allow(dead_code)]
699fn resolve_schema_ref_to_json(reference: &str, spec: &OpenAPI) -> Value {
700 resolve_schema_ref_to_json_depth(reference, spec, 0)
701}
702
703fn resolve_schema_ref_to_json_depth(reference: &str, spec: &OpenAPI, depth: usize) -> Value {
704 if depth >= MAX_SCHEMA_DEPTH {
705 return json!({"type": "object", "description": "(schema too deeply nested)"});
706 }
707
708 let name = match reference.strip_prefix("#/components/schemas/") {
709 Some(n) => n,
710 None => return json!({"type": "object"}),
711 };
712
713 let schema = spec.components.as_ref().and_then(|c| c.schemas.get(name));
714
715 match schema {
716 Some(schema_ref) => resolve_schema_to_json_depth(schema_ref, spec, depth + 1),
717 None => json!({"type": "object"}),
718 }
719}
720
721fn resolve_request_body_ref<'a>(
723 reference: &str,
724 spec: &'a OpenAPI,
725) -> Option<&'a openapiv3::RequestBody> {
726 let name = reference.strip_prefix("#/components/requestBodies/")?;
727 let body = spec.components.as_ref()?.request_bodies.get(name)?;
728 match body {
729 ReferenceOr::Item(b) => Some(b),
730 _ => None,
731 }
732}
733
734pub fn detect_auth(spec: &OpenAPI) -> (String, HashMap<String, String>) {
737 let mut extra = HashMap::new();
738
739 let schemes = match spec.components.as_ref() {
740 Some(c) => &c.security_schemes,
741 None => return ("none".into(), extra),
742 };
743
744 for (_name, scheme_ref) in schemes {
746 let scheme = match scheme_ref {
747 ReferenceOr::Item(s) => s,
748 _ => continue,
749 };
750
751 match scheme {
752 openapiv3::SecurityScheme::HTTP {
753 scheme: http_scheme,
754 ..
755 } => {
756 let scheme_lower = http_scheme.to_lowercase();
757 if scheme_lower == "bearer" {
758 return ("bearer".into(), extra);
759 } else if scheme_lower == "basic" {
760 return ("basic".into(), extra);
761 }
762 }
763 openapiv3::SecurityScheme::APIKey { location, name, .. } => match location {
764 openapiv3::APIKeyLocation::Header => {
765 extra.insert("auth_header_name".into(), name.clone());
766 return ("header".into(), extra);
767 }
768 openapiv3::APIKeyLocation::Query => {
769 extra.insert("auth_query_name".into(), name.clone());
770 return ("query".into(), extra);
771 }
772 openapiv3::APIKeyLocation::Cookie => {
773 return ("none".into(), extra);
774 }
775 },
776 openapiv3::SecurityScheme::OAuth2 { flows, .. } => {
777 if let Some(cc) = &flows.client_credentials {
779 extra.insert("oauth2_token_url".into(), cc.token_url.clone());
780 return ("oauth2".into(), extra);
781 }
782 }
783 openapiv3::SecurityScheme::OpenIDConnect { .. } => {
784 }
786 }
787 }
788
789 ("none".into(), extra)
790}
791
792pub struct OperationSummary {
794 pub operation_id: String,
795 pub method: String,
796 pub path: String,
797 pub description: String,
798 pub tags: Vec<String>,
799}
800
801pub fn list_operations(spec: &OpenAPI) -> Vec<OperationSummary> {
803 let mut ops = Vec::new();
804
805 for (path_str, path_item_ref) in &spec.paths.paths {
806 let path_item = match path_item_ref {
807 ReferenceOr::Item(item) => item,
808 _ => continue,
809 };
810
811 let methods: Vec<(&str, Option<&Operation>)> = vec![
812 ("GET", path_item.get.as_ref()),
813 ("POST", path_item.post.as_ref()),
814 ("PUT", path_item.put.as_ref()),
815 ("DELETE", path_item.delete.as_ref()),
816 ("PATCH", path_item.patch.as_ref()),
817 ];
818
819 for (method, maybe_op) in methods {
820 if let Some(op) = maybe_op {
821 let operation_id = op.operation_id.clone().unwrap_or_else(|| {
822 auto_generate_operation_id(&method.to_lowercase(), path_str)
823 });
824 let description = build_description(op);
825 ops.push(OperationSummary {
826 operation_id,
827 method: method.to_string(),
828 path: path_str.clone(),
829 description,
830 tags: op.tags.clone(),
831 });
832 }
833 }
834 }
835
836 ops
837}
838
839pub fn spec_base_url(spec: &OpenAPI) -> Option<String> {
841 spec.servers.first().map(|s| s.url.clone())
842}
843
844#[cfg(test)]
845mod tests {
846 use super::*;
847
848 const PETSTORE_JSON: &str = r#"{
849 "openapi": "3.0.3",
850 "info": { "title": "Petstore", "version": "1.0.0" },
851 "paths": {
852 "/pet/{petId}": {
853 "get": {
854 "operationId": "getPetById",
855 "summary": "Find pet by ID",
856 "tags": ["pet"],
857 "parameters": [
858 {
859 "name": "petId",
860 "in": "path",
861 "required": true,
862 "schema": { "type": "integer" }
863 }
864 ],
865 "responses": { "200": { "description": "OK" } }
866 }
867 },
868 "/pet": {
869 "post": {
870 "operationId": "addPet",
871 "summary": "Add a new pet",
872 "tags": ["pet"],
873 "requestBody": {
874 "required": true,
875 "content": {
876 "application/json": {
877 "schema": {
878 "type": "object",
879 "required": ["name"],
880 "properties": {
881 "name": { "type": "string", "description": "Pet name" },
882 "status": { "type": "string", "enum": ["available", "pending", "sold"] }
883 }
884 }
885 }
886 }
887 },
888 "responses": { "200": { "description": "OK" } }
889 },
890 "get": {
891 "operationId": "listPets",
892 "summary": "List all pets",
893 "tags": ["pet"],
894 "parameters": [
895 {
896 "name": "limit",
897 "in": "query",
898 "schema": { "type": "integer", "default": 20 }
899 },
900 {
901 "name": "status",
902 "in": "query",
903 "schema": { "type": "string" }
904 }
905 ],
906 "responses": { "200": { "description": "OK" } }
907 }
908 },
909 "/store/order": {
910 "post": {
911 "operationId": "placeOrder",
912 "summary": "Place an order",
913 "tags": ["store"],
914 "requestBody": {
915 "content": {
916 "application/json": {
917 "schema": {
918 "type": "object",
919 "properties": {
920 "petId": { "type": "integer" },
921 "quantity": { "type": "integer" }
922 }
923 }
924 }
925 }
926 },
927 "responses": { "200": { "description": "OK" } }
928 }
929 }
930 },
931 "components": {
932 "securitySchemes": {
933 "api_key": {
934 "type": "apiKey",
935 "in": "header",
936 "name": "X-Api-Key"
937 }
938 }
939 }
940 }"#;
941
942 #[test]
943 fn test_parse_spec() {
944 let spec = parse_spec(PETSTORE_JSON).unwrap();
945 assert_eq!(spec.info.title, "Petstore");
946 }
947
948 #[test]
949 fn test_extract_tools_no_filter() {
950 let spec = parse_spec(PETSTORE_JSON).unwrap();
951 let filters = OpenApiFilters {
952 include_tags: vec![],
953 exclude_tags: vec![],
954 include_operations: vec![],
955 exclude_operations: vec![],
956 max_operations: None,
957 };
958 let tools = extract_tools(&spec, &filters);
959 assert_eq!(tools.len(), 4); }
961
962 #[test]
963 fn test_extract_tools_include_tags() {
964 let spec = parse_spec(PETSTORE_JSON).unwrap();
965 let filters = OpenApiFilters {
966 include_tags: vec!["pet".to_string()],
967 exclude_tags: vec![],
968 include_operations: vec![],
969 exclude_operations: vec![],
970 max_operations: None,
971 };
972 let tools = extract_tools(&spec, &filters);
973 assert_eq!(tools.len(), 3); assert!(tools.iter().all(|t| t.tags.contains(&"pet".to_string())));
975 }
976
977 #[test]
978 fn test_extract_tools_exclude_operations() {
979 let spec = parse_spec(PETSTORE_JSON).unwrap();
980 let filters = OpenApiFilters {
981 include_tags: vec![],
982 exclude_tags: vec![],
983 include_operations: vec![],
984 exclude_operations: vec!["placeOrder".to_string()],
985 max_operations: None,
986 };
987 let tools = extract_tools(&spec, &filters);
988 assert_eq!(tools.len(), 3);
989 assert!(!tools.iter().any(|t| t.operation_id == "placeOrder"));
990 }
991
992 #[test]
993 fn test_extract_tools_max_operations() {
994 let spec = parse_spec(PETSTORE_JSON).unwrap();
995 let filters = OpenApiFilters {
996 include_tags: vec![],
997 exclude_tags: vec![],
998 include_operations: vec![],
999 exclude_operations: vec![],
1000 max_operations: Some(2),
1001 };
1002 let tools = extract_tools(&spec, &filters);
1003 assert_eq!(tools.len(), 2);
1004 }
1005
1006 #[test]
1007 fn test_to_ati_tool() {
1008 let spec = parse_spec(PETSTORE_JSON).unwrap();
1009 let filters = OpenApiFilters {
1010 include_tags: vec![],
1011 exclude_tags: vec![],
1012 include_operations: vec!["getPetById".to_string()],
1013 exclude_operations: vec![],
1014 max_operations: None,
1015 };
1016 let tools = extract_tools(&spec, &filters);
1017 assert_eq!(tools.len(), 1);
1018
1019 let overrides = HashMap::new();
1020 let tool = to_ati_tool(tools[0].clone(), "petstore", &overrides);
1021
1022 assert_eq!(tool.name, "petstore:getPetById");
1023 assert!(tool.description.contains("Find pet by ID"));
1024 assert_eq!(tool.endpoint, "/pet/{petId}");
1025 assert!(tool.input_schema.is_some());
1026 }
1027
1028 #[test]
1029 fn test_to_ati_tool_with_override() {
1030 let spec = parse_spec(PETSTORE_JSON).unwrap();
1031 let filters = OpenApiFilters {
1032 include_tags: vec![],
1033 exclude_tags: vec![],
1034 include_operations: vec!["getPetById".to_string()],
1035 exclude_operations: vec![],
1036 max_operations: None,
1037 };
1038 let tools = extract_tools(&spec, &filters);
1039
1040 let mut overrides = HashMap::new();
1041 overrides.insert(
1042 "getPetById".to_string(),
1043 OpenApiToolOverride {
1044 hint: Some("Use this to fetch pet details".into()),
1045 description: Some("Custom description".into()),
1046 tags: vec!["custom-tag".into()],
1047 ..Default::default()
1048 },
1049 );
1050
1051 let tool = to_ati_tool(tools[0].clone(), "petstore", &overrides);
1052 assert_eq!(tool.description, "Custom description");
1053 assert_eq!(tool.hint.as_deref(), Some("Use this to fetch pet details"));
1054 assert!(tool.tags.contains(&"custom-tag".to_string()));
1055 }
1056
1057 #[test]
1058 fn test_detect_auth_api_key_header() {
1059 let spec = parse_spec(PETSTORE_JSON).unwrap();
1060 let (auth_type, extra) = detect_auth(&spec);
1061 assert_eq!(auth_type, "header");
1062 assert_eq!(extra.get("auth_header_name").unwrap(), "X-Api-Key");
1063 }
1064
1065 #[test]
1066 fn test_auto_generate_operation_id() {
1067 assert_eq!(
1068 auto_generate_operation_id("get", "/pet/{petId}"),
1069 "get_pet_petId"
1070 );
1071 assert_eq!(
1072 auto_generate_operation_id("post", "/store/order"),
1073 "post_store_order"
1074 );
1075 }
1076
1077 #[test]
1078 fn test_input_schema_has_params() {
1079 let spec = parse_spec(PETSTORE_JSON).unwrap();
1080 let filters = OpenApiFilters {
1081 include_tags: vec![],
1082 exclude_tags: vec![],
1083 include_operations: vec!["listPets".to_string()],
1084 exclude_operations: vec![],
1085 max_operations: None,
1086 };
1087 let tools = extract_tools(&spec, &filters);
1088 assert_eq!(tools.len(), 1);
1089
1090 let schema = &tools[0].input_schema;
1091 let props = schema.get("properties").unwrap().as_object().unwrap();
1092 assert!(props.contains_key("limit"));
1093 assert!(props.contains_key("status"));
1094
1095 let limit = props.get("limit").unwrap();
1097 assert_eq!(limit.get("default"), Some(&json!(20)));
1098 }
1099
1100 #[test]
1101 fn test_request_body_params() {
1102 let spec = parse_spec(PETSTORE_JSON).unwrap();
1103 let filters = OpenApiFilters {
1104 include_tags: vec![],
1105 exclude_tags: vec![],
1106 include_operations: vec!["addPet".to_string()],
1107 exclude_operations: vec![],
1108 max_operations: None,
1109 };
1110 let tools = extract_tools(&spec, &filters);
1111 assert_eq!(tools.len(), 1);
1112
1113 let schema = &tools[0].input_schema;
1114 let props = schema.get("properties").unwrap().as_object().unwrap();
1115 assert!(props.contains_key("name"));
1116 assert!(props.contains_key("status"));
1117
1118 let required = schema.get("required").unwrap().as_array().unwrap();
1120 assert!(required.contains(&json!("name")));
1121 }
1122}