aperture_cli/cli/commands/
api.rs1use crate::batch::{BatchConfig, BatchProcessor};
4use crate::cache::models::CachedSpec;
5use crate::cli::Cli;
6use crate::config::manager::{get_config_dir, ConfigManager};
7use crate::config::models::GlobalConfig;
8use crate::constants;
9use crate::engine::{executor, generator, loader};
10use crate::error::Error;
11use crate::fs::OsFileSystem;
12use crate::output::Output;
13use crate::shortcuts::{ResolutionResult, ShortcutResolver};
14use std::path::PathBuf;
15
16fn resolve_output_format(
18 matches: &clap::ArgMatches,
19 cli_format: &crate::cli::OutputFormat,
20) -> crate::cli::OutputFormat {
21 use clap::parser::ValueSource;
22
23 let Some(format_str) = matches.get_one::<String>("format") else {
24 return cli_format.clone();
25 };
26
27 if matches.value_source("format") == Some(ValueSource::DefaultValue) {
31 return cli_format.clone();
32 }
33
34 match format_str.as_str() {
35 "json" => crate::cli::OutputFormat::Json,
36 "yaml" => crate::cli::OutputFormat::Yaml,
37 "table" => crate::cli::OutputFormat::Table,
38 _ => cli_format.clone(),
39 }
40}
41
42#[allow(clippy::too_many_lines)]
43pub async fn execute_api_command(context: &str, args: Vec<String>, cli: &Cli) -> Result<(), Error> {
44 let config_dir = if let Ok(dir) = std::env::var(constants::ENV_APERTURE_CONFIG_DIR) {
45 PathBuf::from(dir)
46 } else {
47 get_config_dir()?
48 };
49 let cache_dir = config_dir.join(constants::DIR_CACHE);
50
51 let manager = ConfigManager::with_fs(OsFileSystem, config_dir.clone());
52 let global_config = manager.load_global_config().ok();
53
54 let spec = loader::load_cached_spec(&cache_dir, context).map_err(|e| match e {
55 Error::Io(_) => Error::spec_not_found(context),
56 _ => e,
57 })?;
58
59 if cli.describe_json {
61 let specs_dir = config_dir.join(constants::DIR_SPECS);
62 let spec_path = specs_dir.join(format!("{context}.yaml"));
63 if !spec_path.exists() {
65 return Err(Error::spec_not_found(context));
66 }
67 let spec_content = std::fs::read_to_string(&spec_path)?;
68 let openapi_spec = crate::spec::parse_openapi(&spec_content)
69 .map_err(|e| Error::invalid_config(format!("Failed to parse OpenAPI spec: {e}")))?;
70 let manifest = crate::agent::generate_capability_manifest_from_openapi(
71 context,
72 &openapi_spec,
73 &spec,
74 global_config.as_ref(),
75 )?;
76 let output = match &cli.jq {
77 Some(jq_filter) => executor::apply_jq_filter(&manifest, jq_filter)?,
78 None => manifest,
79 };
80 println!("{output}");
82 return Ok(());
83 }
84
85 if let Some(batch_file_path) = &cli.batch_file {
87 return execute_batch_operations(
88 context,
89 batch_file_path,
90 &spec,
91 global_config.as_ref(),
92 cli,
93 )
94 .await;
95 }
96
97 let command = generator::generate_command_tree_with_flags(&spec, cli.positional_args);
99 let matches = command
100 .try_get_matches_from(std::iter::once(constants::CLI_ROOT_COMMAND.to_string()).chain(args))
101 .map_err(|e| Error::invalid_command(context, e.to_string()))?;
102
103 if crate::cli::translate::has_show_examples_flag(&matches) {
105 let operation_id = crate::cli::translate::matches_to_operation_id(&spec, &matches)?;
106 let operation = spec
107 .commands
108 .iter()
109 .find(|cmd| cmd.operation_id == operation_id)
110 .ok_or_else(|| Error::spec_not_found(context))?;
111 crate::cli::render::render_examples(operation);
112 return Ok(());
113 }
114
115 let jq_filter = matches
116 .get_one::<String>("jq")
117 .map(String::as_str)
118 .or(cli.jq.as_deref());
119 let output_format = resolve_output_format(&matches, &cli.format);
120
121 let call = crate::cli::translate::matches_to_operation_call(&spec, &matches)?;
123 let mut ctx = crate::cli::translate::cli_to_execution_context(cli, global_config)?;
124 ctx.server_var_args = crate::cli::translate::extract_server_var_args(&matches);
125
126 let result = executor::execute(&spec, call, ctx).await.map_err(|e| {
128 let Error::Network(req_err) = &e else {
129 return e;
130 };
131 if req_err.is_connect() {
132 return e.with_context("Failed to connect to API server");
133 }
134 if req_err.is_timeout() {
135 return e.with_context("Request timed out");
136 }
137 e
138 })?;
139
140 crate::cli::render::render_result(&result, &output_format, jq_filter)?;
141 Ok(())
142}
143
144#[allow(clippy::too_many_lines)]
146pub async fn execute_batch_operations(
147 _context: &str,
148 batch_file_path: &str,
149 spec: &CachedSpec,
150 global_config: Option<&GlobalConfig>,
151 cli: &Cli,
152) -> Result<(), Error> {
153 let batch_file =
154 BatchProcessor::parse_batch_file(std::path::Path::new(batch_file_path)).await?;
155 let batch_config = BatchConfig {
156 max_concurrency: cli.batch_concurrency,
157 rate_limit: cli.batch_rate_limit,
158 continue_on_error: true,
159 show_progress: !cli.quiet && !cli.json_errors,
160 suppress_output: cli.json_errors,
161 };
162 let processor = BatchProcessor::new(batch_config);
163 let result = processor
164 .execute_batch(
165 spec,
166 batch_file,
167 global_config,
168 None,
169 cli.dry_run,
170 &cli.format,
171 None,
172 )
173 .await?;
174
175 let output = Output::new(cli.quiet, cli.json_errors);
176
177 if cli.json_errors {
178 let summary = serde_json::json!({
179 "batch_execution_summary": {
180 "total_operations": result.results.len(),
181 "successful_operations": result.success_count,
182 "failed_operations": result.failure_count,
183 "total_duration_seconds": result.total_duration.as_secs_f64(),
184 "operations": result.results.iter().map(|r| serde_json::json!({
185 "operation_id": r.operation.id,
186 "args": r.operation.args,
187 "success": r.success,
188 "duration_seconds": r.duration.as_secs_f64(),
189 "error": r.error
190 })).collect::<Vec<_>>()
191 }
192 });
193 let json_output = match &cli.jq {
194 Some(jq_filter) => {
195 let summary_json = serde_json::to_string(&summary)
196 .expect("JSON serialization of valid structure cannot fail");
197 executor::apply_jq_filter(&summary_json, jq_filter)?
198 }
199 None => serde_json::to_string_pretty(&summary)
200 .expect("JSON serialization of valid structure cannot fail"),
201 };
202 println!("{json_output}");
204 if result.failure_count > 0 {
206 std::process::exit(1);
207 }
208 return Ok(());
209 }
210
211 output.info("\n=== Batch Execution Summary ===");
212 println!("Total operations: {}", result.results.len());
214 println!("Successful: {}", result.success_count);
216 println!("Failed: {}", result.failure_count);
218 println!("Total time: {:.2}s", result.total_duration.as_secs_f64());
220
221 if result.failure_count == 0 {
222 return Ok(());
223 }
224
225 output.info("\nFailed operations:");
226 for (i, op_result) in result.results.iter().enumerate() {
227 if op_result.success {
228 continue;
229 }
230 println!(
232 " {} - {}: {}",
233 i + 1,
234 op_result.operation.args.join(" "),
235 op_result.error.as_deref().unwrap_or("Unknown error")
236 );
237 }
238
239 if result.failure_count > 0 {
240 std::process::exit(1);
241 }
242 Ok(())
243}
244
245pub async fn execute_shortcut_command(
247 manager: &ConfigManager<OsFileSystem>,
248 args: Vec<String>,
249 cli: &Cli,
250) -> Result<(), Error> {
251 let output = Output::new(cli.quiet, cli.json_errors);
252
253 if args.is_empty() {
254 eprintln!("Error: No command specified");
255 eprintln!("Usage: aperture exec <shortcut> [args...]");
256 eprintln!("Examples:");
257 eprintln!(" aperture exec getUserById --id 123");
258 eprintln!(" aperture exec GET /users/123");
259 eprintln!(" aperture exec users list");
260 std::process::exit(1);
261 }
262
263 let specs = manager.list_specs()?;
264 if specs.is_empty() {
265 output.info("No API specifications found. Use 'aperture config add' to register APIs.");
266 return Ok(());
267 }
268
269 let cache_dir = manager.config_dir().join(constants::DIR_CACHE);
270 let mut all_specs = std::collections::BTreeMap::new();
271 for spec_name in &specs {
272 match loader::load_cached_spec(&cache_dir, spec_name) {
273 Ok(spec) => {
274 all_specs.insert(spec_name.clone(), spec);
275 }
276 Err(e) => eprintln!("Warning: Could not load spec '{spec_name}': {e}"),
277 }
278 }
279 if all_specs.is_empty() {
280 output.info("No valid API specifications found.");
281 return Ok(());
282 }
283
284 let mut resolver = ShortcutResolver::new();
285 resolver.index_specs(&all_specs);
286
287 match resolver.resolve_shortcut(&args) {
288 ResolutionResult::Resolved(shortcut) => {
289 output.info(format!(
290 "Resolved shortcut to: aperture {}",
291 shortcut.full_command.join(" ")
292 ));
293 let context = &shortcut.full_command[1];
294 let operation_args = shortcut.full_command[2..].to_vec();
295 let user_args = if args.len() > count_shortcut_args(&args) {
296 args[count_shortcut_args(&args)..].to_vec()
297 } else {
298 Vec::new()
299 };
300 let final_args = [operation_args, user_args].concat();
301 execute_api_command(context, final_args, cli).await
302 }
303 ResolutionResult::Ambiguous(matches) => {
304 eprintln!("Ambiguous shortcut. Multiple commands match:");
305 eprintln!("{}", resolver.format_ambiguous_suggestions(&matches));
306 eprintln!("\nTip: Use 'aperture search <term>' to explore available commands");
307 std::process::exit(1);
308 }
309 ResolutionResult::NotFound => {
310 eprintln!("No command found for shortcut: {}", args.join(" "));
311 eprintln!("Try one of these:");
312 eprintln!(
313 " aperture search '{}' # Search for similar commands",
314 args[0]
315 );
316 eprintln!(" aperture list-commands <api> # List available commands for an API");
317 eprintln!(" aperture api <api> --help # Show help for an API");
318 std::process::exit(1);
319 }
320 }
321}
322
323fn count_shortcut_args(args: &[String]) -> usize {
324 for (i, arg) in args.iter().enumerate() {
325 if arg.starts_with('-') || arg.contains('=') {
326 return i;
327 }
328 }
329 std::cmp::min(args.len(), 3)
330}
331
332#[cfg(test)]
333mod tests {
334 use super::resolve_output_format;
335 use crate::cli::OutputFormat;
336 use clap::{Arg, Command};
337
338 fn matches_from(args: &[&str]) -> clap::ArgMatches {
339 Command::new("api")
340 .arg(
341 Arg::new("format")
342 .long("format")
343 .value_parser(["json", "yaml", "table"])
344 .default_value("json"),
345 )
346 .get_matches_from(args)
347 }
348
349 #[test]
350 fn resolve_output_format_prefers_cli_value_when_dynamic_match_is_default() {
351 let matches = matches_from(&["api"]);
352 let resolved = resolve_output_format(&matches, &OutputFormat::Yaml);
353
354 assert!(matches!(resolved, OutputFormat::Yaml));
355 }
356
357 #[test]
358 fn resolve_output_format_honors_explicit_json_override() {
359 let matches = matches_from(&["api", "--format", "json"]);
360 let resolved = resolve_output_format(&matches, &OutputFormat::Yaml);
361
362 assert!(matches!(resolved, OutputFormat::Json));
363 }
364}