Skip to main content

ati/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!(
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    // Deduplicate tags
240    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
279// ---------------------------------------------------------------------------
280// Internal helpers
281// ---------------------------------------------------------------------------
282
283/// Auto-generate an operationId from method + path when the spec doesn't provide one.
284/// E.g., ("get", "/pet/{petId}") → "get_pet_petId"
285fn auto_generate_operation_id(method: &str, path: &str) -> String {
286    let slug = path
287        .trim_matches('/')
288        .replace('/', "_")
289        .replace('{', "")
290        .replace('}', "");
291    format!("{}_{}", method, slug)
292}
293
294/// Build a description string from operation summary and/or description.
295fn build_description(op: &Operation) -> String {
296    match (&op.summary, &op.description) {
297        (Some(s), Some(d)) if s != d => format!("{s} — {d}"),
298        (Some(s), _) => s.clone(),
299        (_, Some(d)) => d.clone(),
300        (None, None) => String::new(),
301    }
302}
303
304/// Check if an operation uses multipart/form-data (file upload).
305fn is_multipart(op: &Operation) -> bool {
306    if let Some(ReferenceOr::Item(body)) = &op.request_body {
307        return body.content.contains_key("multipart/form-data");
308    }
309    false
310}
311
312/// Extract ParameterData from a Parameter enum.
313fn parameter_data(param: &Parameter) -> Option<&ParameterData> {
314    match param {
315        Parameter::Query { parameter_data, .. } => Some(parameter_data),
316        Parameter::Header { parameter_data, .. } => Some(parameter_data),
317        Parameter::Path { parameter_data, .. } => Some(parameter_data),
318        Parameter::Cookie { parameter_data, .. } => Some(parameter_data),
319    }
320}
321
322/// Get the location string for a Parameter.
323fn parameter_location(param: &Parameter) -> &'static str {
324    match param {
325        Parameter::Query { .. } => "query",
326        Parameter::Header { .. } => "header",
327        Parameter::Path { .. } => "path",
328        Parameter::Cookie { .. } => "query", // treat cookies as query for simplicity
329    }
330}
331
332/// Resolve a $ref to a Parameter component.
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
342/// Get location from a resolved parameter ref or direct parameter.
343fn 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            // Try to resolve and determine location
348            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" // default
357        }
358    }
359}
360
361/// Determine the collection format for an array query parameter.
362/// Returns None for non-array or non-query params.
363///
364/// Mapping from OpenAPI 3.0 style/explode to ATI collection format:
365/// - Form + explode:true (default) → "multi" (?status=a&status=b)
366/// - Form + explode:false → "csv" (?status=a,b)
367/// - SpaceDelimited → "ssv" (?status=a%20b)
368/// - PipeDelimited → "pipes" (?status=a|b)
369fn 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    // Check if the parameter schema is an array type
380    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, // Can't resolve inline, skip
386        },
387        _ => false,
388    };
389
390    if !is_array {
391        return None;
392    }
393
394    match style {
395        QueryStyle::Form => {
396            // Default explode for form is true
397            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, // Not a simple collection format
407    }
408}
409
410/// Resolve a $ref to a full Parameter (preserving style info, unlike resolve_parameter_ref
411/// which only returns ParameterData and loses style).
412fn 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
421/// Build a unified input schema that preserves parameter locations.
422/// This is the version called from extract_tools() with full context.
423pub 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    // Process all parameter refs with location info
433    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            // Inject location metadata
451            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    // Add request body properties
465    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            // Detect content-type: prefer JSON, then form-urlencoded, then whatever's first
475            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    // Inject body encoding metadata for non-JSON content types
530    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
540/// Convert a ParameterData into a JSON Schema property.
541fn parameter_data_to_schema(data: &ParameterData) -> Value {
542    let mut prop = Map::new();
543
544    // Extract type from schema
545    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    // Add description
563    if let Some(desc) = &data.description {
564        prop.insert("description".into(), json!(desc));
565    }
566
567    // Add example
568    if let Some(example) = &data.example {
569        prop.insert("example".into(), example.clone());
570    }
571
572    Value::Object(prop)
573}
574
575/// Convert an openapiv3 Schema to a simple JSON Schema type representation.
576fn 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            // For complex schemas, default to string for CLI simplicity
625            result.insert("type".into(), json!("string"));
626        }
627    }
628
629    // Add description from schema_data
630    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
643/// Maximum recursion depth for schema resolution (prevents stack overflow from circular $ref).
644const MAX_SCHEMA_DEPTH: usize = 32;
645
646/// Resolve a Schema $ref to a JSON representation.
647fn 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            // Build a full JSON schema from the openapiv3 Schema
663            let mut result = schema_to_json_type(schema);
664
665            // If it's an object type, also extract properties
666            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/// Resolve a schema $ref string like "#/components/schemas/Pet" to JSON.
698fn resolve_schema_ref_to_json(reference: &str, spec: &OpenAPI) -> Value {
699    resolve_schema_ref_to_json_depth(reference, spec, 0)
700}
701
702fn resolve_schema_ref_to_json_depth(reference: &str, spec: &OpenAPI, depth: usize) -> Value {
703    if depth >= MAX_SCHEMA_DEPTH {
704        return json!({"type": "object", "description": "(schema too deeply nested)"});
705    }
706
707    let name = match reference.strip_prefix("#/components/schemas/") {
708        Some(n) => n,
709        None => return json!({"type": "object"}),
710    };
711
712    let schema = spec.components.as_ref().and_then(|c| c.schemas.get(name));
713
714    match schema {
715        Some(schema_ref) => resolve_schema_to_json_depth(schema_ref, spec, depth + 1),
716        None => json!({"type": "object"}),
717    }
718}
719
720/// Resolve a RequestBody $ref.
721fn resolve_request_body_ref<'a>(
722    reference: &str,
723    spec: &'a OpenAPI,
724) -> Option<&'a openapiv3::RequestBody> {
725    let name = reference.strip_prefix("#/components/requestBodies/")?;
726    let body = spec.components.as_ref()?.request_bodies.get(name)?;
727    match body {
728        ReferenceOr::Item(b) => Some(b),
729        _ => None,
730    }
731}
732
733/// Detect auth scheme from an OpenAPI spec's securitySchemes.
734/// Returns (auth_type_str, extra_fields) for TOML manifest generation.
735pub fn detect_auth(spec: &OpenAPI) -> (String, HashMap<String, String>) {
736    let mut extra = HashMap::new();
737
738    let schemes = match spec.components.as_ref() {
739        Some(c) => &c.security_schemes,
740        None => return ("none".into(), extra),
741    };
742
743    // Pick the first security scheme
744    for (_name, scheme_ref) in schemes {
745        let scheme = match scheme_ref {
746            ReferenceOr::Item(s) => s,
747            _ => continue,
748        };
749
750        match scheme {
751            openapiv3::SecurityScheme::HTTP {
752                scheme: http_scheme,
753                ..
754            } => {
755                let scheme_lower = http_scheme.to_lowercase();
756                if scheme_lower == "bearer" {
757                    return ("bearer".into(), extra);
758                } else if scheme_lower == "basic" {
759                    return ("basic".into(), extra);
760                }
761            }
762            openapiv3::SecurityScheme::APIKey { location, name, .. } => match location {
763                openapiv3::APIKeyLocation::Header => {
764                    extra.insert("auth_header_name".into(), name.clone());
765                    return ("header".into(), extra);
766                }
767                openapiv3::APIKeyLocation::Query => {
768                    extra.insert("auth_query_name".into(), name.clone());
769                    return ("query".into(), extra);
770                }
771                openapiv3::APIKeyLocation::Cookie => {
772                    return ("none".into(), extra);
773                }
774            },
775            openapiv3::SecurityScheme::OAuth2 { flows, .. } => {
776                // Check for client_credentials flow
777                if let Some(cc) = &flows.client_credentials {
778                    extra.insert("oauth2_token_url".into(), cc.token_url.clone());
779                    return ("oauth2".into(), extra);
780                }
781            }
782            openapiv3::SecurityScheme::OpenIDConnect { .. } => {
783                // Not directly supported — leave for manual config
784            }
785        }
786    }
787
788    ("none".into(), extra)
789}
790
791/// Summarize operations in a spec for the `inspect` command.
792pub struct OperationSummary {
793    pub operation_id: String,
794    pub method: String,
795    pub path: String,
796    pub description: String,
797    pub tags: Vec<String>,
798}
799
800/// List all operations in an OpenAPI spec (unfiltered) for inspection.
801pub fn list_operations(spec: &OpenAPI) -> Vec<OperationSummary> {
802    let mut ops = Vec::new();
803
804    for (path_str, path_item_ref) in &spec.paths.paths {
805        let path_item = match path_item_ref {
806            ReferenceOr::Item(item) => item,
807            _ => continue,
808        };
809
810        let methods: Vec<(&str, Option<&Operation>)> = vec![
811            ("GET", path_item.get.as_ref()),
812            ("POST", path_item.post.as_ref()),
813            ("PUT", path_item.put.as_ref()),
814            ("DELETE", path_item.delete.as_ref()),
815            ("PATCH", path_item.patch.as_ref()),
816        ];
817
818        for (method, maybe_op) in methods {
819            if let Some(op) = maybe_op {
820                let operation_id = op.operation_id.clone().unwrap_or_else(|| {
821                    auto_generate_operation_id(&method.to_lowercase(), path_str)
822                });
823                let description = build_description(op);
824                ops.push(OperationSummary {
825                    operation_id,
826                    method: method.to_string(),
827                    path: path_str.clone(),
828                    description,
829                    tags: op.tags.clone(),
830                });
831            }
832        }
833    }
834
835    ops
836}
837
838/// Get the base URL from the spec's servers list.
839pub fn spec_base_url(spec: &OpenAPI) -> Option<String> {
840    spec.servers.first().map(|s| s.url.clone())
841}
842
843#[cfg(test)]
844mod tests {
845    use super::*;
846
847    const PETSTORE_JSON: &str = r#"{
848        "openapi": "3.0.3",
849        "info": { "title": "Petstore", "version": "1.0.0" },
850        "paths": {
851            "/pet/{petId}": {
852                "get": {
853                    "operationId": "getPetById",
854                    "summary": "Find pet by ID",
855                    "tags": ["pet"],
856                    "parameters": [
857                        {
858                            "name": "petId",
859                            "in": "path",
860                            "required": true,
861                            "schema": { "type": "integer" }
862                        }
863                    ],
864                    "responses": { "200": { "description": "OK" } }
865                }
866            },
867            "/pet": {
868                "post": {
869                    "operationId": "addPet",
870                    "summary": "Add a new pet",
871                    "tags": ["pet"],
872                    "requestBody": {
873                        "required": true,
874                        "content": {
875                            "application/json": {
876                                "schema": {
877                                    "type": "object",
878                                    "required": ["name"],
879                                    "properties": {
880                                        "name": { "type": "string", "description": "Pet name" },
881                                        "status": { "type": "string", "enum": ["available", "pending", "sold"] }
882                                    }
883                                }
884                            }
885                        }
886                    },
887                    "responses": { "200": { "description": "OK" } }
888                },
889                "get": {
890                    "operationId": "listPets",
891                    "summary": "List all pets",
892                    "tags": ["pet"],
893                    "parameters": [
894                        {
895                            "name": "limit",
896                            "in": "query",
897                            "schema": { "type": "integer", "default": 20 }
898                        },
899                        {
900                            "name": "status",
901                            "in": "query",
902                            "schema": { "type": "string" }
903                        }
904                    ],
905                    "responses": { "200": { "description": "OK" } }
906                }
907            },
908            "/store/order": {
909                "post": {
910                    "operationId": "placeOrder",
911                    "summary": "Place an order",
912                    "tags": ["store"],
913                    "requestBody": {
914                        "content": {
915                            "application/json": {
916                                "schema": {
917                                    "type": "object",
918                                    "properties": {
919                                        "petId": { "type": "integer" },
920                                        "quantity": { "type": "integer" }
921                                    }
922                                }
923                            }
924                        }
925                    },
926                    "responses": { "200": { "description": "OK" } }
927                }
928            }
929        },
930        "components": {
931            "securitySchemes": {
932                "api_key": {
933                    "type": "apiKey",
934                    "in": "header",
935                    "name": "X-Api-Key"
936                }
937            }
938        }
939    }"#;
940
941    #[test]
942    fn test_parse_spec() {
943        let spec = parse_spec(PETSTORE_JSON).unwrap();
944        assert_eq!(spec.info.title, "Petstore");
945    }
946
947    #[test]
948    fn test_extract_tools_no_filter() {
949        let spec = parse_spec(PETSTORE_JSON).unwrap();
950        let filters = OpenApiFilters {
951            include_tags: vec![],
952            exclude_tags: vec![],
953            include_operations: vec![],
954            exclude_operations: vec![],
955            max_operations: None,
956        };
957        let tools = extract_tools(&spec, &filters);
958        assert_eq!(tools.len(), 4); // getPetById, addPet, listPets, placeOrder
959    }
960
961    #[test]
962    fn test_extract_tools_include_tags() {
963        let spec = parse_spec(PETSTORE_JSON).unwrap();
964        let filters = OpenApiFilters {
965            include_tags: vec!["pet".to_string()],
966            exclude_tags: vec![],
967            include_operations: vec![],
968            exclude_operations: vec![],
969            max_operations: None,
970        };
971        let tools = extract_tools(&spec, &filters);
972        assert_eq!(tools.len(), 3); // Only pet-tagged operations
973        assert!(tools.iter().all(|t| t.tags.contains(&"pet".to_string())));
974    }
975
976    #[test]
977    fn test_extract_tools_exclude_operations() {
978        let spec = parse_spec(PETSTORE_JSON).unwrap();
979        let filters = OpenApiFilters {
980            include_tags: vec![],
981            exclude_tags: vec![],
982            include_operations: vec![],
983            exclude_operations: vec!["placeOrder".to_string()],
984            max_operations: None,
985        };
986        let tools = extract_tools(&spec, &filters);
987        assert_eq!(tools.len(), 3);
988        assert!(!tools.iter().any(|t| t.operation_id == "placeOrder"));
989    }
990
991    #[test]
992    fn test_extract_tools_max_operations() {
993        let spec = parse_spec(PETSTORE_JSON).unwrap();
994        let filters = OpenApiFilters {
995            include_tags: vec![],
996            exclude_tags: vec![],
997            include_operations: vec![],
998            exclude_operations: vec![],
999            max_operations: Some(2),
1000        };
1001        let tools = extract_tools(&spec, &filters);
1002        assert_eq!(tools.len(), 2);
1003    }
1004
1005    #[test]
1006    fn test_to_ati_tool() {
1007        let spec = parse_spec(PETSTORE_JSON).unwrap();
1008        let filters = OpenApiFilters {
1009            include_tags: vec![],
1010            exclude_tags: vec![],
1011            include_operations: vec!["getPetById".to_string()],
1012            exclude_operations: vec![],
1013            max_operations: None,
1014        };
1015        let tools = extract_tools(&spec, &filters);
1016        assert_eq!(tools.len(), 1);
1017
1018        let overrides = HashMap::new();
1019        let tool = to_ati_tool(tools[0].clone(), "petstore", &overrides);
1020
1021        assert_eq!(tool.name, "petstore:getPetById");
1022        assert!(tool.description.contains("Find pet by ID"));
1023        assert_eq!(tool.endpoint, "/pet/{petId}");
1024        assert!(tool.input_schema.is_some());
1025    }
1026
1027    #[test]
1028    fn test_to_ati_tool_with_override() {
1029        let spec = parse_spec(PETSTORE_JSON).unwrap();
1030        let filters = OpenApiFilters {
1031            include_tags: vec![],
1032            exclude_tags: vec![],
1033            include_operations: vec!["getPetById".to_string()],
1034            exclude_operations: vec![],
1035            max_operations: None,
1036        };
1037        let tools = extract_tools(&spec, &filters);
1038
1039        let mut overrides = HashMap::new();
1040        overrides.insert(
1041            "getPetById".to_string(),
1042            OpenApiToolOverride {
1043                hint: Some("Use this to fetch pet details".into()),
1044                description: Some("Custom description".into()),
1045                tags: vec!["custom-tag".into()],
1046                ..Default::default()
1047            },
1048        );
1049
1050        let tool = to_ati_tool(tools[0].clone(), "petstore", &overrides);
1051        assert_eq!(tool.description, "Custom description");
1052        assert_eq!(tool.hint.as_deref(), Some("Use this to fetch pet details"));
1053        assert!(tool.tags.contains(&"custom-tag".to_string()));
1054    }
1055
1056    #[test]
1057    fn test_detect_auth_api_key_header() {
1058        let spec = parse_spec(PETSTORE_JSON).unwrap();
1059        let (auth_type, extra) = detect_auth(&spec);
1060        assert_eq!(auth_type, "header");
1061        assert_eq!(extra.get("auth_header_name").unwrap(), "X-Api-Key");
1062    }
1063
1064    #[test]
1065    fn test_auto_generate_operation_id() {
1066        assert_eq!(
1067            auto_generate_operation_id("get", "/pet/{petId}"),
1068            "get_pet_petId"
1069        );
1070        assert_eq!(
1071            auto_generate_operation_id("post", "/store/order"),
1072            "post_store_order"
1073        );
1074    }
1075
1076    #[test]
1077    fn test_input_schema_has_params() {
1078        let spec = parse_spec(PETSTORE_JSON).unwrap();
1079        let filters = OpenApiFilters {
1080            include_tags: vec![],
1081            exclude_tags: vec![],
1082            include_operations: vec!["listPets".to_string()],
1083            exclude_operations: vec![],
1084            max_operations: None,
1085        };
1086        let tools = extract_tools(&spec, &filters);
1087        assert_eq!(tools.len(), 1);
1088
1089        let schema = &tools[0].input_schema;
1090        let props = schema.get("properties").unwrap().as_object().unwrap();
1091        assert!(props.contains_key("limit"));
1092        assert!(props.contains_key("status"));
1093
1094        // Verify default value is preserved
1095        let limit = props.get("limit").unwrap();
1096        assert_eq!(limit.get("default"), Some(&json!(20)));
1097    }
1098
1099    #[test]
1100    fn test_request_body_params() {
1101        let spec = parse_spec(PETSTORE_JSON).unwrap();
1102        let filters = OpenApiFilters {
1103            include_tags: vec![],
1104            exclude_tags: vec![],
1105            include_operations: vec!["addPet".to_string()],
1106            exclude_operations: vec![],
1107            max_operations: None,
1108        };
1109        let tools = extract_tools(&spec, &filters);
1110        assert_eq!(tools.len(), 1);
1111
1112        let schema = &tools[0].input_schema;
1113        let props = schema.get("properties").unwrap().as_object().unwrap();
1114        assert!(props.contains_key("name"));
1115        assert!(props.contains_key("status"));
1116
1117        // name should be required
1118        let required = schema.get("required").unwrap().as_array().unwrap();
1119        assert!(required.contains(&json!("name")));
1120    }
1121}