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    format!("{}_{}", method, slug)
291}
292
293/// Build a description string from operation summary and/or description.
294fn build_description(op: &Operation) -> String {
295    match (&op.summary, &op.description) {
296        (Some(s), Some(d)) if s != d => format!("{s} — {d}"),
297        (Some(s), _) => s.clone(),
298        (_, Some(d)) => d.clone(),
299        (None, None) => String::new(),
300    }
301}
302
303/// Check if an operation uses multipart/form-data (file upload).
304fn is_multipart(op: &Operation) -> bool {
305    if let Some(ReferenceOr::Item(body)) = &op.request_body {
306        return body.content.contains_key("multipart/form-data");
307    }
308    false
309}
310
311/// Extract ParameterData from a Parameter enum.
312fn parameter_data(param: &Parameter) -> Option<&ParameterData> {
313    match param {
314        Parameter::Query { parameter_data, .. } => Some(parameter_data),
315        Parameter::Header { parameter_data, .. } => Some(parameter_data),
316        Parameter::Path { parameter_data, .. } => Some(parameter_data),
317        Parameter::Cookie { parameter_data, .. } => Some(parameter_data),
318    }
319}
320
321/// Get the location string for a Parameter.
322fn parameter_location(param: &Parameter) -> &'static str {
323    match param {
324        Parameter::Query { .. } => "query",
325        Parameter::Header { .. } => "header",
326        Parameter::Path { .. } => "path",
327        Parameter::Cookie { .. } => "query", // treat cookies as query for simplicity
328    }
329}
330
331/// Resolve a $ref to a Parameter component.
332#[allow(dead_code)]
333fn resolve_parameter_ref<'a>(reference: &str, spec: &'a OpenAPI) -> Option<&'a ParameterData> {
334    let name = reference.strip_prefix("#/components/parameters/")?;
335    let param = spec.components.as_ref()?.parameters.get(name)?;
336    match param {
337        ReferenceOr::Item(p) => parameter_data(p),
338        _ => None,
339    }
340}
341
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.
698#[allow(dead_code)]
699fn resolve_schema_ref_to_json(reference: &str, spec: &OpenAPI) -> Value {
700    resolve_schema_ref_to_json_depth(reference, spec, 0)
701}
702
703fn resolve_schema_ref_to_json_depth(reference: &str, spec: &OpenAPI, depth: usize) -> Value {
704    if depth >= MAX_SCHEMA_DEPTH {
705        return json!({"type": "object", "description": "(schema too deeply nested)"});
706    }
707
708    let name = match reference.strip_prefix("#/components/schemas/") {
709        Some(n) => n,
710        None => return json!({"type": "object"}),
711    };
712
713    let schema = spec.components.as_ref().and_then(|c| c.schemas.get(name));
714
715    match schema {
716        Some(schema_ref) => resolve_schema_to_json_depth(schema_ref, spec, depth + 1),
717        None => json!({"type": "object"}),
718    }
719}
720
721/// Resolve a RequestBody $ref.
722fn resolve_request_body_ref<'a>(
723    reference: &str,
724    spec: &'a OpenAPI,
725) -> Option<&'a openapiv3::RequestBody> {
726    let name = reference.strip_prefix("#/components/requestBodies/")?;
727    let body = spec.components.as_ref()?.request_bodies.get(name)?;
728    match body {
729        ReferenceOr::Item(b) => Some(b),
730        _ => None,
731    }
732}
733
734/// Detect auth scheme from an OpenAPI spec's securitySchemes.
735/// Returns (auth_type_str, extra_fields) for TOML manifest generation.
736pub fn detect_auth(spec: &OpenAPI) -> (String, HashMap<String, String>) {
737    let mut extra = HashMap::new();
738
739    let schemes = match spec.components.as_ref() {
740        Some(c) => &c.security_schemes,
741        None => return ("none".into(), extra),
742    };
743
744    // Pick the first security scheme
745    for (_name, scheme_ref) in schemes {
746        let scheme = match scheme_ref {
747            ReferenceOr::Item(s) => s,
748            _ => continue,
749        };
750
751        match scheme {
752            openapiv3::SecurityScheme::HTTP {
753                scheme: http_scheme,
754                ..
755            } => {
756                let scheme_lower = http_scheme.to_lowercase();
757                if scheme_lower == "bearer" {
758                    return ("bearer".into(), extra);
759                } else if scheme_lower == "basic" {
760                    return ("basic".into(), extra);
761                }
762            }
763            openapiv3::SecurityScheme::APIKey { location, name, .. } => match location {
764                openapiv3::APIKeyLocation::Header => {
765                    extra.insert("auth_header_name".into(), name.clone());
766                    return ("header".into(), extra);
767                }
768                openapiv3::APIKeyLocation::Query => {
769                    extra.insert("auth_query_name".into(), name.clone());
770                    return ("query".into(), extra);
771                }
772                openapiv3::APIKeyLocation::Cookie => {
773                    return ("none".into(), extra);
774                }
775            },
776            openapiv3::SecurityScheme::OAuth2 { flows, .. } => {
777                // Check for client_credentials flow
778                if let Some(cc) = &flows.client_credentials {
779                    extra.insert("oauth2_token_url".into(), cc.token_url.clone());
780                    return ("oauth2".into(), extra);
781                }
782            }
783            openapiv3::SecurityScheme::OpenIDConnect { .. } => {
784                // Not directly supported — leave for manual config
785            }
786        }
787    }
788
789    ("none".into(), extra)
790}
791
792/// Summarize operations in a spec for the `inspect` command.
793pub struct OperationSummary {
794    pub operation_id: String,
795    pub method: String,
796    pub path: String,
797    pub description: String,
798    pub tags: Vec<String>,
799}
800
801/// List all operations in an OpenAPI spec (unfiltered) for inspection.
802pub fn list_operations(spec: &OpenAPI) -> Vec<OperationSummary> {
803    let mut ops = Vec::new();
804
805    for (path_str, path_item_ref) in &spec.paths.paths {
806        let path_item = match path_item_ref {
807            ReferenceOr::Item(item) => item,
808            _ => continue,
809        };
810
811        let methods: Vec<(&str, Option<&Operation>)> = vec![
812            ("GET", path_item.get.as_ref()),
813            ("POST", path_item.post.as_ref()),
814            ("PUT", path_item.put.as_ref()),
815            ("DELETE", path_item.delete.as_ref()),
816            ("PATCH", path_item.patch.as_ref()),
817        ];
818
819        for (method, maybe_op) in methods {
820            if let Some(op) = maybe_op {
821                let operation_id = op.operation_id.clone().unwrap_or_else(|| {
822                    auto_generate_operation_id(&method.to_lowercase(), path_str)
823                });
824                let description = build_description(op);
825                ops.push(OperationSummary {
826                    operation_id,
827                    method: method.to_string(),
828                    path: path_str.clone(),
829                    description,
830                    tags: op.tags.clone(),
831                });
832            }
833        }
834    }
835
836    ops
837}
838
839/// Get the base URL from the spec's servers list.
840pub fn spec_base_url(spec: &OpenAPI) -> Option<String> {
841    spec.servers.first().map(|s| s.url.clone())
842}
843
844#[cfg(test)]
845mod tests {
846    use super::*;
847
848    const PETSTORE_JSON: &str = r#"{
849        "openapi": "3.0.3",
850        "info": { "title": "Petstore", "version": "1.0.0" },
851        "paths": {
852            "/pet/{petId}": {
853                "get": {
854                    "operationId": "getPetById",
855                    "summary": "Find pet by ID",
856                    "tags": ["pet"],
857                    "parameters": [
858                        {
859                            "name": "petId",
860                            "in": "path",
861                            "required": true,
862                            "schema": { "type": "integer" }
863                        }
864                    ],
865                    "responses": { "200": { "description": "OK" } }
866                }
867            },
868            "/pet": {
869                "post": {
870                    "operationId": "addPet",
871                    "summary": "Add a new pet",
872                    "tags": ["pet"],
873                    "requestBody": {
874                        "required": true,
875                        "content": {
876                            "application/json": {
877                                "schema": {
878                                    "type": "object",
879                                    "required": ["name"],
880                                    "properties": {
881                                        "name": { "type": "string", "description": "Pet name" },
882                                        "status": { "type": "string", "enum": ["available", "pending", "sold"] }
883                                    }
884                                }
885                            }
886                        }
887                    },
888                    "responses": { "200": { "description": "OK" } }
889                },
890                "get": {
891                    "operationId": "listPets",
892                    "summary": "List all pets",
893                    "tags": ["pet"],
894                    "parameters": [
895                        {
896                            "name": "limit",
897                            "in": "query",
898                            "schema": { "type": "integer", "default": 20 }
899                        },
900                        {
901                            "name": "status",
902                            "in": "query",
903                            "schema": { "type": "string" }
904                        }
905                    ],
906                    "responses": { "200": { "description": "OK" } }
907                }
908            },
909            "/store/order": {
910                "post": {
911                    "operationId": "placeOrder",
912                    "summary": "Place an order",
913                    "tags": ["store"],
914                    "requestBody": {
915                        "content": {
916                            "application/json": {
917                                "schema": {
918                                    "type": "object",
919                                    "properties": {
920                                        "petId": { "type": "integer" },
921                                        "quantity": { "type": "integer" }
922                                    }
923                                }
924                            }
925                        }
926                    },
927                    "responses": { "200": { "description": "OK" } }
928                }
929            }
930        },
931        "components": {
932            "securitySchemes": {
933                "api_key": {
934                    "type": "apiKey",
935                    "in": "header",
936                    "name": "X-Api-Key"
937                }
938            }
939        }
940    }"#;
941
942    #[test]
943    fn test_parse_spec() {
944        let spec = parse_spec(PETSTORE_JSON).unwrap();
945        assert_eq!(spec.info.title, "Petstore");
946    }
947
948    #[test]
949    fn test_extract_tools_no_filter() {
950        let spec = parse_spec(PETSTORE_JSON).unwrap();
951        let filters = OpenApiFilters {
952            include_tags: vec![],
953            exclude_tags: vec![],
954            include_operations: vec![],
955            exclude_operations: vec![],
956            max_operations: None,
957        };
958        let tools = extract_tools(&spec, &filters);
959        assert_eq!(tools.len(), 4); // getPetById, addPet, listPets, placeOrder
960    }
961
962    #[test]
963    fn test_extract_tools_include_tags() {
964        let spec = parse_spec(PETSTORE_JSON).unwrap();
965        let filters = OpenApiFilters {
966            include_tags: vec!["pet".to_string()],
967            exclude_tags: vec![],
968            include_operations: vec![],
969            exclude_operations: vec![],
970            max_operations: None,
971        };
972        let tools = extract_tools(&spec, &filters);
973        assert_eq!(tools.len(), 3); // Only pet-tagged operations
974        assert!(tools.iter().all(|t| t.tags.contains(&"pet".to_string())));
975    }
976
977    #[test]
978    fn test_extract_tools_exclude_operations() {
979        let spec = parse_spec(PETSTORE_JSON).unwrap();
980        let filters = OpenApiFilters {
981            include_tags: vec![],
982            exclude_tags: vec![],
983            include_operations: vec![],
984            exclude_operations: vec!["placeOrder".to_string()],
985            max_operations: None,
986        };
987        let tools = extract_tools(&spec, &filters);
988        assert_eq!(tools.len(), 3);
989        assert!(!tools.iter().any(|t| t.operation_id == "placeOrder"));
990    }
991
992    #[test]
993    fn test_extract_tools_max_operations() {
994        let spec = parse_spec(PETSTORE_JSON).unwrap();
995        let filters = OpenApiFilters {
996            include_tags: vec![],
997            exclude_tags: vec![],
998            include_operations: vec![],
999            exclude_operations: vec![],
1000            max_operations: Some(2),
1001        };
1002        let tools = extract_tools(&spec, &filters);
1003        assert_eq!(tools.len(), 2);
1004    }
1005
1006    #[test]
1007    fn test_to_ati_tool() {
1008        let spec = parse_spec(PETSTORE_JSON).unwrap();
1009        let filters = OpenApiFilters {
1010            include_tags: vec![],
1011            exclude_tags: vec![],
1012            include_operations: vec!["getPetById".to_string()],
1013            exclude_operations: vec![],
1014            max_operations: None,
1015        };
1016        let tools = extract_tools(&spec, &filters);
1017        assert_eq!(tools.len(), 1);
1018
1019        let overrides = HashMap::new();
1020        let tool = to_ati_tool(tools[0].clone(), "petstore", &overrides);
1021
1022        assert_eq!(tool.name, "petstore:getPetById");
1023        assert!(tool.description.contains("Find pet by ID"));
1024        assert_eq!(tool.endpoint, "/pet/{petId}");
1025        assert!(tool.input_schema.is_some());
1026    }
1027
1028    #[test]
1029    fn test_to_ati_tool_with_override() {
1030        let spec = parse_spec(PETSTORE_JSON).unwrap();
1031        let filters = OpenApiFilters {
1032            include_tags: vec![],
1033            exclude_tags: vec![],
1034            include_operations: vec!["getPetById".to_string()],
1035            exclude_operations: vec![],
1036            max_operations: None,
1037        };
1038        let tools = extract_tools(&spec, &filters);
1039
1040        let mut overrides = HashMap::new();
1041        overrides.insert(
1042            "getPetById".to_string(),
1043            OpenApiToolOverride {
1044                hint: Some("Use this to fetch pet details".into()),
1045                description: Some("Custom description".into()),
1046                tags: vec!["custom-tag".into()],
1047                ..Default::default()
1048            },
1049        );
1050
1051        let tool = to_ati_tool(tools[0].clone(), "petstore", &overrides);
1052        assert_eq!(tool.description, "Custom description");
1053        assert_eq!(tool.hint.as_deref(), Some("Use this to fetch pet details"));
1054        assert!(tool.tags.contains(&"custom-tag".to_string()));
1055    }
1056
1057    #[test]
1058    fn test_detect_auth_api_key_header() {
1059        let spec = parse_spec(PETSTORE_JSON).unwrap();
1060        let (auth_type, extra) = detect_auth(&spec);
1061        assert_eq!(auth_type, "header");
1062        assert_eq!(extra.get("auth_header_name").unwrap(), "X-Api-Key");
1063    }
1064
1065    #[test]
1066    fn test_auto_generate_operation_id() {
1067        assert_eq!(
1068            auto_generate_operation_id("get", "/pet/{petId}"),
1069            "get_pet_petId"
1070        );
1071        assert_eq!(
1072            auto_generate_operation_id("post", "/store/order"),
1073            "post_store_order"
1074        );
1075    }
1076
1077    #[test]
1078    fn test_input_schema_has_params() {
1079        let spec = parse_spec(PETSTORE_JSON).unwrap();
1080        let filters = OpenApiFilters {
1081            include_tags: vec![],
1082            exclude_tags: vec![],
1083            include_operations: vec!["listPets".to_string()],
1084            exclude_operations: vec![],
1085            max_operations: None,
1086        };
1087        let tools = extract_tools(&spec, &filters);
1088        assert_eq!(tools.len(), 1);
1089
1090        let schema = &tools[0].input_schema;
1091        let props = schema.get("properties").unwrap().as_object().unwrap();
1092        assert!(props.contains_key("limit"));
1093        assert!(props.contains_key("status"));
1094
1095        // Verify default value is preserved
1096        let limit = props.get("limit").unwrap();
1097        assert_eq!(limit.get("default"), Some(&json!(20)));
1098    }
1099
1100    #[test]
1101    fn test_request_body_params() {
1102        let spec = parse_spec(PETSTORE_JSON).unwrap();
1103        let filters = OpenApiFilters {
1104            include_tags: vec![],
1105            exclude_tags: vec![],
1106            include_operations: vec!["addPet".to_string()],
1107            exclude_operations: vec![],
1108            max_operations: None,
1109        };
1110        let tools = extract_tools(&spec, &filters);
1111        assert_eq!(tools.len(), 1);
1112
1113        let schema = &tools[0].input_schema;
1114        let props = schema.get("properties").unwrap().as_object().unwrap();
1115        assert!(props.contains_key("name"));
1116        assert!(props.contains_key("status"));
1117
1118        // name should be required
1119        let required = schema.get("required").unwrap().as_array().unwrap();
1120        assert!(required.contains(&json!("name")));
1121    }
1122}