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