use crate::cache::models::{
CachedApertureSecret, CachedCommand, CachedParameter, CachedRequestBody, CachedSpec,
};
use crate::config::models::GlobalConfig;
use crate::config::url_resolver::BaseUrlResolver;
use crate::constants;
use crate::error::Error;
use crate::spec::{resolve_parameter_reference, resolve_schema_reference};
use crate::utils::to_kebab_case;
use openapiv3::{OpenAPI, Operation, Parameter as OpenApiParameter, ReferenceOr, SecurityScheme};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
type ParameterSchemaInfo = (
Option<String>,
Option<String>,
Option<String>,
Vec<String>,
Option<String>,
);
#[derive(Debug, Serialize, Deserialize)]
pub struct ApiCapabilityManifest {
pub api: ApiInfo,
pub endpoints: EndpointStatistics,
pub commands: HashMap<String, Vec<CommandInfo>>,
pub security_schemes: HashMap<String, SecuritySchemeInfo>,
pub batch: BatchCapabilityInfo,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ApiInfo {
pub name: String,
pub version: String,
pub description: Option<String>,
pub base_url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct EndpointStatistics {
pub total: usize,
pub available: usize,
pub skipped: usize,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BatchCapabilityInfo {
pub file_formats: Vec<String>,
pub operation_schema: BatchOperationSchema,
pub dependent_workflows: DependentWorkflowInfo,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BatchOperationSchema {
pub fields: Vec<BatchFieldInfo>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BatchFieldInfo {
pub name: String,
#[serde(rename = "type")]
pub field_type: String,
pub required: bool,
pub description: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DependentWorkflowInfo {
pub interpolation_syntax: String,
pub execution_modes: ExecutionModeInfo,
pub dependent_execution: DependentExecutionInfo,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ExecutionModeInfo {
pub concurrent: String,
pub dependent: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DependentExecutionInfo {
pub ordering: String,
pub failure_mode: String,
pub implicit_dependencies: bool,
pub variable_types: VariableTypeInfo,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct VariableTypeInfo {
pub scalar: String,
pub list: String,
}
fn batch_operation_fields() -> Vec<BatchFieldInfo> {
vec![
BatchFieldInfo {
name: "id".into(),
field_type: "string".into(),
required: false,
description: "Unique identifier. Required when using capture, capture_append, or depends_on.".into(),
},
BatchFieldInfo {
name: "args".into(),
field_type: "string[]".into(),
required: true,
description: "Command arguments (e.g. [\"users\", \"create-user\", \"--body\", \"{...}\"] or [\"users\", \"create-user\", \"--body-file\", \"/path/to/body.json\"]).".into(),
},
BatchFieldInfo {
name: "description".into(),
field_type: "string".into(),
required: false,
description: "Human-readable description of this operation.".into(),
},
BatchFieldInfo {
name: "headers".into(),
field_type: "map<string, string>".into(),
required: false,
description: "Custom HTTP headers for this operation.".into(),
},
BatchFieldInfo {
name: "capture".into(),
field_type: "map<string, string>".into(),
required: false,
description: "Extract scalar values from the response via JQ queries. Maps variable_name → jq_query (e.g. {\"user_id\": \".id\"}). Captured values are available as {{variable_name}} in subsequent operations.".into(),
},
BatchFieldInfo {
name: "capture_append".into(),
field_type: "map<string, string>".into(),
required: false,
description: "Append extracted values to a named list via JQ queries. Multiple operations can append to the same list. The list interpolates as a JSON array literal (e.g. [\"a\",\"b\"]).".into(),
},
BatchFieldInfo {
name: "depends_on".into(),
field_type: "string[]".into(),
required: false,
description: "Explicit dependency on other operations by id. This operation waits until all listed operations have completed. Dependencies can also be inferred from {{variable}} usage.".into(),
},
BatchFieldInfo {
name: "use_cache".into(),
field_type: "boolean".into(),
required: false,
description: "Enable response caching for this operation.".into(),
},
BatchFieldInfo {
name: "retry".into(),
field_type: "integer".into(),
required: false,
description: "Maximum retry attempts for this operation.".into(),
},
BatchFieldInfo {
name: "retry_delay".into(),
field_type: "string".into(),
required: false,
description: "Initial retry delay (e.g. \"500ms\", \"1s\").".into(),
},
BatchFieldInfo {
name: "retry_max_delay".into(),
field_type: "string".into(),
required: false,
description: "Maximum retry delay cap (e.g. \"30s\", \"1m\").".into(),
},
BatchFieldInfo {
name: "force_retry".into(),
field_type: "boolean".into(),
required: false,
description: "Allow retrying non-idempotent requests without an idempotency key.".into(),
},
BatchFieldInfo {
name: "body_file".into(),
field_type: "string".into(),
required: false,
description: "Read the request body from this file path instead of embedding JSON in args. Equivalent to --body-file in args; avoids quoting issues with large or complex JSON payloads. Mutually exclusive with --body or --body-file entries in args.".into(),
},
]
}
fn build_batch_capability_info() -> BatchCapabilityInfo {
BatchCapabilityInfo {
file_formats: vec!["json".into(), "yaml".into()],
operation_schema: BatchOperationSchema {
fields: batch_operation_fields(),
},
dependent_workflows: DependentWorkflowInfo {
interpolation_syntax: "{{variable_name}}".into(),
execution_modes: ExecutionModeInfo {
concurrent: "Used when no operation has capture, capture_append, or depends_on. Operations run in parallel with concurrency and rate-limit controls.".into(),
dependent: "Used when any operation has capture, capture_append, or depends_on. Operations run sequentially in topological order with variable interpolation.".into(),
},
dependent_execution: DependentExecutionInfo {
ordering: "Topological sort via Kahn's algorithm. Operations without dependencies preserve original file order.".into(),
failure_mode: "Atomic: halts on first failure. Subsequent operations are marked as skipped.".into(),
implicit_dependencies: true,
variable_types: VariableTypeInfo {
scalar: "From capture — {{name}} interpolates as the extracted string value.".into(),
list: "From capture_append — {{name}} interpolates as a JSON array literal (e.g. [\"a\",\"b\"]).".into(),
},
},
},
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CommandInfo {
pub name: String,
pub method: String,
pub path: String,
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
pub operation_id: String,
pub parameters: Vec<ParameterInfo>,
pub request_body: Option<RequestBodyInfo>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub security_requirements: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub tags: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub original_tags: Vec<String>,
#[serde(skip_serializing_if = "std::ops::Not::not", default)]
pub deprecated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub external_docs_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub response_schema: Option<ResponseSchemaInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_group: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub aliases: Vec<String>,
#[serde(skip_serializing_if = "std::ops::Not::not", default)]
pub hidden: bool,
pub pagination: PaginationManifestInfo,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ParameterInfo {
pub name: String,
pub location: String,
pub required: bool,
pub param_type: String,
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub format: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_value: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub enum_values: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub example: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RequestBodyInfo {
pub required: bool,
pub content_type: String,
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub example: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct PaginationManifestInfo {
pub supported: bool,
pub strategy: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub cursor_field: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cursor_param: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub page_param: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub limit_param: Option<String>,
}
impl Default for PaginationManifestInfo {
fn default() -> Self {
Self {
supported: false,
strategy: crate::constants::PAGINATION_STRATEGY_NONE.to_string(),
cursor_field: None,
cursor_param: None,
page_param: None,
limit_param: None,
}
}
}
impl PaginationManifestInfo {
fn from_cached(info: &crate::cache::models::PaginationInfo) -> Self {
use crate::cache::models::PaginationStrategy;
use crate::constants;
let (supported, strategy) = match info.strategy {
PaginationStrategy::None => (false, constants::PAGINATION_STRATEGY_NONE),
PaginationStrategy::Cursor => (true, constants::PAGINATION_STRATEGY_CURSOR),
PaginationStrategy::Offset => (true, constants::PAGINATION_STRATEGY_OFFSET),
PaginationStrategy::LinkHeader => (true, constants::PAGINATION_STRATEGY_LINK_HEADER),
};
Self {
supported,
strategy: strategy.to_string(),
cursor_field: info.cursor_field.clone(),
cursor_param: info.cursor_param.clone(),
page_param: info.page_param.clone(),
limit_param: info.limit_param.clone(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResponseSchemaInfo {
pub content_type: String,
pub schema: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub example: Option<serde_json::Value>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SecuritySchemeInfo {
#[serde(rename = "type")]
pub scheme_type: String,
pub description: Option<String>,
#[serde(flatten)]
pub details: SecuritySchemeDetails,
#[serde(rename = "x-aperture-secret", skip_serializing_if = "Option::is_none")]
pub aperture_secret: Option<CachedApertureSecret>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "scheme", rename_all = "camelCase")]
pub enum SecuritySchemeDetails {
#[serde(rename = "bearer")]
HttpBearer {
#[serde(skip_serializing_if = "Option::is_none")]
bearer_format: Option<String>,
},
#[serde(rename = "basic")]
HttpBasic,
#[serde(rename = "apiKey")]
ApiKey {
#[serde(rename = "in")]
location: String,
name: String,
},
}
pub fn generate_capability_manifest_from_openapi(
api_name: &str,
spec: &OpenAPI,
cached_spec: &CachedSpec,
global_config: Option<&GlobalConfig>,
) -> Result<String, Error> {
let base_url = spec.servers.first().map(|s| s.url.clone());
let servers: Vec<String> = spec.servers.iter().map(|s| s.url.clone()).collect();
let temp_cached_spec = CachedSpec {
cache_format_version: crate::cache::models::CACHE_FORMAT_VERSION,
name: api_name.to_string(),
version: spec.info.version.clone(),
commands: vec![], base_url,
servers,
security_schemes: HashMap::new(), skipped_endpoints: vec![], server_variables: HashMap::new(), };
let resolver = BaseUrlResolver::new(&temp_cached_spec);
let resolver = if let Some(config) = global_config {
resolver.with_global_config(config)
} else {
resolver
};
let resolved_base_url = resolver.resolve(None);
let mut command_groups: HashMap<String, Vec<CommandInfo>> = HashMap::new();
let skipped_set: std::collections::HashSet<(&str, &str)> = cached_spec
.skipped_endpoints
.iter()
.map(|ep| (ep.path.as_str(), ep.method.as_str()))
.collect();
for (path, path_item) in &spec.paths.paths {
let ReferenceOr::Item(item) = path_item else {
continue;
};
for (method, operation) in crate::spec::http_methods_iter(item) {
let Some(op) = operation else {
continue;
};
if skipped_set.contains(&(path.as_str(), method.to_uppercase().as_str())) {
continue;
}
let command_info =
convert_openapi_operation_to_info(method, path, op, spec, spec.security.as_ref());
let group_name = op.tags.first().map_or_else(
|| constants::DEFAULT_GROUP.to_string(),
|tag| to_kebab_case(tag),
);
command_groups
.entry(group_name)
.or_default()
.push(command_info);
}
}
let mapping_index: HashMap<&str, &CachedCommand> = cached_spec
.commands
.iter()
.map(|c| (c.operation_id.as_str(), c))
.collect();
let mut regrouped: HashMap<String, Vec<CommandInfo>> = HashMap::new();
for (_group, commands) in command_groups {
for mut cmd_info in commands {
if let Some(cached_cmd) = mapping_index.get(cmd_info.operation_id.as_str()) {
cmd_info.display_group.clone_from(&cached_cmd.display_group);
cmd_info.display_name.clone_from(&cached_cmd.display_name);
cmd_info.aliases.clone_from(&cached_cmd.aliases);
cmd_info.hidden = cached_cmd.hidden;
cmd_info.pagination = PaginationManifestInfo::from_cached(&cached_cmd.pagination);
}
let effective_group = cmd_info.display_group.as_ref().map_or_else(
|| {
cmd_info.original_tags.first().map_or_else(
|| constants::DEFAULT_GROUP.to_string(),
|tag| to_kebab_case(tag),
)
},
|g| to_kebab_case(g),
);
regrouped.entry(effective_group).or_default().push(cmd_info);
}
}
let security_schemes = extract_security_schemes_from_openapi(spec);
let skipped = cached_spec.skipped_endpoints.len();
let available = cached_spec.commands.len();
let total = available + skipped;
let manifest = ApiCapabilityManifest {
api: ApiInfo {
name: spec.info.title.clone(),
version: spec.info.version.clone(),
description: spec.info.description.clone(),
base_url: resolved_base_url,
},
endpoints: EndpointStatistics {
total,
available,
skipped,
},
commands: regrouped,
security_schemes,
batch: build_batch_capability_info(),
};
serde_json::to_string_pretty(&manifest)
.map_err(|e| Error::serialization_error(format!("Failed to serialize agent manifest: {e}")))
}
pub fn generate_capability_manifest(
spec: &CachedSpec,
global_config: Option<&GlobalConfig>,
) -> Result<String, Error> {
let mut command_groups: HashMap<String, Vec<CommandInfo>> = HashMap::new();
for cached_command in &spec.commands {
let group_name = if cached_command.name.is_empty() {
constants::DEFAULT_GROUP.to_string()
} else {
to_kebab_case(&cached_command.name)
};
let command_info = convert_cached_command_to_info(cached_command);
command_groups
.entry(group_name)
.or_default()
.push(command_info);
}
let resolver = BaseUrlResolver::new(spec);
let resolver = if let Some(config) = global_config {
resolver.with_global_config(config)
} else {
resolver
};
let base_url = resolver.resolve(None);
let skipped = spec.skipped_endpoints.len();
let available = spec.commands.len();
let total = available + skipped;
let manifest = ApiCapabilityManifest {
api: ApiInfo {
name: spec.name.clone(),
version: spec.version.clone(),
description: None, base_url,
},
endpoints: EndpointStatistics {
total,
available,
skipped,
},
commands: command_groups,
security_schemes: extract_security_schemes(spec),
batch: build_batch_capability_info(),
};
serde_json::to_string_pretty(&manifest)
.map_err(|e| Error::serialization_error(format!("Failed to serialize agent manifest: {e}")))
}
fn convert_cached_command_to_info(cached_command: &CachedCommand) -> CommandInfo {
let command_name = if cached_command.operation_id.is_empty() {
cached_command.method.to_lowercase()
} else {
to_kebab_case(&cached_command.operation_id)
};
let parameters: Vec<ParameterInfo> = cached_command
.parameters
.iter()
.map(convert_cached_parameter_to_info)
.collect();
let request_body = cached_command
.request_body
.as_ref()
.map(convert_cached_request_body_to_info);
let response_schema = extract_response_schema_from_cached(&cached_command.responses);
CommandInfo {
name: command_name,
method: cached_command.method.clone(),
path: cached_command.path.clone(),
description: cached_command.description.clone(),
summary: cached_command.summary.clone(),
operation_id: cached_command.operation_id.clone(),
parameters,
request_body,
security_requirements: cached_command.security_requirements.clone(),
tags: cached_command
.tags
.iter()
.map(|t| to_kebab_case(t))
.collect(),
original_tags: cached_command.tags.clone(),
deprecated: cached_command.deprecated,
external_docs_url: cached_command.external_docs_url.clone(),
response_schema,
display_group: cached_command.display_group.clone(),
display_name: cached_command.display_name.clone(),
aliases: cached_command.aliases.clone(),
hidden: cached_command.hidden,
pagination: PaginationManifestInfo::from_cached(&cached_command.pagination),
}
}
fn convert_cached_parameter_to_info(cached_param: &CachedParameter) -> ParameterInfo {
ParameterInfo {
name: cached_param.name.clone(),
location: cached_param.location.clone(),
required: cached_param.required,
param_type: cached_param
.schema_type
.clone()
.unwrap_or_else(|| constants::SCHEMA_TYPE_STRING.to_string()),
description: cached_param.description.clone(),
format: cached_param.format.clone(),
default_value: cached_param.default_value.clone(),
enum_values: cached_param.enum_values.clone(),
example: cached_param.example.clone(),
}
}
fn convert_cached_request_body_to_info(cached_body: &CachedRequestBody) -> RequestBodyInfo {
RequestBodyInfo {
required: cached_body.required,
content_type: cached_body.content_type.clone(),
description: cached_body.description.clone(),
example: cached_body.example.clone(),
}
}
fn extract_response_schema_from_cached(
responses: &[crate::cache::models::CachedResponse],
) -> Option<ResponseSchemaInfo> {
constants::SUCCESS_STATUS_CODES.iter().find_map(|code| {
responses
.iter()
.find(|r| r.status_code == *code)
.and_then(|response| {
let content_type = response.content_type.as_ref()?;
let schema_str = response.schema.as_ref()?;
let schema = serde_json::from_str(schema_str).ok()?;
let example = response
.example
.as_ref()
.and_then(|ex| serde_json::from_str(ex).ok());
Some(ResponseSchemaInfo {
content_type: content_type.clone(),
schema,
example,
})
})
})
}
fn extract_security_schemes(spec: &CachedSpec) -> HashMap<String, SecuritySchemeInfo> {
let mut security_schemes = HashMap::new();
for (name, scheme) in &spec.security_schemes {
let details = match scheme.scheme_type.as_str() {
constants::SECURITY_TYPE_HTTP => {
scheme.scheme.as_ref().map_or(
SecuritySchemeDetails::HttpBearer {
bearer_format: None,
},
|http_scheme| match http_scheme.as_str() {
constants::AUTH_SCHEME_BEARER => SecuritySchemeDetails::HttpBearer {
bearer_format: scheme.bearer_format.clone(),
},
constants::AUTH_SCHEME_BASIC => SecuritySchemeDetails::HttpBasic,
_ => {
SecuritySchemeDetails::HttpBearer {
bearer_format: None,
}
}
},
)
}
constants::AUTH_SCHEME_APIKEY => SecuritySchemeDetails::ApiKey {
location: scheme
.location
.clone()
.unwrap_or_else(|| constants::LOCATION_HEADER.to_string()),
name: scheme
.parameter_name
.clone()
.unwrap_or_else(|| constants::HEADER_AUTHORIZATION.to_string()),
},
_ => {
SecuritySchemeDetails::HttpBearer {
bearer_format: None,
}
}
};
let scheme_info = SecuritySchemeInfo {
scheme_type: scheme.scheme_type.clone(),
description: scheme.description.clone(),
details,
aperture_secret: scheme.aperture_secret.clone(),
};
security_schemes.insert(name.clone(), scheme_info);
}
security_schemes
}
fn convert_openapi_operation_to_info(
method: &str,
path: &str,
operation: &Operation,
spec: &OpenAPI,
global_security: Option<&Vec<openapiv3::SecurityRequirement>>,
) -> CommandInfo {
let command_name = operation
.operation_id
.as_ref()
.map_or_else(|| method.to_lowercase(), |op_id| to_kebab_case(op_id));
let parameters: Vec<ParameterInfo> = operation
.parameters
.iter()
.filter_map(|param_ref| match param_ref {
ReferenceOr::Item(param) => Some(convert_openapi_parameter_to_info(param)),
ReferenceOr::Reference { reference } => resolve_parameter_reference(spec, reference)
.ok()
.map(|param| convert_openapi_parameter_to_info(¶m)),
})
.collect();
let request_body = operation.request_body.as_ref().and_then(|rb_ref| {
let ReferenceOr::Item(body) = rb_ref else {
return None;
};
let content_type = if body.content.contains_key(constants::CONTENT_TYPE_JSON) {
constants::CONTENT_TYPE_JSON
} else {
body.content.keys().next().map(String::as_str)?
};
let media_type = body.content.get(content_type)?;
let example = media_type
.example
.as_ref()
.map(|ex| serde_json::to_string(ex).unwrap_or_else(|_| ex.to_string()));
Some(RequestBodyInfo {
required: body.required,
content_type: content_type.to_string(),
description: body.description.clone(),
example,
})
});
let security_requirements = operation.security.as_ref().map_or_else(
|| {
global_security.map_or(vec![], |reqs| {
reqs.iter().flat_map(|req| req.keys().cloned()).collect()
})
},
|op_security| {
op_security
.iter()
.flat_map(|req| req.keys().cloned())
.collect()
},
);
let response_schema = extract_response_schema_from_operation(operation, spec);
CommandInfo {
name: command_name,
method: method.to_uppercase(),
path: path.to_string(),
description: operation.description.clone(),
summary: operation.summary.clone(),
operation_id: operation.operation_id.clone().unwrap_or_default(),
parameters,
request_body,
security_requirements,
tags: operation.tags.iter().map(|t| to_kebab_case(t)).collect(),
original_tags: operation.tags.clone(),
deprecated: operation.deprecated,
external_docs_url: operation
.external_docs
.as_ref()
.map(|docs| docs.url.clone()),
response_schema,
display_group: None,
display_name: None,
aliases: vec![],
hidden: false,
pagination: PaginationManifestInfo::default(),
}
}
fn extract_response_schema_from_operation(
operation: &Operation,
spec: &OpenAPI,
) -> Option<ResponseSchemaInfo> {
constants::SUCCESS_STATUS_CODES.iter().find_map(|code| {
operation
.responses
.responses
.get(&openapiv3::StatusCode::Code(
code.parse().expect("valid status code"),
))
.and_then(|response_ref| extract_response_schema_from_response(response_ref, spec))
})
}
fn extract_response_schema_from_response(
response_ref: &ReferenceOr<openapiv3::Response>,
spec: &OpenAPI,
) -> Option<ResponseSchemaInfo> {
let ReferenceOr::Item(response) = response_ref else {
return None;
};
let content_type = if response.content.contains_key(constants::CONTENT_TYPE_JSON) {
constants::CONTENT_TYPE_JSON
} else {
response.content.keys().next().map(String::as_str)?
};
let media_type = response.content.get(content_type)?;
let schema_ref = media_type.schema.as_ref()?;
let schema_value = match schema_ref {
ReferenceOr::Item(schema) => serde_json::to_value(schema).ok()?,
ReferenceOr::Reference { reference } => {
let resolved = resolve_schema_reference(spec, reference).ok()?;
serde_json::to_value(&resolved).ok()?
}
};
let example = media_type
.example
.as_ref()
.and_then(|ex| serde_json::to_value(ex).ok());
Some(ResponseSchemaInfo {
content_type: content_type.to_string(),
schema: schema_value,
example,
})
}
fn extract_schema_info_from_parameter(
format: &openapiv3::ParameterSchemaOrContent,
) -> ParameterSchemaInfo {
let openapiv3::ParameterSchemaOrContent::Schema(schema_ref) = format else {
return (
Some(constants::SCHEMA_TYPE_STRING.to_string()),
None,
None,
vec![],
None,
);
};
match schema_ref {
ReferenceOr::Item(schema) => {
let (schema_type, format, enums) =
extract_schema_type_from_schema_kind(&schema.schema_kind);
let default_value = schema
.schema_data
.default
.as_ref()
.map(|v| serde_json::to_string(v).unwrap_or_else(|_| v.to_string()));
(Some(schema_type), format, default_value, enums, None)
}
ReferenceOr::Reference { .. } => (
Some(constants::SCHEMA_TYPE_STRING.to_string()),
None,
None,
vec![],
None,
),
}
}
fn extract_schema_type_from_schema_kind(
schema_kind: &openapiv3::SchemaKind,
) -> (String, Option<String>, Vec<String>) {
match schema_kind {
openapiv3::SchemaKind::Type(type_val) => match type_val {
openapiv3::Type::String(string_type) => {
let enum_values: Vec<String> = string_type
.enumeration
.iter()
.filter_map(|v| v.as_ref())
.map(|v| serde_json::to_string(v).unwrap_or_else(|_| v.clone()))
.collect();
(constants::SCHEMA_TYPE_STRING.to_string(), None, enum_values)
}
openapiv3::Type::Number(_) => (constants::SCHEMA_TYPE_NUMBER.to_string(), None, vec![]),
openapiv3::Type::Integer(_) => {
(constants::SCHEMA_TYPE_INTEGER.to_string(), None, vec![])
}
openapiv3::Type::Boolean(_) => {
(constants::SCHEMA_TYPE_BOOLEAN.to_string(), None, vec![])
}
openapiv3::Type::Array(_) => (constants::SCHEMA_TYPE_ARRAY.to_string(), None, vec![]),
openapiv3::Type::Object(_) => (constants::SCHEMA_TYPE_OBJECT.to_string(), None, vec![]),
},
_ => (constants::SCHEMA_TYPE_STRING.to_string(), None, vec![]),
}
}
fn convert_openapi_parameter_to_info(param: &OpenApiParameter) -> ParameterInfo {
let (param_data, location_str) = match param {
OpenApiParameter::Query { parameter_data, .. } => {
(parameter_data, constants::PARAM_LOCATION_QUERY)
}
OpenApiParameter::Header { parameter_data, .. } => {
(parameter_data, constants::PARAM_LOCATION_HEADER)
}
OpenApiParameter::Path { parameter_data, .. } => {
(parameter_data, constants::PARAM_LOCATION_PATH)
}
OpenApiParameter::Cookie { parameter_data, .. } => {
(parameter_data, constants::PARAM_LOCATION_COOKIE)
}
};
let (schema_type, format, default_value, enum_values, example) =
extract_schema_info_from_parameter(¶m_data.format);
let example = param_data
.example
.as_ref()
.map(|ex| serde_json::to_string(ex).unwrap_or_else(|_| ex.to_string()))
.or(example);
ParameterInfo {
name: param_data.name.clone(),
location: location_str.to_string(),
required: param_data.required,
param_type: schema_type.unwrap_or_else(|| constants::SCHEMA_TYPE_STRING.to_string()),
description: param_data.description.clone(),
format,
default_value,
enum_values,
example,
}
}
fn extract_security_schemes_from_openapi(spec: &OpenAPI) -> HashMap<String, SecuritySchemeInfo> {
let mut security_schemes = HashMap::new();
let Some(components) = &spec.components else {
return security_schemes;
};
for (name, scheme_ref) in &components.security_schemes {
let ReferenceOr::Item(scheme) = scheme_ref else {
continue;
};
let Some(scheme_info) = convert_openapi_security_scheme(name, scheme) else {
continue;
};
security_schemes.insert(name.clone(), scheme_info);
}
security_schemes
}
fn convert_openapi_security_scheme(
_name: &str,
scheme: &SecurityScheme,
) -> Option<SecuritySchemeInfo> {
match scheme {
SecurityScheme::APIKey {
location,
name: param_name,
description,
..
} => {
let location_str = match location {
openapiv3::APIKeyLocation::Query => constants::PARAM_LOCATION_QUERY,
openapiv3::APIKeyLocation::Header => constants::PARAM_LOCATION_HEADER,
openapiv3::APIKeyLocation::Cookie => constants::PARAM_LOCATION_COOKIE,
};
let aperture_secret = extract_aperture_secret_from_extensions(scheme);
Some(SecuritySchemeInfo {
scheme_type: constants::AUTH_SCHEME_APIKEY.to_string(),
description: description.clone(),
details: SecuritySchemeDetails::ApiKey {
location: location_str.to_string(),
name: param_name.clone(),
},
aperture_secret,
})
}
SecurityScheme::HTTP {
scheme: http_scheme,
bearer_format,
description,
..
} => {
let details = match http_scheme.as_str() {
constants::AUTH_SCHEME_BEARER => SecuritySchemeDetails::HttpBearer {
bearer_format: bearer_format.clone(),
},
constants::AUTH_SCHEME_BASIC => SecuritySchemeDetails::HttpBasic,
_ => SecuritySchemeDetails::HttpBearer {
bearer_format: None,
},
};
let aperture_secret = extract_aperture_secret_from_extensions(scheme);
Some(SecuritySchemeInfo {
scheme_type: constants::SECURITY_TYPE_HTTP.to_string(),
description: description.clone(),
details,
aperture_secret,
})
}
SecurityScheme::OAuth2 { .. } | SecurityScheme::OpenIDConnect { .. } => None,
}
}
fn extract_aperture_secret_from_extensions(
scheme: &SecurityScheme,
) -> Option<CachedApertureSecret> {
let extensions = match scheme {
SecurityScheme::APIKey { extensions, .. } | SecurityScheme::HTTP { extensions, .. } => {
extensions
}
SecurityScheme::OAuth2 { .. } | SecurityScheme::OpenIDConnect { .. } => return None,
};
extensions
.get(constants::EXT_APERTURE_SECRET)
.and_then(|value| {
let obj = value.as_object()?;
let source = obj.get(constants::EXT_KEY_SOURCE)?.as_str()?;
let name = obj.get(constants::EXT_KEY_NAME)?.as_str()?;
if source != constants::SOURCE_ENV {
return None;
}
Some(CachedApertureSecret {
source: source.to_string(),
name: name.to_string(),
})
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cache::models::{
CachedApertureSecret, CachedCommand, CachedParameter, CachedSecurityScheme, CachedSpec,
PaginationInfo,
};
#[test]
fn test_command_name_conversion() {
assert_eq!(to_kebab_case("getUserById"), "get-user-by-id");
assert_eq!(to_kebab_case("createUser"), "create-user");
assert_eq!(to_kebab_case("list"), "list");
assert_eq!(to_kebab_case("GET"), "get");
assert_eq!(
to_kebab_case("List an Organization's Issues"),
"list-an-organizations-issues"
);
}
#[test]
#[allow(clippy::too_many_lines)]
fn test_generate_capability_manifest() {
let mut security_schemes = HashMap::new();
security_schemes.insert(
"bearerAuth".to_string(),
CachedSecurityScheme {
name: "bearerAuth".to_string(),
scheme_type: constants::SECURITY_TYPE_HTTP.to_string(),
scheme: Some(constants::AUTH_SCHEME_BEARER.to_string()),
location: Some(constants::LOCATION_HEADER.to_string()),
parameter_name: Some(constants::HEADER_AUTHORIZATION.to_string()),
description: None,
bearer_format: None,
aperture_secret: Some(CachedApertureSecret {
source: constants::SOURCE_ENV.to_string(),
name: "API_TOKEN".to_string(),
}),
},
);
let spec = CachedSpec {
cache_format_version: crate::cache::models::CACHE_FORMAT_VERSION,
name: "Test API".to_string(),
version: "1.0.0".to_string(),
commands: vec![CachedCommand {
name: "users".to_string(),
description: Some("Get user by ID".to_string()),
summary: None,
operation_id: "getUserById".to_string(),
method: constants::HTTP_METHOD_GET.to_string(),
path: "/users/{id}".to_string(),
parameters: vec![CachedParameter {
name: "id".to_string(),
location: constants::PARAM_LOCATION_PATH.to_string(),
required: true,
description: None,
schema: Some(constants::SCHEMA_TYPE_STRING.to_string()),
schema_type: Some(constants::SCHEMA_TYPE_STRING.to_string()),
format: None,
default_value: None,
enum_values: vec![],
example: None,
}],
request_body: None,
responses: vec![],
security_requirements: vec!["bearerAuth".to_string()],
tags: vec!["users".to_string()],
deprecated: false,
external_docs_url: None,
examples: vec![],
display_group: None,
display_name: None,
aliases: vec![],
hidden: false,
pagination: PaginationInfo::default(),
}],
base_url: Some("https://test-api.example.com".to_string()),
servers: vec!["https://test-api.example.com".to_string()],
security_schemes,
skipped_endpoints: vec![],
server_variables: HashMap::new(),
};
let manifest_json = generate_capability_manifest(&spec, None).unwrap();
let manifest: ApiCapabilityManifest = serde_json::from_str(&manifest_json).unwrap();
assert_eq!(manifest.api.name, "Test API");
assert_eq!(manifest.api.version, "1.0.0");
assert!(manifest.commands.contains_key("users"));
let users_commands = &manifest.commands["users"];
assert_eq!(users_commands.len(), 1);
assert_eq!(users_commands[0].name, "get-user-by-id");
assert_eq!(users_commands[0].method, constants::HTTP_METHOD_GET);
assert_eq!(users_commands[0].parameters.len(), 1);
assert_eq!(users_commands[0].parameters[0].name, "id");
assert!(!manifest.security_schemes.is_empty());
assert!(manifest.security_schemes.contains_key("bearerAuth"));
let bearer_auth = &manifest.security_schemes["bearerAuth"];
assert_eq!(bearer_auth.scheme_type, constants::SECURITY_TYPE_HTTP);
assert!(matches!(
&bearer_auth.details,
SecuritySchemeDetails::HttpBearer { .. }
));
assert!(bearer_auth.aperture_secret.is_some());
let aperture_secret = bearer_auth.aperture_secret.as_ref().unwrap();
assert_eq!(aperture_secret.name, "API_TOKEN");
assert_eq!(aperture_secret.source, constants::SOURCE_ENV);
assert_eq!(manifest.batch.file_formats, vec!["json", "yaml"]);
let field_names: Vec<&str> = manifest
.batch
.operation_schema
.fields
.iter()
.map(|f| f.name.as_str())
.collect();
assert!(field_names.contains(&"capture"));
assert!(field_names.contains(&"capture_append"));
assert!(field_names.contains(&"depends_on"));
assert!(field_names.contains(&"args"));
assert_eq!(
manifest.batch.dependent_workflows.interpolation_syntax,
"{{variable_name}}"
);
assert!(
manifest
.batch
.dependent_workflows
.dependent_execution
.implicit_dependencies
);
}
}