Skip to main content

aperture_cli/
agent.rs

1use crate::cache::models::{
2    CachedApertureSecret, CachedCommand, CachedParameter, CachedRequestBody, CachedSpec,
3};
4use crate::config::models::GlobalConfig;
5use crate::config::url_resolver::BaseUrlResolver;
6use crate::constants;
7use crate::error::Error;
8use crate::spec::{resolve_parameter_reference, resolve_schema_reference};
9use crate::utils::to_kebab_case;
10use openapiv3::{OpenAPI, Operation, Parameter as OpenApiParameter, ReferenceOr, SecurityScheme};
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13
14/// Type alias for schema information extracted from a parameter
15/// Returns: (`schema_type`, `format`, `default_value`, `enum_values`, `example`)
16type ParameterSchemaInfo = (
17    Option<String>,
18    Option<String>,
19    Option<String>,
20    Vec<String>,
21    Option<String>,
22);
23
24/// JSON manifest describing all available commands and parameters for an API context.
25/// This is output when the `--describe-json` flag is used.
26#[derive(Debug, Serialize, Deserialize)]
27pub struct ApiCapabilityManifest {
28    /// Basic API metadata
29    pub api: ApiInfo,
30    /// Endpoint availability statistics
31    pub endpoints: EndpointStatistics,
32    /// Available command groups organized by tags
33    pub commands: HashMap<String, Vec<CommandInfo>>,
34    /// Security schemes available for this API
35    pub security_schemes: HashMap<String, SecuritySchemeInfo>,
36}
37
38#[derive(Debug, Serialize, Deserialize)]
39pub struct ApiInfo {
40    /// API name
41    pub name: String,
42    /// API version
43    pub version: String,
44    /// API description
45    pub description: Option<String>,
46    /// Base URL for the API
47    pub base_url: String,
48}
49
50/// Statistics about endpoint availability
51#[derive(Debug, Serialize, Deserialize)]
52pub struct EndpointStatistics {
53    /// Total number of endpoints in the `OpenAPI` spec
54    pub total: usize,
55    /// Number of endpoints available for use
56    pub available: usize,
57    /// Number of endpoints skipped due to unsupported features
58    pub skipped: usize,
59}
60
61#[derive(Debug, Serialize, Deserialize)]
62pub struct CommandInfo {
63    /// Command name (kebab-case operation ID)
64    pub name: String,
65    /// HTTP method
66    pub method: String,
67    /// API path with parameters
68    pub path: String,
69    /// Operation description
70    pub description: Option<String>,
71    /// Operation summary
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub summary: Option<String>,
74    /// Operation ID from the `OpenAPI` spec
75    pub operation_id: String,
76    /// Parameters for this operation
77    pub parameters: Vec<ParameterInfo>,
78    /// Request body information if applicable
79    pub request_body: Option<RequestBodyInfo>,
80    /// Security requirements for this operation
81    #[serde(skip_serializing_if = "Vec::is_empty", default)]
82    pub security_requirements: Vec<String>,
83    /// Tags associated with this operation (kebab-case)
84    #[serde(skip_serializing_if = "Vec::is_empty", default)]
85    pub tags: Vec<String>,
86    /// Original tag names from the `OpenAPI` spec (before kebab-case conversion)
87    #[serde(skip_serializing_if = "Vec::is_empty", default)]
88    pub original_tags: Vec<String>,
89    /// Whether this operation is deprecated
90    #[serde(skip_serializing_if = "std::ops::Not::not", default)]
91    pub deprecated: bool,
92    /// External documentation URL
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub external_docs_url: Option<String>,
95    /// Response schema for successful responses (200/201/204)
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub response_schema: Option<ResponseSchemaInfo>,
98}
99
100#[derive(Debug, Serialize, Deserialize)]
101pub struct ParameterInfo {
102    /// Parameter name
103    pub name: String,
104    /// Parameter location (path, query, header)
105    pub location: String,
106    /// Whether the parameter is required
107    pub required: bool,
108    /// Parameter type
109    pub param_type: String,
110    /// Parameter description
111    pub description: Option<String>,
112    /// Parameter format (e.g., int32, int64, date-time)
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub format: Option<String>,
115    /// Default value if specified
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub default_value: Option<String>,
118    /// Enumeration of valid values
119    #[serde(skip_serializing_if = "Vec::is_empty", default)]
120    pub enum_values: Vec<String>,
121    /// Example value
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub example: Option<String>,
124}
125
126#[derive(Debug, Serialize, Deserialize)]
127pub struct RequestBodyInfo {
128    /// Whether the request body is required
129    pub required: bool,
130    /// Content type (e.g., "application/json")
131    pub content_type: String,
132    /// Description of the request body
133    pub description: Option<String>,
134    /// Example of the request body
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub example: Option<String>,
137}
138
139/// Response schema information for successful responses (200/201/204)
140///
141/// This struct provides schema information extracted from `OpenAPI` response definitions,
142/// enabling AI agents to understand the expected response structure before execution.
143///
144/// # Current Limitations
145///
146/// 1. **Response references not resolved**: If a response is defined as a reference
147///    (e.g., `$ref: '#/components/responses/UserResponse'`), the schema will not be
148///    extracted. Only inline response definitions are processed.
149///
150/// 2. **Nested schema references not resolved**: While top-level schema references
151///    (e.g., `$ref: '#/components/schemas/User'`) are resolved, nested references
152///    within object properties remain as `$ref` objects in the output. For example:
153///    ```json
154///    {
155///      "type": "object",
156///      "properties": {
157///        "user": { "$ref": "#/components/schemas/User" }  // Not resolved
158///      }
159///    }
160///    ```
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct ResponseSchemaInfo {
163    /// Content type (e.g., "application/json")
164    pub content_type: String,
165    /// JSON Schema representation of the response body
166    ///
167    /// Note: This schema may contain unresolved `$ref` objects for nested references.
168    /// Only the top-level schema reference is resolved.
169    pub schema: serde_json::Value,
170    /// Example response if available from the spec
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub example: Option<serde_json::Value>,
173}
174
175/// Detailed, parsable security scheme description
176#[derive(Debug, Serialize, Deserialize)]
177pub struct SecuritySchemeInfo {
178    /// Type of security scheme (http, apiKey)
179    #[serde(rename = "type")]
180    pub scheme_type: String,
181    /// Optional description of the security scheme
182    pub description: Option<String>,
183    /// Detailed scheme configuration
184    #[serde(flatten)]
185    pub details: SecuritySchemeDetails,
186    /// Aperture-specific secret mapping
187    #[serde(rename = "x-aperture-secret", skip_serializing_if = "Option::is_none")]
188    pub aperture_secret: Option<CachedApertureSecret>,
189}
190
191/// Detailed security scheme configuration
192#[derive(Debug, Serialize, Deserialize)]
193#[serde(tag = "scheme", rename_all = "camelCase")]
194pub enum SecuritySchemeDetails {
195    /// HTTP authentication scheme (e.g., bearer, basic)
196    #[serde(rename = "bearer")]
197    HttpBearer {
198        /// Optional bearer token format
199        #[serde(skip_serializing_if = "Option::is_none")]
200        bearer_format: Option<String>,
201    },
202    /// HTTP basic authentication
203    #[serde(rename = "basic")]
204    HttpBasic,
205    /// API Key authentication
206    #[serde(rename = "apiKey")]
207    ApiKey {
208        /// Location of the API key (header, query, cookie)
209        #[serde(rename = "in")]
210        location: String,
211        /// Name of the parameter/header
212        name: String,
213    },
214}
215
216/// Generates a capability manifest from an `OpenAPI` specification.
217///
218/// This function creates a comprehensive JSON description of all available commands,
219/// parameters, and security requirements directly from the original `OpenAPI` spec,
220/// preserving all metadata that might be lost in the cached representation.
221///
222/// # Arguments
223/// * `api_name` - The name of the API context
224/// * `spec` - The original `OpenAPI` specification
225/// * `global_config` - Optional global configuration for URL resolution
226///
227/// # Returns
228/// * `Ok(String)` - JSON-formatted capability manifest
229/// * `Err(Error)` - If JSON serialization fails
230///
231/// # Errors
232/// Returns an error if JSON serialization fails
233pub fn generate_capability_manifest_from_openapi(
234    api_name: &str,
235    spec: &OpenAPI,
236    cached_spec: &CachedSpec,
237    global_config: Option<&GlobalConfig>,
238) -> Result<String, Error> {
239    // First, convert the OpenAPI spec to a temporary CachedSpec for URL resolution
240    let base_url = spec.servers.first().map(|s| s.url.clone());
241    let servers: Vec<String> = spec.servers.iter().map(|s| s.url.clone()).collect();
242
243    let temp_cached_spec = CachedSpec {
244        cache_format_version: crate::cache::models::CACHE_FORMAT_VERSION,
245        name: api_name.to_string(),
246        version: spec.info.version.clone(),
247        commands: vec![], // We'll generate commands directly from OpenAPI
248        base_url,
249        servers,
250        security_schemes: HashMap::new(), // We'll extract these directly too
251        skipped_endpoints: vec![],        // No endpoints are skipped for agent manifest
252        server_variables: HashMap::new(), // We'll extract these later if needed
253    };
254
255    // Resolve base URL using the same priority hierarchy as executor
256    let resolver = BaseUrlResolver::new(&temp_cached_spec);
257    let resolver = if let Some(config) = global_config {
258        resolver.with_global_config(config)
259    } else {
260        resolver
261    };
262    let resolved_base_url = resolver.resolve(None);
263
264    // Extract commands directly from OpenAPI spec, excluding skipped endpoints
265    let mut command_groups: HashMap<String, Vec<CommandInfo>> = HashMap::new();
266
267    // Build a set of skipped (path, method) pairs for efficient lookup
268    let skipped_set: std::collections::HashSet<(&str, &str)> = cached_spec
269        .skipped_endpoints
270        .iter()
271        .map(|ep| (ep.path.as_str(), ep.method.as_str()))
272        .collect();
273
274    for (path, path_item) in &spec.paths.paths {
275        let ReferenceOr::Item(item) = path_item else {
276            continue;
277        };
278
279        // Process each HTTP method
280        for (method, operation) in crate::spec::http_methods_iter(item) {
281            let Some(op) = operation else {
282                continue;
283            };
284
285            // Skip endpoints that were filtered out during caching
286            if skipped_set.contains(&(path.as_str(), method.to_uppercase().as_str())) {
287                continue;
288            }
289
290            let command_info =
291                convert_openapi_operation_to_info(method, path, op, spec, spec.security.as_ref());
292
293            // Group by first tag or "default", converted to kebab-case
294            let group_name = op.tags.first().map_or_else(
295                || constants::DEFAULT_GROUP.to_string(),
296                |tag| to_kebab_case(tag),
297            );
298
299            command_groups
300                .entry(group_name)
301                .or_default()
302                .push(command_info);
303        }
304    }
305
306    // Extract security schemes directly from OpenAPI
307    let security_schemes = extract_security_schemes_from_openapi(spec);
308
309    // Compute endpoint statistics from the cached spec
310    let skipped = cached_spec.skipped_endpoints.len();
311    let available = cached_spec.commands.len();
312    let total = available + skipped;
313
314    // Create the manifest
315    let manifest = ApiCapabilityManifest {
316        api: ApiInfo {
317            name: spec.info.title.clone(),
318            version: spec.info.version.clone(),
319            description: spec.info.description.clone(),
320            base_url: resolved_base_url,
321        },
322        endpoints: EndpointStatistics {
323            total,
324            available,
325            skipped,
326        },
327        commands: command_groups,
328        security_schemes,
329    };
330
331    // Serialize to JSON
332    serde_json::to_string_pretty(&manifest)
333        .map_err(|e| Error::serialization_error(format!("Failed to serialize agent manifest: {e}")))
334}
335
336/// Generates a capability manifest from a cached API specification.
337///
338/// This function creates a comprehensive JSON description of all available commands,
339/// parameters, and security requirements for the given API context.
340///
341/// # Arguments
342/// * `spec` - The cached API specification
343/// * `global_config` - Optional global configuration for URL resolution
344///
345/// # Returns
346/// * `Ok(String)` - JSON-formatted capability manifest
347/// * `Err(Error)` - If JSON serialization fails
348///
349/// # Errors
350/// Returns an error if JSON serialization fails
351pub fn generate_capability_manifest(
352    spec: &CachedSpec,
353    global_config: Option<&GlobalConfig>,
354) -> Result<String, Error> {
355    let mut command_groups: HashMap<String, Vec<CommandInfo>> = HashMap::new();
356
357    // Group commands by their tag (namespace) and convert to CommandInfo
358    for cached_command in &spec.commands {
359        let group_name = if cached_command.name.is_empty() {
360            constants::DEFAULT_GROUP.to_string()
361        } else {
362            to_kebab_case(&cached_command.name)
363        };
364
365        let command_info = convert_cached_command_to_info(cached_command);
366        command_groups
367            .entry(group_name)
368            .or_default()
369            .push(command_info);
370    }
371
372    // Resolve base URL using the same priority hierarchy as executor
373    let resolver = BaseUrlResolver::new(spec);
374    let resolver = if let Some(config) = global_config {
375        resolver.with_global_config(config)
376    } else {
377        resolver
378    };
379    let base_url = resolver.resolve(None);
380
381    // Compute endpoint statistics
382    let skipped = spec.skipped_endpoints.len();
383    let available = spec.commands.len();
384    let total = available + skipped;
385
386    // Create the manifest
387    let manifest = ApiCapabilityManifest {
388        api: ApiInfo {
389            name: spec.name.clone(),
390            version: spec.version.clone(),
391            description: None, // Not available in cached spec
392            base_url,
393        },
394        endpoints: EndpointStatistics {
395            total,
396            available,
397            skipped,
398        },
399        commands: command_groups,
400        security_schemes: extract_security_schemes(spec),
401    };
402
403    // Serialize to JSON
404    serde_json::to_string_pretty(&manifest)
405        .map_err(|e| Error::serialization_error(format!("Failed to serialize agent manifest: {e}")))
406}
407
408/// Converts a `CachedCommand` to `CommandInfo` for the manifest
409fn convert_cached_command_to_info(cached_command: &CachedCommand) -> CommandInfo {
410    let command_name = if cached_command.operation_id.is_empty() {
411        cached_command.method.to_lowercase()
412    } else {
413        to_kebab_case(&cached_command.operation_id)
414    };
415
416    let parameters: Vec<ParameterInfo> = cached_command
417        .parameters
418        .iter()
419        .map(convert_cached_parameter_to_info)
420        .collect();
421
422    let request_body = cached_command
423        .request_body
424        .as_ref()
425        .map(convert_cached_request_body_to_info);
426
427    // Extract response schema from cached responses
428    let response_schema = extract_response_schema_from_cached(&cached_command.responses);
429
430    CommandInfo {
431        name: command_name,
432        method: cached_command.method.clone(),
433        path: cached_command.path.clone(),
434        description: cached_command.description.clone(),
435        summary: cached_command.summary.clone(),
436        operation_id: cached_command.operation_id.clone(),
437        parameters,
438        request_body,
439        security_requirements: cached_command.security_requirements.clone(),
440        tags: cached_command
441            .tags
442            .iter()
443            .map(|t| to_kebab_case(t))
444            .collect(),
445        original_tags: cached_command.tags.clone(),
446        deprecated: cached_command.deprecated,
447        external_docs_url: cached_command.external_docs_url.clone(),
448        response_schema,
449    }
450}
451
452/// Converts a `CachedParameter` to `ParameterInfo` for the manifest
453fn convert_cached_parameter_to_info(cached_param: &CachedParameter) -> ParameterInfo {
454    ParameterInfo {
455        name: cached_param.name.clone(),
456        location: cached_param.location.clone(),
457        required: cached_param.required,
458        param_type: cached_param
459            .schema_type
460            .clone()
461            .unwrap_or_else(|| constants::SCHEMA_TYPE_STRING.to_string()),
462        description: cached_param.description.clone(),
463        format: cached_param.format.clone(),
464        default_value: cached_param.default_value.clone(),
465        enum_values: cached_param.enum_values.clone(),
466        example: cached_param.example.clone(),
467    }
468}
469
470/// Converts a `CachedRequestBody` to `RequestBodyInfo` for the manifest
471fn convert_cached_request_body_to_info(cached_body: &CachedRequestBody) -> RequestBodyInfo {
472    RequestBodyInfo {
473        required: cached_body.required,
474        content_type: cached_body.content_type.clone(),
475        description: cached_body.description.clone(),
476        example: cached_body.example.clone(),
477    }
478}
479
480/// Extracts response schema from cached responses
481///
482/// Looks for successful response codes (200, 201, 204) in priority order.
483/// If a response exists but lacks `content_type` or schema, falls through to
484/// check the next status code.
485fn extract_response_schema_from_cached(
486    responses: &[crate::cache::models::CachedResponse],
487) -> Option<ResponseSchemaInfo> {
488    constants::SUCCESS_STATUS_CODES.iter().find_map(|code| {
489        responses
490            .iter()
491            .find(|r| r.status_code == *code)
492            .and_then(|response| {
493                let content_type = response.content_type.as_ref()?;
494                let schema_str = response.schema.as_ref()?;
495                let schema = serde_json::from_str(schema_str).ok()?;
496                let example = response
497                    .example
498                    .as_ref()
499                    .and_then(|ex| serde_json::from_str(ex).ok());
500
501                Some(ResponseSchemaInfo {
502                    content_type: content_type.clone(),
503                    schema,
504                    example,
505                })
506            })
507    })
508}
509
510/// Extracts security schemes from the cached spec for the capability manifest
511fn extract_security_schemes(spec: &CachedSpec) -> HashMap<String, SecuritySchemeInfo> {
512    let mut security_schemes = HashMap::new();
513
514    for (name, scheme) in &spec.security_schemes {
515        let details = match scheme.scheme_type.as_str() {
516            constants::SECURITY_TYPE_HTTP => {
517                scheme.scheme.as_ref().map_or(
518                    SecuritySchemeDetails::HttpBearer {
519                        bearer_format: None,
520                    },
521                    |http_scheme| match http_scheme.as_str() {
522                        constants::AUTH_SCHEME_BEARER => SecuritySchemeDetails::HttpBearer {
523                            bearer_format: scheme.bearer_format.clone(),
524                        },
525                        constants::AUTH_SCHEME_BASIC => SecuritySchemeDetails::HttpBasic,
526                        _ => {
527                            // For other HTTP schemes, default to bearer
528                            SecuritySchemeDetails::HttpBearer {
529                                bearer_format: None,
530                            }
531                        }
532                    },
533                )
534            }
535            constants::AUTH_SCHEME_APIKEY => SecuritySchemeDetails::ApiKey {
536                location: scheme
537                    .location
538                    .clone()
539                    .unwrap_or_else(|| constants::LOCATION_HEADER.to_string()),
540                name: scheme
541                    .parameter_name
542                    .clone()
543                    .unwrap_or_else(|| constants::HEADER_AUTHORIZATION.to_string()),
544            },
545            _ => {
546                // Default to bearer for unknown types
547                SecuritySchemeDetails::HttpBearer {
548                    bearer_format: None,
549                }
550            }
551        };
552
553        let scheme_info = SecuritySchemeInfo {
554            scheme_type: scheme.scheme_type.clone(),
555            description: scheme.description.clone(),
556            details,
557            aperture_secret: scheme.aperture_secret.clone(),
558        };
559
560        security_schemes.insert(name.clone(), scheme_info);
561    }
562
563    security_schemes
564}
565
566/// Converts an `OpenAPI` operation to `CommandInfo` with full metadata
567fn convert_openapi_operation_to_info(
568    method: &str,
569    path: &str,
570    operation: &Operation,
571    spec: &OpenAPI,
572    global_security: Option<&Vec<openapiv3::SecurityRequirement>>,
573) -> CommandInfo {
574    let command_name = operation
575        .operation_id
576        .as_ref()
577        .map_or_else(|| method.to_lowercase(), |op_id| to_kebab_case(op_id));
578
579    // Extract parameters with full metadata, resolving references
580    let parameters: Vec<ParameterInfo> = operation
581        .parameters
582        .iter()
583        .filter_map(|param_ref| match param_ref {
584            ReferenceOr::Item(param) => Some(convert_openapi_parameter_to_info(param)),
585            ReferenceOr::Reference { reference } => resolve_parameter_reference(spec, reference)
586                .ok()
587                .map(|param| convert_openapi_parameter_to_info(&param)),
588        })
589        .collect();
590
591    // Extract request body info
592    let request_body = operation.request_body.as_ref().and_then(|rb_ref| {
593        let ReferenceOr::Item(body) = rb_ref else {
594            return None;
595        };
596
597        // Prefer JSON content if available
598        let content_type = if body.content.contains_key(constants::CONTENT_TYPE_JSON) {
599            constants::CONTENT_TYPE_JSON
600        } else {
601            body.content.keys().next().map(String::as_str)?
602        };
603
604        let media_type = body.content.get(content_type)?;
605        let example = media_type
606            .example
607            .as_ref()
608            .map(|ex| serde_json::to_string(ex).unwrap_or_else(|_| ex.to_string()));
609
610        Some(RequestBodyInfo {
611            required: body.required,
612            content_type: content_type.to_string(),
613            description: body.description.clone(),
614            example,
615        })
616    });
617
618    // Extract security requirements
619    let security_requirements = operation.security.as_ref().map_or_else(
620        || {
621            global_security.map_or(vec![], |reqs| {
622                reqs.iter().flat_map(|req| req.keys().cloned()).collect()
623            })
624        },
625        |op_security| {
626            op_security
627                .iter()
628                .flat_map(|req| req.keys().cloned())
629                .collect()
630        },
631    );
632
633    // Extract response schema from successful responses (200, 201, 204)
634    let response_schema = extract_response_schema_from_operation(operation, spec);
635
636    CommandInfo {
637        name: command_name,
638        method: method.to_uppercase(),
639        path: path.to_string(),
640        description: operation.description.clone(),
641        summary: operation.summary.clone(),
642        operation_id: operation.operation_id.clone().unwrap_or_default(),
643        parameters,
644        request_body,
645        security_requirements,
646        tags: operation.tags.iter().map(|t| to_kebab_case(t)).collect(),
647        original_tags: operation.tags.clone(),
648        deprecated: operation.deprecated,
649        external_docs_url: operation
650            .external_docs
651            .as_ref()
652            .map(|docs| docs.url.clone()),
653        response_schema,
654    }
655}
656
657/// Extracts response schema from an operation's responses
658///
659/// Looks for successful response codes (200, 201, 204) in priority order
660/// and extracts the schema for the first one found with application/json content.
661fn extract_response_schema_from_operation(
662    operation: &Operation,
663    spec: &OpenAPI,
664) -> Option<ResponseSchemaInfo> {
665    constants::SUCCESS_STATUS_CODES.iter().find_map(|code| {
666        operation
667            .responses
668            .responses
669            .get(&openapiv3::StatusCode::Code(
670                code.parse().expect("valid status code"),
671            ))
672            .and_then(|response_ref| extract_response_schema_from_response(response_ref, spec))
673    })
674}
675
676/// Extracts response schema from a single response reference
677///
678/// # Limitations
679///
680/// - **Response references are not resolved**: If `response_ref` is a `$ref` to
681///   `#/components/responses/...`, this function returns `None`. Only inline
682///   response definitions are processed. This is a known limitation that may
683///   be addressed in a future version.
684///
685/// - **Nested schema references**: While top-level schema references within the
686///   response content are resolved, any nested `$ref` within the schema's
687///   properties remain unresolved. See [`ResponseSchemaInfo`] for details.
688fn extract_response_schema_from_response(
689    response_ref: &ReferenceOr<openapiv3::Response>,
690    spec: &OpenAPI,
691) -> Option<ResponseSchemaInfo> {
692    // Note: Response references ($ref to #/components/responses/...) are not
693    // currently resolved. This would require implementing resolve_response_reference()
694    // similar to resolve_schema_reference().
695    let ReferenceOr::Item(response) = response_ref else {
696        return None;
697    };
698
699    // Prefer application/json content type
700    let content_type = if response.content.contains_key(constants::CONTENT_TYPE_JSON) {
701        constants::CONTENT_TYPE_JSON
702    } else {
703        // Fall back to first available content type
704        response.content.keys().next().map(String::as_str)?
705    };
706
707    let media_type = response.content.get(content_type)?;
708    let schema_ref = media_type.schema.as_ref()?;
709
710    // Resolve schema reference if necessary
711    let schema_value = match schema_ref {
712        ReferenceOr::Item(schema) => serde_json::to_value(schema).ok()?,
713        ReferenceOr::Reference { reference } => {
714            let resolved = resolve_schema_reference(spec, reference).ok()?;
715            serde_json::to_value(&resolved).ok()?
716        }
717    };
718
719    // Extract example if available
720    let example = media_type
721        .example
722        .as_ref()
723        .and_then(|ex| serde_json::to_value(ex).ok());
724
725    Some(ResponseSchemaInfo {
726        content_type: content_type.to_string(),
727        schema: schema_value,
728        example,
729    })
730}
731
732/// Extracts schema information from a parameter format
733fn extract_schema_info_from_parameter(
734    format: &openapiv3::ParameterSchemaOrContent,
735) -> ParameterSchemaInfo {
736    let openapiv3::ParameterSchemaOrContent::Schema(schema_ref) = format else {
737        return (
738            Some(constants::SCHEMA_TYPE_STRING.to_string()),
739            None,
740            None,
741            vec![],
742            None,
743        );
744    };
745
746    match schema_ref {
747        ReferenceOr::Item(schema) => {
748            let (schema_type, format, enums) =
749                extract_schema_type_from_schema_kind(&schema.schema_kind);
750
751            let default_value = schema
752                .schema_data
753                .default
754                .as_ref()
755                .map(|v| serde_json::to_string(v).unwrap_or_else(|_| v.to_string()));
756
757            (Some(schema_type), format, default_value, enums, None)
758        }
759        ReferenceOr::Reference { .. } => (
760            Some(constants::SCHEMA_TYPE_STRING.to_string()),
761            None,
762            None,
763            vec![],
764            None,
765        ),
766    }
767}
768
769/// Extracts type information from a schema kind
770fn extract_schema_type_from_schema_kind(
771    schema_kind: &openapiv3::SchemaKind,
772) -> (String, Option<String>, Vec<String>) {
773    match schema_kind {
774        openapiv3::SchemaKind::Type(type_val) => match type_val {
775            openapiv3::Type::String(string_type) => {
776                let enum_values: Vec<String> = string_type
777                    .enumeration
778                    .iter()
779                    .filter_map(|v| v.as_ref())
780                    .map(|v| serde_json::to_string(v).unwrap_or_else(|_| v.clone()))
781                    .collect();
782                (constants::SCHEMA_TYPE_STRING.to_string(), None, enum_values)
783            }
784            openapiv3::Type::Number(_) => (constants::SCHEMA_TYPE_NUMBER.to_string(), None, vec![]),
785            openapiv3::Type::Integer(_) => {
786                (constants::SCHEMA_TYPE_INTEGER.to_string(), None, vec![])
787            }
788            openapiv3::Type::Boolean(_) => {
789                (constants::SCHEMA_TYPE_BOOLEAN.to_string(), None, vec![])
790            }
791            openapiv3::Type::Array(_) => (constants::SCHEMA_TYPE_ARRAY.to_string(), None, vec![]),
792            openapiv3::Type::Object(_) => (constants::SCHEMA_TYPE_OBJECT.to_string(), None, vec![]),
793        },
794        _ => (constants::SCHEMA_TYPE_STRING.to_string(), None, vec![]),
795    }
796}
797
798/// Converts an `OpenAPI` parameter to `ParameterInfo` with full metadata
799fn convert_openapi_parameter_to_info(param: &OpenApiParameter) -> ParameterInfo {
800    let (param_data, location_str) = match param {
801        OpenApiParameter::Query { parameter_data, .. } => {
802            (parameter_data, constants::PARAM_LOCATION_QUERY)
803        }
804        OpenApiParameter::Header { parameter_data, .. } => {
805            (parameter_data, constants::PARAM_LOCATION_HEADER)
806        }
807        OpenApiParameter::Path { parameter_data, .. } => {
808            (parameter_data, constants::PARAM_LOCATION_PATH)
809        }
810        OpenApiParameter::Cookie { parameter_data, .. } => {
811            (parameter_data, constants::PARAM_LOCATION_COOKIE)
812        }
813    };
814
815    // Extract schema information
816    let (schema_type, format, default_value, enum_values, example) =
817        extract_schema_info_from_parameter(&param_data.format);
818
819    // Extract example from parameter data
820    let example = param_data
821        .example
822        .as_ref()
823        .map(|ex| serde_json::to_string(ex).unwrap_or_else(|_| ex.to_string()))
824        .or(example);
825
826    ParameterInfo {
827        name: param_data.name.clone(),
828        location: location_str.to_string(),
829        required: param_data.required,
830        param_type: schema_type.unwrap_or_else(|| constants::SCHEMA_TYPE_STRING.to_string()),
831        description: param_data.description.clone(),
832        format,
833        default_value,
834        enum_values,
835        example,
836    }
837}
838
839/// Extracts security schemes directly from `OpenAPI` spec
840fn extract_security_schemes_from_openapi(spec: &OpenAPI) -> HashMap<String, SecuritySchemeInfo> {
841    let mut security_schemes = HashMap::new();
842
843    let Some(components) = &spec.components else {
844        return security_schemes;
845    };
846
847    for (name, scheme_ref) in &components.security_schemes {
848        let ReferenceOr::Item(scheme) = scheme_ref else {
849            continue;
850        };
851
852        let Some(scheme_info) = convert_openapi_security_scheme(name, scheme) else {
853            continue;
854        };
855
856        security_schemes.insert(name.clone(), scheme_info);
857    }
858
859    security_schemes
860}
861
862/// Converts an `OpenAPI` security scheme to `SecuritySchemeInfo`
863fn convert_openapi_security_scheme(
864    _name: &str,
865    scheme: &SecurityScheme,
866) -> Option<SecuritySchemeInfo> {
867    match scheme {
868        SecurityScheme::APIKey {
869            location,
870            name: param_name,
871            description,
872            ..
873        } => {
874            let location_str = match location {
875                openapiv3::APIKeyLocation::Query => constants::PARAM_LOCATION_QUERY,
876                openapiv3::APIKeyLocation::Header => constants::PARAM_LOCATION_HEADER,
877                openapiv3::APIKeyLocation::Cookie => constants::PARAM_LOCATION_COOKIE,
878            };
879
880            let aperture_secret = extract_aperture_secret_from_extensions(scheme);
881
882            Some(SecuritySchemeInfo {
883                scheme_type: constants::AUTH_SCHEME_APIKEY.to_string(),
884                description: description.clone(),
885                details: SecuritySchemeDetails::ApiKey {
886                    location: location_str.to_string(),
887                    name: param_name.clone(),
888                },
889                aperture_secret,
890            })
891        }
892        SecurityScheme::HTTP {
893            scheme: http_scheme,
894            bearer_format,
895            description,
896            ..
897        } => {
898            let details = match http_scheme.as_str() {
899                constants::AUTH_SCHEME_BEARER => SecuritySchemeDetails::HttpBearer {
900                    bearer_format: bearer_format.clone(),
901                },
902                constants::AUTH_SCHEME_BASIC => SecuritySchemeDetails::HttpBasic,
903                _ => SecuritySchemeDetails::HttpBearer {
904                    bearer_format: None,
905                },
906            };
907
908            let aperture_secret = extract_aperture_secret_from_extensions(scheme);
909
910            Some(SecuritySchemeInfo {
911                scheme_type: constants::SECURITY_TYPE_HTTP.to_string(),
912                description: description.clone(),
913                details,
914                aperture_secret,
915            })
916        }
917        SecurityScheme::OAuth2 { .. } | SecurityScheme::OpenIDConnect { .. } => None,
918    }
919}
920
921/// Extracts x-aperture-secret extension from a security scheme's extensions
922fn extract_aperture_secret_from_extensions(
923    scheme: &SecurityScheme,
924) -> Option<CachedApertureSecret> {
925    let extensions = match scheme {
926        SecurityScheme::APIKey { extensions, .. } | SecurityScheme::HTTP { extensions, .. } => {
927            extensions
928        }
929        SecurityScheme::OAuth2 { .. } | SecurityScheme::OpenIDConnect { .. } => return None,
930    };
931
932    extensions
933        .get(constants::EXT_APERTURE_SECRET)
934        .and_then(|value| {
935            let obj = value.as_object()?;
936            let source = obj.get(constants::EXT_KEY_SOURCE)?.as_str()?;
937            let name = obj.get(constants::EXT_KEY_NAME)?.as_str()?;
938
939            if source != constants::SOURCE_ENV {
940                return None;
941            }
942
943            Some(CachedApertureSecret {
944                source: source.to_string(),
945                name: name.to_string(),
946            })
947        })
948}
949
950#[cfg(test)]
951mod tests {
952    use super::*;
953    use crate::cache::models::{
954        CachedApertureSecret, CachedCommand, CachedParameter, CachedSecurityScheme, CachedSpec,
955    };
956
957    #[test]
958    fn test_command_name_conversion() {
959        // Test that command names are properly converted
960        assert_eq!(to_kebab_case("getUserById"), "get-user-by-id");
961        assert_eq!(to_kebab_case("createUser"), "create-user");
962        assert_eq!(to_kebab_case("list"), "list");
963        assert_eq!(to_kebab_case("GET"), "get");
964        assert_eq!(
965            to_kebab_case("List an Organization's Issues"),
966            "list-an-organizations-issues"
967        );
968    }
969
970    #[test]
971    fn test_generate_capability_manifest() {
972        let mut security_schemes = HashMap::new();
973        security_schemes.insert(
974            "bearerAuth".to_string(),
975            CachedSecurityScheme {
976                name: "bearerAuth".to_string(),
977                scheme_type: constants::SECURITY_TYPE_HTTP.to_string(),
978                scheme: Some(constants::AUTH_SCHEME_BEARER.to_string()),
979                location: Some(constants::LOCATION_HEADER.to_string()),
980                parameter_name: Some(constants::HEADER_AUTHORIZATION.to_string()),
981                description: None,
982                bearer_format: None,
983                aperture_secret: Some(CachedApertureSecret {
984                    source: constants::SOURCE_ENV.to_string(),
985                    name: "API_TOKEN".to_string(),
986                }),
987            },
988        );
989
990        let spec = CachedSpec {
991            cache_format_version: crate::cache::models::CACHE_FORMAT_VERSION,
992            name: "Test API".to_string(),
993            version: "1.0.0".to_string(),
994            commands: vec![CachedCommand {
995                name: "users".to_string(),
996                description: Some("Get user by ID".to_string()),
997                summary: None,
998                operation_id: "getUserById".to_string(),
999                method: constants::HTTP_METHOD_GET.to_string(),
1000                path: "/users/{id}".to_string(),
1001                parameters: vec![CachedParameter {
1002                    name: "id".to_string(),
1003                    location: constants::PARAM_LOCATION_PATH.to_string(),
1004                    required: true,
1005                    description: None,
1006                    schema: Some(constants::SCHEMA_TYPE_STRING.to_string()),
1007                    schema_type: Some(constants::SCHEMA_TYPE_STRING.to_string()),
1008                    format: None,
1009                    default_value: None,
1010                    enum_values: vec![],
1011                    example: None,
1012                }],
1013                request_body: None,
1014                responses: vec![],
1015                security_requirements: vec!["bearerAuth".to_string()],
1016                tags: vec!["users".to_string()],
1017                deprecated: false,
1018                external_docs_url: None,
1019                examples: vec![],
1020            }],
1021            base_url: Some("https://test-api.example.com".to_string()),
1022            servers: vec!["https://test-api.example.com".to_string()],
1023            security_schemes,
1024            skipped_endpoints: vec![],
1025            server_variables: HashMap::new(),
1026        };
1027
1028        let manifest_json = generate_capability_manifest(&spec, None).unwrap();
1029        let manifest: ApiCapabilityManifest = serde_json::from_str(&manifest_json).unwrap();
1030
1031        assert_eq!(manifest.api.name, "Test API");
1032        assert_eq!(manifest.api.version, "1.0.0");
1033        assert!(manifest.commands.contains_key("users"));
1034
1035        let users_commands = &manifest.commands["users"];
1036        assert_eq!(users_commands.len(), 1);
1037        assert_eq!(users_commands[0].name, "get-user-by-id");
1038        assert_eq!(users_commands[0].method, constants::HTTP_METHOD_GET);
1039        assert_eq!(users_commands[0].parameters.len(), 1);
1040        assert_eq!(users_commands[0].parameters[0].name, "id");
1041
1042        // Test security information extraction
1043        assert!(!manifest.security_schemes.is_empty());
1044        assert!(manifest.security_schemes.contains_key("bearerAuth"));
1045        let bearer_auth = &manifest.security_schemes["bearerAuth"];
1046        assert_eq!(bearer_auth.scheme_type, constants::SECURITY_TYPE_HTTP);
1047        assert!(matches!(
1048            &bearer_auth.details,
1049            SecuritySchemeDetails::HttpBearer { .. }
1050        ));
1051        assert!(bearer_auth.aperture_secret.is_some());
1052        let aperture_secret = bearer_auth.aperture_secret.as_ref().unwrap();
1053        assert_eq!(aperture_secret.name, "API_TOKEN");
1054        assert_eq!(aperture_secret.source, constants::SOURCE_ENV);
1055    }
1056}