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
14type ParameterSchemaInfo = (
17 Option<String>,
18 Option<String>,
19 Option<String>,
20 Vec<String>,
21 Option<String>,
22);
23
24#[derive(Debug, Serialize, Deserialize)]
27pub struct ApiCapabilityManifest {
28 pub api: ApiInfo,
30 pub endpoints: EndpointStatistics,
32 pub commands: HashMap<String, Vec<CommandInfo>>,
34 pub security_schemes: HashMap<String, SecuritySchemeInfo>,
36 pub batch: BatchCapabilityInfo,
38}
39
40#[derive(Debug, Serialize, Deserialize)]
41pub struct ApiInfo {
42 pub name: String,
44 pub version: String,
46 pub description: Option<String>,
48 pub base_url: String,
50}
51
52#[derive(Debug, Serialize, Deserialize)]
54pub struct EndpointStatistics {
55 pub total: usize,
57 pub available: usize,
59 pub skipped: usize,
61}
62
63#[derive(Debug, Serialize, Deserialize)]
68pub struct BatchCapabilityInfo {
69 pub file_formats: Vec<String>,
71 pub operation_schema: BatchOperationSchema,
73 pub dependent_workflows: DependentWorkflowInfo,
75}
76
77#[derive(Debug, Serialize, Deserialize)]
79pub struct BatchOperationSchema {
80 pub fields: Vec<BatchFieldInfo>,
82}
83
84#[derive(Debug, Serialize, Deserialize)]
86pub struct BatchFieldInfo {
87 pub name: String,
89 #[serde(rename = "type")]
91 pub field_type: String,
92 pub required: bool,
94 pub description: String,
96}
97
98#[derive(Debug, Serialize, Deserialize)]
100pub struct DependentWorkflowInfo {
101 pub interpolation_syntax: String,
103 pub execution_modes: ExecutionModeInfo,
105 pub dependent_execution: DependentExecutionInfo,
107}
108
109#[derive(Debug, Serialize, Deserialize)]
111pub struct ExecutionModeInfo {
112 pub concurrent: String,
114 pub dependent: String,
116}
117
118#[derive(Debug, Serialize, Deserialize)]
120pub struct DependentExecutionInfo {
121 pub ordering: String,
123 pub failure_mode: String,
125 pub implicit_dependencies: bool,
127 pub variable_types: VariableTypeInfo,
129}
130
131#[derive(Debug, Serialize, Deserialize)]
133pub struct VariableTypeInfo {
134 pub scalar: String,
136 pub list: String,
138}
139
140fn batch_operation_fields() -> Vec<BatchFieldInfo> {
142 vec![
143 BatchFieldInfo {
144 name: "id".into(),
145 field_type: "string".into(),
146 required: false,
147 description: "Unique identifier. Required when using capture, capture_append, or depends_on.".into(),
148 },
149 BatchFieldInfo {
150 name: "args".into(),
151 field_type: "string[]".into(),
152 required: true,
153 description: "Command arguments (e.g. [\"users\", \"create-user\", \"--body\", \"{...}\"] or [\"users\", \"create-user\", \"--body-file\", \"/path/to/body.json\"]).".into(),
154 },
155 BatchFieldInfo {
156 name: "description".into(),
157 field_type: "string".into(),
158 required: false,
159 description: "Human-readable description of this operation.".into(),
160 },
161 BatchFieldInfo {
162 name: "headers".into(),
163 field_type: "map<string, string>".into(),
164 required: false,
165 description: "Custom HTTP headers for this operation.".into(),
166 },
167 BatchFieldInfo {
168 name: "capture".into(),
169 field_type: "map<string, string>".into(),
170 required: false,
171 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(),
172 },
173 BatchFieldInfo {
174 name: "capture_append".into(),
175 field_type: "map<string, string>".into(),
176 required: false,
177 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(),
178 },
179 BatchFieldInfo {
180 name: "depends_on".into(),
181 field_type: "string[]".into(),
182 required: false,
183 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(),
184 },
185 BatchFieldInfo {
186 name: "use_cache".into(),
187 field_type: "boolean".into(),
188 required: false,
189 description: "Enable response caching for this operation.".into(),
190 },
191 BatchFieldInfo {
192 name: "retry".into(),
193 field_type: "integer".into(),
194 required: false,
195 description: "Maximum retry attempts for this operation.".into(),
196 },
197 BatchFieldInfo {
198 name: "retry_delay".into(),
199 field_type: "string".into(),
200 required: false,
201 description: "Initial retry delay (e.g. \"500ms\", \"1s\").".into(),
202 },
203 BatchFieldInfo {
204 name: "retry_max_delay".into(),
205 field_type: "string".into(),
206 required: false,
207 description: "Maximum retry delay cap (e.g. \"30s\", \"1m\").".into(),
208 },
209 BatchFieldInfo {
210 name: "force_retry".into(),
211 field_type: "boolean".into(),
212 required: false,
213 description: "Allow retrying non-idempotent requests without an idempotency key.".into(),
214 },
215 BatchFieldInfo {
216 name: "body_file".into(),
217 field_type: "string".into(),
218 required: false,
219 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(),
220 },
221 ]
222}
223
224fn build_batch_capability_info() -> BatchCapabilityInfo {
226 BatchCapabilityInfo {
227 file_formats: vec!["json".into(), "yaml".into()],
228 operation_schema: BatchOperationSchema {
229 fields: batch_operation_fields(),
230 },
231 dependent_workflows: DependentWorkflowInfo {
232 interpolation_syntax: "{{variable_name}}".into(),
233 execution_modes: ExecutionModeInfo {
234 concurrent: "Used when no operation has capture, capture_append, or depends_on. Operations run in parallel with concurrency and rate-limit controls.".into(),
235 dependent: "Used when any operation has capture, capture_append, or depends_on. Operations run sequentially in topological order with variable interpolation.".into(),
236 },
237 dependent_execution: DependentExecutionInfo {
238 ordering: "Topological sort via Kahn's algorithm. Operations without dependencies preserve original file order.".into(),
239 failure_mode: "Atomic: halts on first failure. Subsequent operations are marked as skipped.".into(),
240 implicit_dependencies: true,
241 variable_types: VariableTypeInfo {
242 scalar: "From capture — {{name}} interpolates as the extracted string value.".into(),
243 list: "From capture_append — {{name}} interpolates as a JSON array literal (e.g. [\"a\",\"b\"]).".into(),
244 },
245 },
246 },
247 }
248}
249
250#[derive(Debug, Serialize, Deserialize)]
251pub struct CommandInfo {
252 pub name: String,
254 pub method: String,
256 pub path: String,
258 pub description: Option<String>,
260 #[serde(skip_serializing_if = "Option::is_none")]
262 pub summary: Option<String>,
263 pub operation_id: String,
265 pub parameters: Vec<ParameterInfo>,
267 pub request_body: Option<RequestBodyInfo>,
269 #[serde(skip_serializing_if = "Vec::is_empty", default)]
271 pub security_requirements: Vec<String>,
272 #[serde(skip_serializing_if = "Vec::is_empty", default)]
274 pub tags: Vec<String>,
275 #[serde(skip_serializing_if = "Vec::is_empty", default)]
277 pub original_tags: Vec<String>,
278 #[serde(skip_serializing_if = "std::ops::Not::not", default)]
280 pub deprecated: bool,
281 #[serde(skip_serializing_if = "Option::is_none")]
283 pub external_docs_url: Option<String>,
284 #[serde(skip_serializing_if = "Option::is_none")]
286 pub response_schema: Option<ResponseSchemaInfo>,
287 #[serde(skip_serializing_if = "Option::is_none")]
289 pub display_group: Option<String>,
290 #[serde(skip_serializing_if = "Option::is_none")]
292 pub display_name: Option<String>,
293 #[serde(skip_serializing_if = "Vec::is_empty", default)]
295 pub aliases: Vec<String>,
296 #[serde(skip_serializing_if = "std::ops::Not::not", default)]
298 pub hidden: bool,
299 pub pagination: PaginationManifestInfo,
301}
302
303#[derive(Debug, Serialize, Deserialize)]
304pub struct ParameterInfo {
305 pub name: String,
307 pub location: String,
309 pub required: bool,
311 pub param_type: String,
313 pub description: Option<String>,
315 #[serde(skip_serializing_if = "Option::is_none")]
317 pub format: Option<String>,
318 #[serde(skip_serializing_if = "Option::is_none")]
320 pub default_value: Option<String>,
321 #[serde(skip_serializing_if = "Vec::is_empty", default)]
323 pub enum_values: Vec<String>,
324 #[serde(skip_serializing_if = "Option::is_none")]
326 pub example: Option<String>,
327}
328
329#[derive(Debug, Serialize, Deserialize)]
330pub struct RequestBodyInfo {
331 pub required: bool,
333 pub content_type: String,
335 pub description: Option<String>,
337 #[serde(skip_serializing_if = "Option::is_none")]
339 pub example: Option<String>,
340}
341
342#[derive(Debug, Serialize, Deserialize)]
347pub struct PaginationManifestInfo {
348 pub supported: bool,
350 pub strategy: String,
352 #[serde(skip_serializing_if = "Option::is_none")]
354 pub cursor_field: Option<String>,
355 #[serde(skip_serializing_if = "Option::is_none")]
357 pub cursor_param: Option<String>,
358 #[serde(skip_serializing_if = "Option::is_none")]
360 pub page_param: Option<String>,
361 #[serde(skip_serializing_if = "Option::is_none")]
363 pub limit_param: Option<String>,
364}
365
366impl Default for PaginationManifestInfo {
367 fn default() -> Self {
368 Self {
369 supported: false,
370 strategy: crate::constants::PAGINATION_STRATEGY_NONE.to_string(),
371 cursor_field: None,
372 cursor_param: None,
373 page_param: None,
374 limit_param: None,
375 }
376 }
377}
378
379impl PaginationManifestInfo {
380 fn from_cached(info: &crate::cache::models::PaginationInfo) -> Self {
382 use crate::cache::models::PaginationStrategy;
383 use crate::constants;
384
385 let (supported, strategy) = match info.strategy {
386 PaginationStrategy::None => (false, constants::PAGINATION_STRATEGY_NONE),
387 PaginationStrategy::Cursor => (true, constants::PAGINATION_STRATEGY_CURSOR),
388 PaginationStrategy::Offset => (true, constants::PAGINATION_STRATEGY_OFFSET),
389 PaginationStrategy::LinkHeader => (true, constants::PAGINATION_STRATEGY_LINK_HEADER),
390 };
391
392 Self {
393 supported,
394 strategy: strategy.to_string(),
395 cursor_field: info.cursor_field.clone(),
396 cursor_param: info.cursor_param.clone(),
397 page_param: info.page_param.clone(),
398 limit_param: info.limit_param.clone(),
399 }
400 }
401}
402
403#[derive(Debug, Clone, Serialize, Deserialize)]
426pub struct ResponseSchemaInfo {
427 pub content_type: String,
429 pub schema: serde_json::Value,
434 #[serde(skip_serializing_if = "Option::is_none")]
436 pub example: Option<serde_json::Value>,
437}
438
439#[derive(Debug, Serialize, Deserialize)]
441pub struct SecuritySchemeInfo {
442 #[serde(rename = "type")]
444 pub scheme_type: String,
445 pub description: Option<String>,
447 #[serde(flatten)]
449 pub details: SecuritySchemeDetails,
450 #[serde(rename = "x-aperture-secret", skip_serializing_if = "Option::is_none")]
452 pub aperture_secret: Option<CachedApertureSecret>,
453}
454
455#[derive(Debug, Serialize, Deserialize)]
457#[serde(tag = "scheme", rename_all = "camelCase")]
458pub enum SecuritySchemeDetails {
459 #[serde(rename = "bearer")]
461 HttpBearer {
462 #[serde(skip_serializing_if = "Option::is_none")]
464 bearer_format: Option<String>,
465 },
466 #[serde(rename = "basic")]
468 HttpBasic,
469 #[serde(rename = "apiKey")]
471 ApiKey {
472 #[serde(rename = "in")]
474 location: String,
475 name: String,
477 },
478}
479
480pub fn generate_capability_manifest_from_openapi(
498 api_name: &str,
499 spec: &OpenAPI,
500 cached_spec: &CachedSpec,
501 global_config: Option<&GlobalConfig>,
502) -> Result<String, Error> {
503 let base_url = spec.servers.first().map(|s| s.url.clone());
505 let servers: Vec<String> = spec.servers.iter().map(|s| s.url.clone()).collect();
506
507 let temp_cached_spec = CachedSpec {
508 cache_format_version: crate::cache::models::CACHE_FORMAT_VERSION,
509 name: api_name.to_string(),
510 version: spec.info.version.clone(),
511 commands: vec![], base_url,
513 servers,
514 security_schemes: HashMap::new(), skipped_endpoints: vec![], server_variables: HashMap::new(), };
518
519 let resolver = BaseUrlResolver::new(&temp_cached_spec);
521 let resolver = if let Some(config) = global_config {
522 resolver.with_global_config(config)
523 } else {
524 resolver
525 };
526 let resolved_base_url = resolver.resolve(None);
527
528 let mut command_groups: HashMap<String, Vec<CommandInfo>> = HashMap::new();
530
531 let skipped_set: std::collections::HashSet<(&str, &str)> = cached_spec
533 .skipped_endpoints
534 .iter()
535 .map(|ep| (ep.path.as_str(), ep.method.as_str()))
536 .collect();
537
538 for (path, path_item) in &spec.paths.paths {
539 let ReferenceOr::Item(item) = path_item else {
540 continue;
541 };
542
543 for (method, operation) in crate::spec::http_methods_iter(item) {
545 let Some(op) = operation else {
546 continue;
547 };
548
549 if skipped_set.contains(&(path.as_str(), method.to_uppercase().as_str())) {
551 continue;
552 }
553
554 let command_info =
555 convert_openapi_operation_to_info(method, path, op, spec, spec.security.as_ref());
556
557 let group_name = op.tags.first().map_or_else(
559 || constants::DEFAULT_GROUP.to_string(),
560 |tag| to_kebab_case(tag),
561 );
562
563 command_groups
564 .entry(group_name)
565 .or_default()
566 .push(command_info);
567 }
568 }
569
570 let mapping_index: HashMap<&str, &CachedCommand> = cached_spec
577 .commands
578 .iter()
579 .map(|c| (c.operation_id.as_str(), c))
580 .collect();
581
582 let mut regrouped: HashMap<String, Vec<CommandInfo>> = HashMap::new();
585 for (_group, commands) in command_groups {
586 for mut cmd_info in commands {
587 if let Some(cached_cmd) = mapping_index.get(cmd_info.operation_id.as_str()) {
588 cmd_info.display_group.clone_from(&cached_cmd.display_group);
589 cmd_info.display_name.clone_from(&cached_cmd.display_name);
590 cmd_info.aliases.clone_from(&cached_cmd.aliases);
591 cmd_info.hidden = cached_cmd.hidden;
592 cmd_info.pagination = PaginationManifestInfo::from_cached(&cached_cmd.pagination);
593 }
594
595 let effective_group = cmd_info.display_group.as_ref().map_or_else(
597 || {
598 cmd_info.original_tags.first().map_or_else(
599 || constants::DEFAULT_GROUP.to_string(),
600 |tag| to_kebab_case(tag),
601 )
602 },
603 |g| to_kebab_case(g),
604 );
605
606 regrouped.entry(effective_group).or_default().push(cmd_info);
607 }
608 }
609
610 let security_schemes = extract_security_schemes_from_openapi(spec);
612
613 let skipped = cached_spec.skipped_endpoints.len();
615 let available = cached_spec.commands.len();
616 let total = available + skipped;
617
618 let manifest = ApiCapabilityManifest {
620 api: ApiInfo {
621 name: spec.info.title.clone(),
622 version: spec.info.version.clone(),
623 description: spec.info.description.clone(),
624 base_url: resolved_base_url,
625 },
626 endpoints: EndpointStatistics {
627 total,
628 available,
629 skipped,
630 },
631 commands: regrouped,
632 security_schemes,
633 batch: build_batch_capability_info(),
634 };
635
636 serde_json::to_string_pretty(&manifest)
638 .map_err(|e| Error::serialization_error(format!("Failed to serialize agent manifest: {e}")))
639}
640
641pub fn generate_capability_manifest(
657 spec: &CachedSpec,
658 global_config: Option<&GlobalConfig>,
659) -> Result<String, Error> {
660 let mut command_groups: HashMap<String, Vec<CommandInfo>> = HashMap::new();
661
662 for cached_command in &spec.commands {
664 let group_name = if cached_command.name.is_empty() {
665 constants::DEFAULT_GROUP.to_string()
666 } else {
667 to_kebab_case(&cached_command.name)
668 };
669
670 let command_info = convert_cached_command_to_info(cached_command);
671 command_groups
672 .entry(group_name)
673 .or_default()
674 .push(command_info);
675 }
676
677 let resolver = BaseUrlResolver::new(spec);
679 let resolver = if let Some(config) = global_config {
680 resolver.with_global_config(config)
681 } else {
682 resolver
683 };
684 let base_url = resolver.resolve(None);
685
686 let skipped = spec.skipped_endpoints.len();
688 let available = spec.commands.len();
689 let total = available + skipped;
690
691 let manifest = ApiCapabilityManifest {
693 api: ApiInfo {
694 name: spec.name.clone(),
695 version: spec.version.clone(),
696 description: None, base_url,
698 },
699 endpoints: EndpointStatistics {
700 total,
701 available,
702 skipped,
703 },
704 commands: command_groups,
705 security_schemes: extract_security_schemes(spec),
706 batch: build_batch_capability_info(),
707 };
708
709 serde_json::to_string_pretty(&manifest)
711 .map_err(|e| Error::serialization_error(format!("Failed to serialize agent manifest: {e}")))
712}
713
714fn convert_cached_command_to_info(cached_command: &CachedCommand) -> CommandInfo {
716 let command_name = if cached_command.operation_id.is_empty() {
717 cached_command.method.to_lowercase()
718 } else {
719 to_kebab_case(&cached_command.operation_id)
720 };
721
722 let parameters: Vec<ParameterInfo> = cached_command
723 .parameters
724 .iter()
725 .map(convert_cached_parameter_to_info)
726 .collect();
727
728 let request_body = cached_command
729 .request_body
730 .as_ref()
731 .map(convert_cached_request_body_to_info);
732
733 let response_schema = extract_response_schema_from_cached(&cached_command.responses);
735
736 CommandInfo {
737 name: command_name,
738 method: cached_command.method.clone(),
739 path: cached_command.path.clone(),
740 description: cached_command.description.clone(),
741 summary: cached_command.summary.clone(),
742 operation_id: cached_command.operation_id.clone(),
743 parameters,
744 request_body,
745 security_requirements: cached_command.security_requirements.clone(),
746 tags: cached_command
747 .tags
748 .iter()
749 .map(|t| to_kebab_case(t))
750 .collect(),
751 original_tags: cached_command.tags.clone(),
752 deprecated: cached_command.deprecated,
753 external_docs_url: cached_command.external_docs_url.clone(),
754 response_schema,
755 display_group: cached_command.display_group.clone(),
756 display_name: cached_command.display_name.clone(),
757 aliases: cached_command.aliases.clone(),
758 hidden: cached_command.hidden,
759 pagination: PaginationManifestInfo::from_cached(&cached_command.pagination),
760 }
761}
762
763fn convert_cached_parameter_to_info(cached_param: &CachedParameter) -> ParameterInfo {
765 ParameterInfo {
766 name: cached_param.name.clone(),
767 location: cached_param.location.clone(),
768 required: cached_param.required,
769 param_type: cached_param
770 .schema_type
771 .clone()
772 .unwrap_or_else(|| constants::SCHEMA_TYPE_STRING.to_string()),
773 description: cached_param.description.clone(),
774 format: cached_param.format.clone(),
775 default_value: cached_param.default_value.clone(),
776 enum_values: cached_param.enum_values.clone(),
777 example: cached_param.example.clone(),
778 }
779}
780
781fn convert_cached_request_body_to_info(cached_body: &CachedRequestBody) -> RequestBodyInfo {
783 RequestBodyInfo {
784 required: cached_body.required,
785 content_type: cached_body.content_type.clone(),
786 description: cached_body.description.clone(),
787 example: cached_body.example.clone(),
788 }
789}
790
791fn extract_response_schema_from_cached(
797 responses: &[crate::cache::models::CachedResponse],
798) -> Option<ResponseSchemaInfo> {
799 constants::SUCCESS_STATUS_CODES.iter().find_map(|code| {
800 responses
801 .iter()
802 .find(|r| r.status_code == *code)
803 .and_then(|response| {
804 let content_type = response.content_type.as_ref()?;
805 let schema_str = response.schema.as_ref()?;
806 let schema = serde_json::from_str(schema_str).ok()?;
807 let example = response
808 .example
809 .as_ref()
810 .and_then(|ex| serde_json::from_str(ex).ok());
811
812 Some(ResponseSchemaInfo {
813 content_type: content_type.clone(),
814 schema,
815 example,
816 })
817 })
818 })
819}
820
821fn extract_security_schemes(spec: &CachedSpec) -> HashMap<String, SecuritySchemeInfo> {
823 let mut security_schemes = HashMap::new();
824
825 for (name, scheme) in &spec.security_schemes {
826 let details = match scheme.scheme_type.as_str() {
827 constants::SECURITY_TYPE_HTTP => {
828 scheme.scheme.as_ref().map_or(
829 SecuritySchemeDetails::HttpBearer {
830 bearer_format: None,
831 },
832 |http_scheme| match http_scheme.as_str() {
833 constants::AUTH_SCHEME_BEARER => SecuritySchemeDetails::HttpBearer {
834 bearer_format: scheme.bearer_format.clone(),
835 },
836 constants::AUTH_SCHEME_BASIC => SecuritySchemeDetails::HttpBasic,
837 _ => {
838 SecuritySchemeDetails::HttpBearer {
840 bearer_format: None,
841 }
842 }
843 },
844 )
845 }
846 constants::AUTH_SCHEME_APIKEY => SecuritySchemeDetails::ApiKey {
847 location: scheme
848 .location
849 .clone()
850 .unwrap_or_else(|| constants::LOCATION_HEADER.to_string()),
851 name: scheme
852 .parameter_name
853 .clone()
854 .unwrap_or_else(|| constants::HEADER_AUTHORIZATION.to_string()),
855 },
856 _ => {
857 SecuritySchemeDetails::HttpBearer {
859 bearer_format: None,
860 }
861 }
862 };
863
864 let scheme_info = SecuritySchemeInfo {
865 scheme_type: scheme.scheme_type.clone(),
866 description: scheme.description.clone(),
867 details,
868 aperture_secret: scheme.aperture_secret.clone(),
869 };
870
871 security_schemes.insert(name.clone(), scheme_info);
872 }
873
874 security_schemes
875}
876
877fn convert_openapi_operation_to_info(
879 method: &str,
880 path: &str,
881 operation: &Operation,
882 spec: &OpenAPI,
883 global_security: Option<&Vec<openapiv3::SecurityRequirement>>,
884) -> CommandInfo {
885 let command_name = operation
886 .operation_id
887 .as_ref()
888 .map_or_else(|| method.to_lowercase(), |op_id| to_kebab_case(op_id));
889
890 let parameters: Vec<ParameterInfo> = operation
892 .parameters
893 .iter()
894 .filter_map(|param_ref| match param_ref {
895 ReferenceOr::Item(param) => Some(convert_openapi_parameter_to_info(param)),
896 ReferenceOr::Reference { reference } => resolve_parameter_reference(spec, reference)
897 .ok()
898 .map(|param| convert_openapi_parameter_to_info(¶m)),
899 })
900 .collect();
901
902 let request_body = operation.request_body.as_ref().and_then(|rb_ref| {
904 let ReferenceOr::Item(body) = rb_ref else {
905 return None;
906 };
907
908 let content_type = if body.content.contains_key(constants::CONTENT_TYPE_JSON) {
910 constants::CONTENT_TYPE_JSON
911 } else {
912 body.content.keys().next().map(String::as_str)?
913 };
914
915 let media_type = body.content.get(content_type)?;
916 let example = media_type
917 .example
918 .as_ref()
919 .map(|ex| serde_json::to_string(ex).unwrap_or_else(|_| ex.to_string()));
920
921 Some(RequestBodyInfo {
922 required: body.required,
923 content_type: content_type.to_string(),
924 description: body.description.clone(),
925 example,
926 })
927 });
928
929 let security_requirements = operation.security.as_ref().map_or_else(
931 || {
932 global_security.map_or(vec![], |reqs| {
933 reqs.iter().flat_map(|req| req.keys().cloned()).collect()
934 })
935 },
936 |op_security| {
937 op_security
938 .iter()
939 .flat_map(|req| req.keys().cloned())
940 .collect()
941 },
942 );
943
944 let response_schema = extract_response_schema_from_operation(operation, spec);
946
947 CommandInfo {
948 name: command_name,
949 method: method.to_uppercase(),
950 path: path.to_string(),
951 description: operation.description.clone(),
952 summary: operation.summary.clone(),
953 operation_id: operation.operation_id.clone().unwrap_or_default(),
954 parameters,
955 request_body,
956 security_requirements,
957 tags: operation.tags.iter().map(|t| to_kebab_case(t)).collect(),
958 original_tags: operation.tags.clone(),
959 deprecated: operation.deprecated,
960 external_docs_url: operation
961 .external_docs
962 .as_ref()
963 .map(|docs| docs.url.clone()),
964 response_schema,
965 display_group: None,
968 display_name: None,
969 aliases: vec![],
970 hidden: false,
971 pagination: PaginationManifestInfo::default(),
972 }
973}
974
975fn extract_response_schema_from_operation(
980 operation: &Operation,
981 spec: &OpenAPI,
982) -> Option<ResponseSchemaInfo> {
983 constants::SUCCESS_STATUS_CODES.iter().find_map(|code| {
984 operation
985 .responses
986 .responses
987 .get(&openapiv3::StatusCode::Code(
988 code.parse().expect("valid status code"),
989 ))
990 .and_then(|response_ref| extract_response_schema_from_response(response_ref, spec))
991 })
992}
993
994fn extract_response_schema_from_response(
1007 response_ref: &ReferenceOr<openapiv3::Response>,
1008 spec: &OpenAPI,
1009) -> Option<ResponseSchemaInfo> {
1010 let ReferenceOr::Item(response) = response_ref else {
1014 return None;
1015 };
1016
1017 let content_type = if response.content.contains_key(constants::CONTENT_TYPE_JSON) {
1019 constants::CONTENT_TYPE_JSON
1020 } else {
1021 response.content.keys().next().map(String::as_str)?
1023 };
1024
1025 let media_type = response.content.get(content_type)?;
1026 let schema_ref = media_type.schema.as_ref()?;
1027
1028 let schema_value = match schema_ref {
1030 ReferenceOr::Item(schema) => serde_json::to_value(schema).ok()?,
1031 ReferenceOr::Reference { reference } => {
1032 let resolved = resolve_schema_reference(spec, reference).ok()?;
1033 serde_json::to_value(&resolved).ok()?
1034 }
1035 };
1036
1037 let example = media_type
1039 .example
1040 .as_ref()
1041 .and_then(|ex| serde_json::to_value(ex).ok());
1042
1043 Some(ResponseSchemaInfo {
1044 content_type: content_type.to_string(),
1045 schema: schema_value,
1046 example,
1047 })
1048}
1049
1050fn extract_schema_info_from_parameter(
1052 format: &openapiv3::ParameterSchemaOrContent,
1053) -> ParameterSchemaInfo {
1054 let openapiv3::ParameterSchemaOrContent::Schema(schema_ref) = format else {
1055 return (
1056 Some(constants::SCHEMA_TYPE_STRING.to_string()),
1057 None,
1058 None,
1059 vec![],
1060 None,
1061 );
1062 };
1063
1064 match schema_ref {
1065 ReferenceOr::Item(schema) => {
1066 let (schema_type, format, enums) =
1067 extract_schema_type_from_schema_kind(&schema.schema_kind);
1068
1069 let default_value = schema
1070 .schema_data
1071 .default
1072 .as_ref()
1073 .map(|v| serde_json::to_string(v).unwrap_or_else(|_| v.to_string()));
1074
1075 (Some(schema_type), format, default_value, enums, None)
1076 }
1077 ReferenceOr::Reference { .. } => (
1078 Some(constants::SCHEMA_TYPE_STRING.to_string()),
1079 None,
1080 None,
1081 vec![],
1082 None,
1083 ),
1084 }
1085}
1086
1087fn extract_schema_type_from_schema_kind(
1089 schema_kind: &openapiv3::SchemaKind,
1090) -> (String, Option<String>, Vec<String>) {
1091 match schema_kind {
1092 openapiv3::SchemaKind::Type(type_val) => match type_val {
1093 openapiv3::Type::String(string_type) => {
1094 let enum_values: Vec<String> = string_type
1095 .enumeration
1096 .iter()
1097 .filter_map(|v| v.as_ref())
1098 .map(|v| serde_json::to_string(v).unwrap_or_else(|_| v.clone()))
1099 .collect();
1100 (constants::SCHEMA_TYPE_STRING.to_string(), None, enum_values)
1101 }
1102 openapiv3::Type::Number(_) => (constants::SCHEMA_TYPE_NUMBER.to_string(), None, vec![]),
1103 openapiv3::Type::Integer(_) => {
1104 (constants::SCHEMA_TYPE_INTEGER.to_string(), None, vec![])
1105 }
1106 openapiv3::Type::Boolean(_) => {
1107 (constants::SCHEMA_TYPE_BOOLEAN.to_string(), None, vec![])
1108 }
1109 openapiv3::Type::Array(_) => (constants::SCHEMA_TYPE_ARRAY.to_string(), None, vec![]),
1110 openapiv3::Type::Object(_) => (constants::SCHEMA_TYPE_OBJECT.to_string(), None, vec![]),
1111 },
1112 _ => (constants::SCHEMA_TYPE_STRING.to_string(), None, vec![]),
1113 }
1114}
1115
1116fn convert_openapi_parameter_to_info(param: &OpenApiParameter) -> ParameterInfo {
1118 let (param_data, location_str) = match param {
1119 OpenApiParameter::Query { parameter_data, .. } => {
1120 (parameter_data, constants::PARAM_LOCATION_QUERY)
1121 }
1122 OpenApiParameter::Header { parameter_data, .. } => {
1123 (parameter_data, constants::PARAM_LOCATION_HEADER)
1124 }
1125 OpenApiParameter::Path { parameter_data, .. } => {
1126 (parameter_data, constants::PARAM_LOCATION_PATH)
1127 }
1128 OpenApiParameter::Cookie { parameter_data, .. } => {
1129 (parameter_data, constants::PARAM_LOCATION_COOKIE)
1130 }
1131 };
1132
1133 let (schema_type, format, default_value, enum_values, example) =
1135 extract_schema_info_from_parameter(¶m_data.format);
1136
1137 let example = param_data
1139 .example
1140 .as_ref()
1141 .map(|ex| serde_json::to_string(ex).unwrap_or_else(|_| ex.to_string()))
1142 .or(example);
1143
1144 ParameterInfo {
1145 name: param_data.name.clone(),
1146 location: location_str.to_string(),
1147 required: param_data.required,
1148 param_type: schema_type.unwrap_or_else(|| constants::SCHEMA_TYPE_STRING.to_string()),
1149 description: param_data.description.clone(),
1150 format,
1151 default_value,
1152 enum_values,
1153 example,
1154 }
1155}
1156
1157fn extract_security_schemes_from_openapi(spec: &OpenAPI) -> HashMap<String, SecuritySchemeInfo> {
1159 let mut security_schemes = HashMap::new();
1160
1161 let Some(components) = &spec.components else {
1162 return security_schemes;
1163 };
1164
1165 for (name, scheme_ref) in &components.security_schemes {
1166 let ReferenceOr::Item(scheme) = scheme_ref else {
1167 continue;
1168 };
1169
1170 let Some(scheme_info) = convert_openapi_security_scheme(name, scheme) else {
1171 continue;
1172 };
1173
1174 security_schemes.insert(name.clone(), scheme_info);
1175 }
1176
1177 security_schemes
1178}
1179
1180fn convert_openapi_security_scheme(
1182 _name: &str,
1183 scheme: &SecurityScheme,
1184) -> Option<SecuritySchemeInfo> {
1185 match scheme {
1186 SecurityScheme::APIKey {
1187 location,
1188 name: param_name,
1189 description,
1190 ..
1191 } => {
1192 let location_str = match location {
1193 openapiv3::APIKeyLocation::Query => constants::PARAM_LOCATION_QUERY,
1194 openapiv3::APIKeyLocation::Header => constants::PARAM_LOCATION_HEADER,
1195 openapiv3::APIKeyLocation::Cookie => constants::PARAM_LOCATION_COOKIE,
1196 };
1197
1198 let aperture_secret = extract_aperture_secret_from_extensions(scheme);
1199
1200 Some(SecuritySchemeInfo {
1201 scheme_type: constants::AUTH_SCHEME_APIKEY.to_string(),
1202 description: description.clone(),
1203 details: SecuritySchemeDetails::ApiKey {
1204 location: location_str.to_string(),
1205 name: param_name.clone(),
1206 },
1207 aperture_secret,
1208 })
1209 }
1210 SecurityScheme::HTTP {
1211 scheme: http_scheme,
1212 bearer_format,
1213 description,
1214 ..
1215 } => {
1216 let details = match http_scheme.as_str() {
1217 constants::AUTH_SCHEME_BEARER => SecuritySchemeDetails::HttpBearer {
1218 bearer_format: bearer_format.clone(),
1219 },
1220 constants::AUTH_SCHEME_BASIC => SecuritySchemeDetails::HttpBasic,
1221 _ => SecuritySchemeDetails::HttpBearer {
1222 bearer_format: None,
1223 },
1224 };
1225
1226 let aperture_secret = extract_aperture_secret_from_extensions(scheme);
1227
1228 Some(SecuritySchemeInfo {
1229 scheme_type: constants::SECURITY_TYPE_HTTP.to_string(),
1230 description: description.clone(),
1231 details,
1232 aperture_secret,
1233 })
1234 }
1235 SecurityScheme::OAuth2 { .. } | SecurityScheme::OpenIDConnect { .. } => None,
1236 }
1237}
1238
1239fn extract_aperture_secret_from_extensions(
1241 scheme: &SecurityScheme,
1242) -> Option<CachedApertureSecret> {
1243 let extensions = match scheme {
1244 SecurityScheme::APIKey { extensions, .. } | SecurityScheme::HTTP { extensions, .. } => {
1245 extensions
1246 }
1247 SecurityScheme::OAuth2 { .. } | SecurityScheme::OpenIDConnect { .. } => return None,
1248 };
1249
1250 extensions
1251 .get(constants::EXT_APERTURE_SECRET)
1252 .and_then(|value| {
1253 let obj = value.as_object()?;
1254 let source = obj.get(constants::EXT_KEY_SOURCE)?.as_str()?;
1255 let name = obj.get(constants::EXT_KEY_NAME)?.as_str()?;
1256
1257 if source != constants::SOURCE_ENV {
1258 return None;
1259 }
1260
1261 Some(CachedApertureSecret {
1262 source: source.to_string(),
1263 name: name.to_string(),
1264 })
1265 })
1266}
1267
1268#[cfg(test)]
1269mod tests {
1270 use super::*;
1271 use crate::cache::models::{
1272 CachedApertureSecret, CachedCommand, CachedParameter, CachedSecurityScheme, CachedSpec,
1273 PaginationInfo,
1274 };
1275
1276 #[test]
1277 fn test_command_name_conversion() {
1278 assert_eq!(to_kebab_case("getUserById"), "get-user-by-id");
1280 assert_eq!(to_kebab_case("createUser"), "create-user");
1281 assert_eq!(to_kebab_case("list"), "list");
1282 assert_eq!(to_kebab_case("GET"), "get");
1283 assert_eq!(
1284 to_kebab_case("List an Organization's Issues"),
1285 "list-an-organizations-issues"
1286 );
1287 }
1288
1289 #[test]
1290 #[allow(clippy::too_many_lines)]
1291 fn test_generate_capability_manifest() {
1292 let mut security_schemes = HashMap::new();
1293 security_schemes.insert(
1294 "bearerAuth".to_string(),
1295 CachedSecurityScheme {
1296 name: "bearerAuth".to_string(),
1297 scheme_type: constants::SECURITY_TYPE_HTTP.to_string(),
1298 scheme: Some(constants::AUTH_SCHEME_BEARER.to_string()),
1299 location: Some(constants::LOCATION_HEADER.to_string()),
1300 parameter_name: Some(constants::HEADER_AUTHORIZATION.to_string()),
1301 description: None,
1302 bearer_format: None,
1303 aperture_secret: Some(CachedApertureSecret {
1304 source: constants::SOURCE_ENV.to_string(),
1305 name: "API_TOKEN".to_string(),
1306 }),
1307 },
1308 );
1309
1310 let spec = CachedSpec {
1311 cache_format_version: crate::cache::models::CACHE_FORMAT_VERSION,
1312 name: "Test API".to_string(),
1313 version: "1.0.0".to_string(),
1314 commands: vec![CachedCommand {
1315 name: "users".to_string(),
1316 description: Some("Get user by ID".to_string()),
1317 summary: None,
1318 operation_id: "getUserById".to_string(),
1319 method: constants::HTTP_METHOD_GET.to_string(),
1320 path: "/users/{id}".to_string(),
1321 parameters: vec![CachedParameter {
1322 name: "id".to_string(),
1323 location: constants::PARAM_LOCATION_PATH.to_string(),
1324 required: true,
1325 description: None,
1326 schema: Some(constants::SCHEMA_TYPE_STRING.to_string()),
1327 schema_type: Some(constants::SCHEMA_TYPE_STRING.to_string()),
1328 format: None,
1329 default_value: None,
1330 enum_values: vec![],
1331 example: None,
1332 }],
1333 request_body: None,
1334 responses: vec![],
1335 security_requirements: vec!["bearerAuth".to_string()],
1336 tags: vec!["users".to_string()],
1337 deprecated: false,
1338 external_docs_url: None,
1339 examples: vec![],
1340 display_group: None,
1341 display_name: None,
1342 aliases: vec![],
1343 hidden: false,
1344 pagination: PaginationInfo::default(),
1345 }],
1346 base_url: Some("https://test-api.example.com".to_string()),
1347 servers: vec!["https://test-api.example.com".to_string()],
1348 security_schemes,
1349 skipped_endpoints: vec![],
1350 server_variables: HashMap::new(),
1351 };
1352
1353 let manifest_json = generate_capability_manifest(&spec, None).unwrap();
1354 let manifest: ApiCapabilityManifest = serde_json::from_str(&manifest_json).unwrap();
1355
1356 assert_eq!(manifest.api.name, "Test API");
1357 assert_eq!(manifest.api.version, "1.0.0");
1358 assert!(manifest.commands.contains_key("users"));
1359
1360 let users_commands = &manifest.commands["users"];
1361 assert_eq!(users_commands.len(), 1);
1362 assert_eq!(users_commands[0].name, "get-user-by-id");
1363 assert_eq!(users_commands[0].method, constants::HTTP_METHOD_GET);
1364 assert_eq!(users_commands[0].parameters.len(), 1);
1365 assert_eq!(users_commands[0].parameters[0].name, "id");
1366
1367 assert!(!manifest.security_schemes.is_empty());
1369 assert!(manifest.security_schemes.contains_key("bearerAuth"));
1370 let bearer_auth = &manifest.security_schemes["bearerAuth"];
1371 assert_eq!(bearer_auth.scheme_type, constants::SECURITY_TYPE_HTTP);
1372 assert!(matches!(
1373 &bearer_auth.details,
1374 SecuritySchemeDetails::HttpBearer { .. }
1375 ));
1376 assert!(bearer_auth.aperture_secret.is_some());
1377 let aperture_secret = bearer_auth.aperture_secret.as_ref().unwrap();
1378 assert_eq!(aperture_secret.name, "API_TOKEN");
1379 assert_eq!(aperture_secret.source, constants::SOURCE_ENV);
1380
1381 assert_eq!(manifest.batch.file_formats, vec!["json", "yaml"]);
1383 let field_names: Vec<&str> = manifest
1384 .batch
1385 .operation_schema
1386 .fields
1387 .iter()
1388 .map(|f| f.name.as_str())
1389 .collect();
1390 assert!(field_names.contains(&"capture"));
1391 assert!(field_names.contains(&"capture_append"));
1392 assert!(field_names.contains(&"depends_on"));
1393 assert!(field_names.contains(&"args"));
1394 assert_eq!(
1395 manifest.batch.dependent_workflows.interpolation_syntax,
1396 "{{variable_name}}"
1397 );
1398 assert!(
1399 manifest
1400 .batch
1401 .dependent_workflows
1402 .dependent_execution
1403 .implicit_dependencies
1404 );
1405 }
1406}