Skip to main content

agent_tools_interface/core/
openapi.rs

1/// OpenAPI spec parser — loads OpenAPI 3.x specs and generates ATI Tool definitions.
2///
3/// Supports both JSON and YAML specs (local files or URLs).
4/// Each operation in the spec becomes an ATI tool with:
5///   - Name: `{provider}__{operationId}` (or auto-generated from method + path)
6///   - Description from operation summary/description
7///   - Input schema with `x-ati-param-location` metadata for path/query/header/body routing
8///   - HTTP method from the spec
9///
10/// Filters: include/exclude by tags and operationIds, max operations cap.
11use 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/// Errors specific to OpenAPI spec loading.
24#[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
34/// Filter configuration derived from Provider fields.
35pub 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/// An extracted operation from an OpenAPI spec, before conversion to ATI Tool.
56#[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
66/// Load an OpenAPI spec and produce ATI Tool definitions for a provider.
67/// Called during ManifestRegistry::load() for handler = "openapi".
68pub 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
83/// Load an OpenAPI spec from a file path or URL.
84/// Supports JSON and YAML. If `spec_ref` is a relative path, resolves against `specs_dir`.
85pub 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        // URL — for now we don't support runtime fetching during load.
88        // The `ati provider import-openapi` command downloads specs to ~/.ati/specs/.
89        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        // Local file path
94        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
108/// Parse an OpenAPI spec from a string (JSON or YAML).
109pub fn parse_spec(content: &str) -> Result<OpenAPI, OpenApiError> {
110    // Try JSON first, then YAML
111    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
117/// Extract tool definitions from an OpenAPI spec, applying filters.
118pub 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, // Skip unresolved $ref paths
125        };
126
127        // Process each HTTP method on this path
128        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            // Derive operationId
143            let operation_id = operation
144                .operation_id
145                .clone()
146                .unwrap_or_else(|| auto_generate_operation_id(method_str, path_str));
147
148            // Apply filters
149            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            // Skip multipart/form-data (file uploads)
171            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 maps to PUT as ATI doesn't have a Patch variant
181                "patch" => HttpMethod::Put,
182                _ => continue,
183            };
184
185            // Build description from summary + description
186            let description = build_description(operation);
187
188            // Build unified input schema with location metadata
189            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    // Apply max_operations cap
208    if let Some(max) = filters.max_operations {
209        tools.truncate(max);
210    }
211
212    tools
213}
214
215/// Convert an extracted OpenAPI tool def into an ATI Tool struct.
216pub 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    // Deduplicate tags
235    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
274// ---------------------------------------------------------------------------
275// Internal helpers
276// ---------------------------------------------------------------------------
277
278/// Auto-generate an operationId from method + path when the spec doesn't provide one.
279/// E.g., ("get", "/pet/{petId}") → "get_pet_petId"
280fn 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
289/// Build a description string from operation summary and/or description.
290fn 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
299/// Check if an operation uses multipart/form-data (file upload).
300fn 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
307/// Extract ParameterData from a Parameter enum.
308fn 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
317/// Get the location string for a Parameter.
318fn 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", // treat cookies as query for simplicity
324    }
325}
326
327/// Resolve a $ref to a Parameter component.
328fn 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
337/// Get location from a resolved parameter ref or direct parameter.
338fn 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            // Try to resolve and determine location
343            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" // default
352        }
353    }
354}
355
356/// Determine the collection format for an array query parameter.
357/// Returns None for non-array or non-query params.
358///
359/// Mapping from OpenAPI 3.0 style/explode to ATI collection format:
360/// - Form + explode:true (default) → "multi" (?status=a&status=b)
361/// - Form + explode:false → "csv" (?status=a,b)
362/// - SpaceDelimited → "ssv" (?status=a%20b)
363/// - PipeDelimited → "pipes" (?status=a|b)
364fn 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    // Check if the parameter schema is an array type
375    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, // Can't resolve inline, skip
381        },
382        _ => false,
383    };
384
385    if !is_array {
386        return None;
387    }
388
389    match style {
390        QueryStyle::Form => {
391            // Default explode for form is true
392            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, // Not a simple collection format
402    }
403}
404
405/// Resolve a $ref to a full Parameter (preserving style info, unlike resolve_parameter_ref
406/// which only returns ParameterData and loses style).
407fn 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
416/// Build a unified input schema that preserves parameter locations.
417/// This is the version called from extract_tools() with full context.
418pub 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    // Process all parameter refs with location info
428    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            // Inject location metadata
446            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    // Add request body properties
460    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            // Detect content-type: prefer JSON, then form-urlencoded, then whatever's first
470            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    // Inject body encoding metadata for non-JSON content types
525    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
535/// Convert a ParameterData into a JSON Schema property.
536fn parameter_data_to_schema(data: &ParameterData) -> Value {
537    let mut prop = Map::new();
538
539    // Extract type from schema
540    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    // Add description
558    if let Some(desc) = &data.description {
559        prop.insert("description".into(), json!(desc));
560    }
561
562    // Add example
563    if let Some(example) = &data.example {
564        prop.insert("example".into(), example.clone());
565    }
566
567    Value::Object(prop)
568}
569
570/// Convert an openapiv3 Schema to a simple JSON Schema type representation.
571fn 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            // For complex schemas, default to string for CLI simplicity
620            result.insert("type".into(), json!("string"));
621        }
622    }
623
624    // Add description from schema_data
625    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
638/// Maximum recursion depth for schema resolution (prevents stack overflow from circular $ref).
639const MAX_SCHEMA_DEPTH: usize = 32;
640
641/// Resolve a Schema $ref to a JSON representation.
642fn 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            // Build a full JSON schema from the openapiv3 Schema
658            let mut result = schema_to_json_type(schema);
659
660            // If it's an object type, also extract properties
661            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
692/// Resolve a schema $ref string like "#/components/schemas/Pet" to JSON.
693fn 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
715/// Resolve a RequestBody $ref.
716fn 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
728/// Detect auth scheme from an OpenAPI spec's securitySchemes.
729/// Returns (auth_type_str, extra_fields) for TOML manifest generation.
730pub 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    // Pick the first security scheme
739    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                // Check for client_credentials flow
772                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                // Not directly supported — leave for manual config
779            }
780        }
781    }
782
783    ("none".into(), extra)
784}
785
786/// Summarize operations in a spec for the `inspect` command.
787pub 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
795/// List all operations in an OpenAPI spec (unfiltered) for inspection.
796pub 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
833/// Get the base URL from the spec's servers list.
834pub 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); // getPetById, addPet, listPets, placeOrder
954    }
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); // Only pet-tagged operations
968        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        // Verify default value is preserved
1090        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        // name should be required
1113        let required = schema.get("required").unwrap().as_array().unwrap();
1114        assert!(required.contains(&json!("name")));
1115    }
1116}