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!("{}__{}", provider_name, def.operation_id);
222 let override_cfg = overrides.get(&def.operation_id);
223
224 let description = override_cfg
225 .and_then(|o| o.description.clone())
226 .unwrap_or(def.description);
227
228 let hint = override_cfg.and_then(|o| o.hint.clone());
229
230 let mut tags = def.tags;
231 if let Some(extra) = override_cfg.map(|o| &o.tags) {
232 tags.extend(extra.iter().cloned());
233 }
234 tags.sort();
236 tags.dedup();
237
238 let examples = override_cfg.map(|o| o.examples.clone()).unwrap_or_default();
239
240 let scope = override_cfg
241 .and_then(|o| o.scope.clone())
242 .unwrap_or_else(|| format!("tool:{prefixed_name}"));
243
244 let response = override_cfg.and_then(|o| {
245 if o.response_extract.is_some() || o.response_format.is_some() {
246 Some(ResponseConfig {
247 extract: o.response_extract.clone(),
248 format: match o.response_format.as_deref() {
249 Some("markdown_table") => ResponseFormat::MarkdownTable,
250 Some("json") => ResponseFormat::Json,
251 Some("raw") => ResponseFormat::Raw,
252 _ => ResponseFormat::Text,
253 },
254 })
255 } else {
256 None
257 }
258 });
259
260 Tool {
261 name: prefixed_name,
262 description,
263 endpoint: def.endpoint,
264 method: def.method,
265 scope: Some(scope),
266 input_schema: Some(def.input_schema),
267 response,
268 tags,
269 hint,
270 examples,
271 }
272}
273
274fn auto_generate_operation_id(method: &str, path: &str) -> String {
281 let slug = path
282 .trim_matches('/')
283 .replace('/', "_")
284 .replace('{', "")
285 .replace('}', "");
286 format!("{}_{}", method, slug)
287}
288
289fn build_description(op: &Operation) -> String {
291 match (&op.summary, &op.description) {
292 (Some(s), Some(d)) if s != d => format!("{s} — {d}"),
293 (Some(s), _) => s.clone(),
294 (_, Some(d)) => d.clone(),
295 (None, None) => String::new(),
296 }
297}
298
299fn is_multipart(op: &Operation) -> bool {
301 if let Some(ReferenceOr::Item(body)) = &op.request_body {
302 return body.content.contains_key("multipart/form-data");
303 }
304 false
305}
306
307fn parameter_data(param: &Parameter) -> Option<&ParameterData> {
309 match param {
310 Parameter::Query { parameter_data, .. } => Some(parameter_data),
311 Parameter::Header { parameter_data, .. } => Some(parameter_data),
312 Parameter::Path { parameter_data, .. } => Some(parameter_data),
313 Parameter::Cookie { parameter_data, .. } => Some(parameter_data),
314 }
315}
316
317fn parameter_location(param: &Parameter) -> &'static str {
319 match param {
320 Parameter::Query { .. } => "query",
321 Parameter::Header { .. } => "header",
322 Parameter::Path { .. } => "path",
323 Parameter::Cookie { .. } => "query", }
325}
326
327fn resolve_parameter_ref<'a>(reference: &str, spec: &'a OpenAPI) -> Option<&'a ParameterData> {
329 let name = reference.strip_prefix("#/components/parameters/")?;
330 let param = spec.components.as_ref()?.parameters.get(name)?;
331 match param {
332 ReferenceOr::Item(p) => parameter_data(p),
333 _ => None,
334 }
335}
336
337fn param_location_from_ref(param_ref: &ReferenceOr<Parameter>, spec: &OpenAPI) -> &'static str {
339 match param_ref {
340 ReferenceOr::Item(param) => parameter_location(param),
341 ReferenceOr::Reference { reference } => {
342 let name = reference.strip_prefix("#/components/parameters/");
344 if let Some(name) = name {
345 if let Some(components) = &spec.components {
346 if let Some(ReferenceOr::Item(param)) = components.parameters.get(name) {
347 return parameter_location(param);
348 }
349 }
350 }
351 "query" }
353 }
354}
355
356fn collection_format_for_param(param: &Parameter) -> Option<&'static str> {
365 let (style, data) = match param {
366 Parameter::Query {
367 style,
368 parameter_data,
369 ..
370 } => (style, parameter_data),
371 _ => return None,
372 };
373
374 let is_array = match &data.format {
376 ParameterSchemaOrContent::Schema(schema_ref) => match schema_ref {
377 ReferenceOr::Item(schema) => {
378 matches!(&schema.schema_kind, SchemaKind::Type(OAType::Array(_)))
379 }
380 ReferenceOr::Reference { .. } => false, },
382 _ => false,
383 };
384
385 if !is_array {
386 return None;
387 }
388
389 match style {
390 QueryStyle::Form => {
391 let explode = data.explode.unwrap_or(true);
393 if explode {
394 Some("multi")
395 } else {
396 Some("csv")
397 }
398 }
399 QueryStyle::SpaceDelimited => Some("ssv"),
400 QueryStyle::PipeDelimited => Some("pipes"),
401 QueryStyle::DeepObject => None, }
403}
404
405fn resolve_parameter_full_ref<'a>(reference: &str, spec: &'a OpenAPI) -> Option<&'a Parameter> {
408 let name = reference.strip_prefix("#/components/parameters/")?;
409 let param = spec.components.as_ref()?.parameters.get(name)?;
410 match param {
411 ReferenceOr::Item(p) => Some(p),
412 _ => None,
413 }
414}
415
416pub fn build_input_schema_with_locations(
419 path_params: &[ReferenceOr<Parameter>],
420 op_params: &[ReferenceOr<Parameter>],
421 request_body: &Option<ReferenceOr<openapiv3::RequestBody>>,
422 spec: &OpenAPI,
423) -> Value {
424 let mut properties = Map::new();
425 let mut required_fields: Vec<String> = Vec::new();
426
427 let all_param_refs: Vec<&ReferenceOr<Parameter>> =
429 path_params.iter().chain(op_params.iter()).collect();
430
431 for param_ref in &all_param_refs {
432 let location = param_location_from_ref(param_ref, spec);
433 let (data, collection_fmt) = match param_ref {
434 ReferenceOr::Item(p) => (parameter_data(p), collection_format_for_param(p)),
435 ReferenceOr::Reference { reference } => {
436 let full = resolve_parameter_full_ref(reference, spec);
437 (
438 full.and_then(parameter_data),
439 full.and_then(collection_format_for_param),
440 )
441 }
442 };
443 if let Some(data) = data {
444 let mut prop = parameter_data_to_schema(data);
445 if let Some(obj) = prop.as_object_mut() {
447 obj.insert("x-ati-param-location".into(), json!(location));
448 if let Some(cf) = collection_fmt {
449 obj.insert("x-ati-collection-format".into(), json!(cf));
450 }
451 }
452 properties.insert(data.name.clone(), prop);
453 if data.required {
454 required_fields.push(data.name.clone());
455 }
456 }
457 }
458
459 let mut body_encoding = "json";
461
462 if let Some(body_ref) = request_body {
463 let body = match body_ref {
464 ReferenceOr::Item(b) => Some(b),
465 ReferenceOr::Reference { reference } => resolve_request_body_ref(reference, spec),
466 };
467
468 if let Some(body) = body {
469 let (media_type, detected_encoding) =
471 if let Some(mt) = body.content.get("application/json") {
472 (Some(mt), "json")
473 } else if let Some(mt) = body.content.get("application/x-www-form-urlencoded") {
474 (Some(mt), "form")
475 } else {
476 (body.content.values().next(), "json")
477 };
478 body_encoding = detected_encoding;
479
480 if let Some(mt) = media_type {
481 if let Some(schema_ref) = &mt.schema {
482 let body_schema = resolve_schema_to_json(schema_ref, spec);
483 if let Some(body_props) =
484 body_schema.get("properties").and_then(|p| p.as_object())
485 {
486 let body_required: Vec<String> = body_schema
487 .get("required")
488 .and_then(|r| r.as_array())
489 .map(|arr| {
490 arr.iter()
491 .filter_map(|v| v.as_str().map(String::from))
492 .collect()
493 })
494 .unwrap_or_default();
495
496 for (k, v) in body_props {
497 let mut prop = v.clone();
498 if let Some(obj) = prop.as_object_mut() {
499 obj.insert("x-ati-param-location".into(), json!("body"));
500 }
501 properties.insert(k.clone(), prop);
502 if body.required && body_required.contains(k) {
503 required_fields.push(k.clone());
504 }
505 }
506 }
507 }
508 }
509 }
510 }
511
512 let mut schema = json!({
513 "type": "object",
514 "properties": Value::Object(properties),
515 });
516
517 if !required_fields.is_empty() {
518 schema
519 .as_object_mut()
520 .unwrap()
521 .insert("required".into(), json!(required_fields));
522 }
523
524 if body_encoding == "form" {
526 schema
527 .as_object_mut()
528 .unwrap()
529 .insert("x-ati-body-encoding".into(), json!("form"));
530 }
531
532 schema
533}
534
535fn parameter_data_to_schema(data: &ParameterData) -> Value {
537 let mut prop = Map::new();
538
539 match &data.format {
541 ParameterSchemaOrContent::Schema(schema_ref) => {
542 let resolved = match schema_ref {
543 ReferenceOr::Item(schema) => schema_to_json_type(schema),
544 ReferenceOr::Reference { .. } => json!({"type": "string"}),
545 };
546 if let Some(obj) = resolved.as_object() {
547 for (k, v) in obj {
548 prop.insert(k.clone(), v.clone());
549 }
550 }
551 }
552 ParameterSchemaOrContent::Content(_) => {
553 prop.insert("type".into(), json!("string"));
554 }
555 }
556
557 if let Some(desc) = &data.description {
559 prop.insert("description".into(), json!(desc));
560 }
561
562 if let Some(example) = &data.example {
564 prop.insert("example".into(), example.clone());
565 }
566
567 Value::Object(prop)
568}
569
570fn schema_to_json_type(schema: &Schema) -> Value {
572 let mut result = Map::new();
573
574 match &schema.schema_kind {
575 SchemaKind::Type(t) => match t {
576 OAType::String(s) => {
577 result.insert("type".into(), json!("string"));
578 if !s.enumeration.is_empty() {
579 let enums: Vec<Value> = s
580 .enumeration
581 .iter()
582 .filter_map(|e| e.as_ref().map(|v| json!(v)))
583 .collect();
584 result.insert("enum".into(), json!(enums));
585 }
586 }
587 OAType::Number(_) => {
588 result.insert("type".into(), json!("number"));
589 }
590 OAType::Integer(_) => {
591 result.insert("type".into(), json!("integer"));
592 }
593 OAType::Boolean { .. } => {
594 result.insert("type".into(), json!("boolean"));
595 }
596 OAType::Object(_) => {
597 result.insert("type".into(), json!("object"));
598 }
599 OAType::Array(a) => {
600 result.insert("type".into(), json!("array"));
601 if let Some(items_ref) = &a.items {
602 match items_ref {
603 ReferenceOr::Item(items_schema) => {
604 let items_type = schema_to_json_type(items_schema);
605 result.insert("items".into(), items_type);
606 }
607 ReferenceOr::Reference { .. } => {
608 result.insert("items".into(), json!({"type": "object"}));
609 }
610 }
611 }
612 }
613 },
614 SchemaKind::OneOf { .. }
615 | SchemaKind::AnyOf { .. }
616 | SchemaKind::AllOf { .. }
617 | SchemaKind::Not { .. }
618 | SchemaKind::Any(_) => {
619 result.insert("type".into(), json!("string"));
621 }
622 }
623
624 if let Some(desc) = &schema.schema_data.description {
626 result.insert("description".into(), json!(desc));
627 }
628 if let Some(def) = &schema.schema_data.default {
629 result.insert("default".into(), def.clone());
630 }
631 if let Some(example) = &schema.schema_data.example {
632 result.insert("example".into(), example.clone());
633 }
634
635 Value::Object(result)
636}
637
638const MAX_SCHEMA_DEPTH: usize = 32;
640
641fn resolve_schema_to_json(schema_ref: &ReferenceOr<Schema>, spec: &OpenAPI) -> Value {
643 resolve_schema_to_json_depth(schema_ref, spec, 0)
644}
645
646fn resolve_schema_to_json_depth(
647 schema_ref: &ReferenceOr<Schema>,
648 spec: &OpenAPI,
649 depth: usize,
650) -> Value {
651 if depth >= MAX_SCHEMA_DEPTH {
652 return json!({"type": "object", "description": "(schema too deeply nested)"});
653 }
654
655 match schema_ref {
656 ReferenceOr::Item(schema) => {
657 let mut result = schema_to_json_type(schema);
659
660 if let SchemaKind::Type(OAType::Object(obj)) = &schema.schema_kind {
662 let mut props = Map::new();
663 for (name, prop_ref) in &obj.properties {
664 let prop_schema = match prop_ref {
665 ReferenceOr::Item(s) => schema_to_json_type(s.as_ref()),
666 ReferenceOr::Reference { reference } => {
667 resolve_schema_ref_to_json_depth(reference, spec, depth + 1)
668 }
669 };
670 props.insert(name.clone(), prop_schema);
671 }
672 if !props.is_empty() {
673 if let Some(obj) = result.as_object_mut() {
674 obj.insert("properties".into(), Value::Object(props));
675 }
676 }
677 if !obj.required.is_empty() {
678 if let Some(obj_map) = result.as_object_mut() {
679 obj_map.insert("required".into(), json!(obj.required));
680 }
681 }
682 }
683
684 result
685 }
686 ReferenceOr::Reference { reference } => {
687 resolve_schema_ref_to_json_depth(reference, spec, depth + 1)
688 }
689 }
690}
691
692fn resolve_schema_ref_to_json(reference: &str, spec: &OpenAPI) -> Value {
694 resolve_schema_ref_to_json_depth(reference, spec, 0)
695}
696
697fn resolve_schema_ref_to_json_depth(reference: &str, spec: &OpenAPI, depth: usize) -> Value {
698 if depth >= MAX_SCHEMA_DEPTH {
699 return json!({"type": "object", "description": "(schema too deeply nested)"});
700 }
701
702 let name = match reference.strip_prefix("#/components/schemas/") {
703 Some(n) => n,
704 None => return json!({"type": "object"}),
705 };
706
707 let schema = spec.components.as_ref().and_then(|c| c.schemas.get(name));
708
709 match schema {
710 Some(schema_ref) => resolve_schema_to_json_depth(schema_ref, spec, depth + 1),
711 None => json!({"type": "object"}),
712 }
713}
714
715fn resolve_request_body_ref<'a>(
717 reference: &str,
718 spec: &'a OpenAPI,
719) -> Option<&'a openapiv3::RequestBody> {
720 let name = reference.strip_prefix("#/components/requestBodies/")?;
721 let body = spec.components.as_ref()?.request_bodies.get(name)?;
722 match body {
723 ReferenceOr::Item(b) => Some(b),
724 _ => None,
725 }
726}
727
728pub fn detect_auth(spec: &OpenAPI) -> (String, HashMap<String, String>) {
731 let mut extra = HashMap::new();
732
733 let schemes = match spec.components.as_ref() {
734 Some(c) => &c.security_schemes,
735 None => return ("none".into(), extra),
736 };
737
738 for (_name, scheme_ref) in schemes {
740 let scheme = match scheme_ref {
741 ReferenceOr::Item(s) => s,
742 _ => continue,
743 };
744
745 match scheme {
746 openapiv3::SecurityScheme::HTTP {
747 scheme: http_scheme,
748 ..
749 } => {
750 let scheme_lower = http_scheme.to_lowercase();
751 if scheme_lower == "bearer" {
752 return ("bearer".into(), extra);
753 } else if scheme_lower == "basic" {
754 return ("basic".into(), extra);
755 }
756 }
757 openapiv3::SecurityScheme::APIKey { location, name, .. } => match location {
758 openapiv3::APIKeyLocation::Header => {
759 extra.insert("auth_header_name".into(), name.clone());
760 return ("header".into(), extra);
761 }
762 openapiv3::APIKeyLocation::Query => {
763 extra.insert("auth_query_name".into(), name.clone());
764 return ("query".into(), extra);
765 }
766 openapiv3::APIKeyLocation::Cookie => {
767 return ("none".into(), extra);
768 }
769 },
770 openapiv3::SecurityScheme::OAuth2 { flows, .. } => {
771 if let Some(cc) = &flows.client_credentials {
773 extra.insert("oauth2_token_url".into(), cc.token_url.clone());
774 return ("oauth2".into(), extra);
775 }
776 }
777 openapiv3::SecurityScheme::OpenIDConnect { .. } => {
778 }
780 }
781 }
782
783 ("none".into(), extra)
784}
785
786pub struct OperationSummary {
788 pub operation_id: String,
789 pub method: String,
790 pub path: String,
791 pub description: String,
792 pub tags: Vec<String>,
793}
794
795pub fn list_operations(spec: &OpenAPI) -> Vec<OperationSummary> {
797 let mut ops = Vec::new();
798
799 for (path_str, path_item_ref) in &spec.paths.paths {
800 let path_item = match path_item_ref {
801 ReferenceOr::Item(item) => item,
802 _ => continue,
803 };
804
805 let methods: Vec<(&str, Option<&Operation>)> = vec![
806 ("GET", path_item.get.as_ref()),
807 ("POST", path_item.post.as_ref()),
808 ("PUT", path_item.put.as_ref()),
809 ("DELETE", path_item.delete.as_ref()),
810 ("PATCH", path_item.patch.as_ref()),
811 ];
812
813 for (method, maybe_op) in methods {
814 if let Some(op) = maybe_op {
815 let operation_id = op.operation_id.clone().unwrap_or_else(|| {
816 auto_generate_operation_id(&method.to_lowercase(), path_str)
817 });
818 let description = build_description(op);
819 ops.push(OperationSummary {
820 operation_id,
821 method: method.to_string(),
822 path: path_str.clone(),
823 description,
824 tags: op.tags.clone(),
825 });
826 }
827 }
828 }
829
830 ops
831}
832
833pub fn spec_base_url(spec: &OpenAPI) -> Option<String> {
835 spec.servers.first().map(|s| s.url.clone())
836}
837
838#[cfg(test)]
839mod tests {
840 use super::*;
841
842 const PETSTORE_JSON: &str = r#"{
843 "openapi": "3.0.3",
844 "info": { "title": "Petstore", "version": "1.0.0" },
845 "paths": {
846 "/pet/{petId}": {
847 "get": {
848 "operationId": "getPetById",
849 "summary": "Find pet by ID",
850 "tags": ["pet"],
851 "parameters": [
852 {
853 "name": "petId",
854 "in": "path",
855 "required": true,
856 "schema": { "type": "integer" }
857 }
858 ],
859 "responses": { "200": { "description": "OK" } }
860 }
861 },
862 "/pet": {
863 "post": {
864 "operationId": "addPet",
865 "summary": "Add a new pet",
866 "tags": ["pet"],
867 "requestBody": {
868 "required": true,
869 "content": {
870 "application/json": {
871 "schema": {
872 "type": "object",
873 "required": ["name"],
874 "properties": {
875 "name": { "type": "string", "description": "Pet name" },
876 "status": { "type": "string", "enum": ["available", "pending", "sold"] }
877 }
878 }
879 }
880 }
881 },
882 "responses": { "200": { "description": "OK" } }
883 },
884 "get": {
885 "operationId": "listPets",
886 "summary": "List all pets",
887 "tags": ["pet"],
888 "parameters": [
889 {
890 "name": "limit",
891 "in": "query",
892 "schema": { "type": "integer", "default": 20 }
893 },
894 {
895 "name": "status",
896 "in": "query",
897 "schema": { "type": "string" }
898 }
899 ],
900 "responses": { "200": { "description": "OK" } }
901 }
902 },
903 "/store/order": {
904 "post": {
905 "operationId": "placeOrder",
906 "summary": "Place an order",
907 "tags": ["store"],
908 "requestBody": {
909 "content": {
910 "application/json": {
911 "schema": {
912 "type": "object",
913 "properties": {
914 "petId": { "type": "integer" },
915 "quantity": { "type": "integer" }
916 }
917 }
918 }
919 }
920 },
921 "responses": { "200": { "description": "OK" } }
922 }
923 }
924 },
925 "components": {
926 "securitySchemes": {
927 "api_key": {
928 "type": "apiKey",
929 "in": "header",
930 "name": "X-Api-Key"
931 }
932 }
933 }
934 }"#;
935
936 #[test]
937 fn test_parse_spec() {
938 let spec = parse_spec(PETSTORE_JSON).unwrap();
939 assert_eq!(spec.info.title, "Petstore");
940 }
941
942 #[test]
943 fn test_extract_tools_no_filter() {
944 let spec = parse_spec(PETSTORE_JSON).unwrap();
945 let filters = OpenApiFilters {
946 include_tags: vec![],
947 exclude_tags: vec![],
948 include_operations: vec![],
949 exclude_operations: vec![],
950 max_operations: None,
951 };
952 let tools = extract_tools(&spec, &filters);
953 assert_eq!(tools.len(), 4); }
955
956 #[test]
957 fn test_extract_tools_include_tags() {
958 let spec = parse_spec(PETSTORE_JSON).unwrap();
959 let filters = OpenApiFilters {
960 include_tags: vec!["pet".to_string()],
961 exclude_tags: vec![],
962 include_operations: vec![],
963 exclude_operations: vec![],
964 max_operations: None,
965 };
966 let tools = extract_tools(&spec, &filters);
967 assert_eq!(tools.len(), 3); assert!(tools.iter().all(|t| t.tags.contains(&"pet".to_string())));
969 }
970
971 #[test]
972 fn test_extract_tools_exclude_operations() {
973 let spec = parse_spec(PETSTORE_JSON).unwrap();
974 let filters = OpenApiFilters {
975 include_tags: vec![],
976 exclude_tags: vec![],
977 include_operations: vec![],
978 exclude_operations: vec!["placeOrder".to_string()],
979 max_operations: None,
980 };
981 let tools = extract_tools(&spec, &filters);
982 assert_eq!(tools.len(), 3);
983 assert!(!tools.iter().any(|t| t.operation_id == "placeOrder"));
984 }
985
986 #[test]
987 fn test_extract_tools_max_operations() {
988 let spec = parse_spec(PETSTORE_JSON).unwrap();
989 let filters = OpenApiFilters {
990 include_tags: vec![],
991 exclude_tags: vec![],
992 include_operations: vec![],
993 exclude_operations: vec![],
994 max_operations: Some(2),
995 };
996 let tools = extract_tools(&spec, &filters);
997 assert_eq!(tools.len(), 2);
998 }
999
1000 #[test]
1001 fn test_to_ati_tool() {
1002 let spec = parse_spec(PETSTORE_JSON).unwrap();
1003 let filters = OpenApiFilters {
1004 include_tags: vec![],
1005 exclude_tags: vec![],
1006 include_operations: vec!["getPetById".to_string()],
1007 exclude_operations: vec![],
1008 max_operations: None,
1009 };
1010 let tools = extract_tools(&spec, &filters);
1011 assert_eq!(tools.len(), 1);
1012
1013 let overrides = HashMap::new();
1014 let tool = to_ati_tool(tools[0].clone(), "petstore", &overrides);
1015
1016 assert_eq!(tool.name, "petstore__getPetById");
1017 assert!(tool.description.contains("Find pet by ID"));
1018 assert_eq!(tool.endpoint, "/pet/{petId}");
1019 assert!(tool.input_schema.is_some());
1020 }
1021
1022 #[test]
1023 fn test_to_ati_tool_with_override() {
1024 let spec = parse_spec(PETSTORE_JSON).unwrap();
1025 let filters = OpenApiFilters {
1026 include_tags: vec![],
1027 exclude_tags: vec![],
1028 include_operations: vec!["getPetById".to_string()],
1029 exclude_operations: vec![],
1030 max_operations: None,
1031 };
1032 let tools = extract_tools(&spec, &filters);
1033
1034 let mut overrides = HashMap::new();
1035 overrides.insert(
1036 "getPetById".to_string(),
1037 OpenApiToolOverride {
1038 hint: Some("Use this to fetch pet details".into()),
1039 description: Some("Custom description".into()),
1040 tags: vec!["custom-tag".into()],
1041 ..Default::default()
1042 },
1043 );
1044
1045 let tool = to_ati_tool(tools[0].clone(), "petstore", &overrides);
1046 assert_eq!(tool.description, "Custom description");
1047 assert_eq!(tool.hint.as_deref(), Some("Use this to fetch pet details"));
1048 assert!(tool.tags.contains(&"custom-tag".to_string()));
1049 }
1050
1051 #[test]
1052 fn test_detect_auth_api_key_header() {
1053 let spec = parse_spec(PETSTORE_JSON).unwrap();
1054 let (auth_type, extra) = detect_auth(&spec);
1055 assert_eq!(auth_type, "header");
1056 assert_eq!(extra.get("auth_header_name").unwrap(), "X-Api-Key");
1057 }
1058
1059 #[test]
1060 fn test_auto_generate_operation_id() {
1061 assert_eq!(
1062 auto_generate_operation_id("get", "/pet/{petId}"),
1063 "get_pet_petId"
1064 );
1065 assert_eq!(
1066 auto_generate_operation_id("post", "/store/order"),
1067 "post_store_order"
1068 );
1069 }
1070
1071 #[test]
1072 fn test_input_schema_has_params() {
1073 let spec = parse_spec(PETSTORE_JSON).unwrap();
1074 let filters = OpenApiFilters {
1075 include_tags: vec![],
1076 exclude_tags: vec![],
1077 include_operations: vec!["listPets".to_string()],
1078 exclude_operations: vec![],
1079 max_operations: None,
1080 };
1081 let tools = extract_tools(&spec, &filters);
1082 assert_eq!(tools.len(), 1);
1083
1084 let schema = &tools[0].input_schema;
1085 let props = schema.get("properties").unwrap().as_object().unwrap();
1086 assert!(props.contains_key("limit"));
1087 assert!(props.contains_key("status"));
1088
1089 let limit = props.get("limit").unwrap();
1091 assert_eq!(limit.get("default"), Some(&json!(20)));
1092 }
1093
1094 #[test]
1095 fn test_request_body_params() {
1096 let spec = parse_spec(PETSTORE_JSON).unwrap();
1097 let filters = OpenApiFilters {
1098 include_tags: vec![],
1099 exclude_tags: vec![],
1100 include_operations: vec!["addPet".to_string()],
1101 exclude_operations: vec![],
1102 max_operations: None,
1103 };
1104 let tools = extract_tools(&spec, &filters);
1105 assert_eq!(tools.len(), 1);
1106
1107 let schema = &tools[0].input_schema;
1108 let props = schema.get("properties").unwrap().as_object().unwrap();
1109 assert!(props.contains_key("name"));
1110 assert!(props.contains_key("status"));
1111
1112 let required = schema.get("required").unwrap().as_array().unwrap();
1114 assert!(required.contains(&json!("name")));
1115 }
1116}