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 enrich_network_error(e: Error) -> Error {
18 let Error::Network(ref req_err) = e else {
19 return e;
20 };
21 if req_err.is_connect() {
22 return e.with_context("Failed to connect to API server");
23 }
24 if req_err.is_timeout() {
25 return e.with_context("Request timed out");
26 }
27 e
28}
29
30fn emit_pagination_error_ndjson(cli: &Cli, writer: &mut impl std::io::Write, error: &Error) {
32 if !cli.json_errors {
33 return;
34 }
35 let Ok(json) = serde_json::to_string(&error.to_json()) else {
36 return;
37 };
38 let _ = writeln!(writer, "{json}");
39}
40
41fn resolve_output_format(
43 matches: &clap::ArgMatches,
44 cli_format: &crate::cli::OutputFormat,
45) -> crate::cli::OutputFormat {
46 use clap::parser::ValueSource;
47
48 let Some(format_str) = matches.get_one::<String>("format") else {
49 return cli_format.clone();
50 };
51
52 if matches.value_source("format") == Some(ValueSource::DefaultValue) {
56 return cli_format.clone();
57 }
58
59 match format_str.as_str() {
60 "json" => crate::cli::OutputFormat::Json,
61 "yaml" => crate::cli::OutputFormat::Yaml,
62 "table" => crate::cli::OutputFormat::Table,
63 _ => cli_format.clone(),
64 }
65}
66
67#[allow(clippy::too_many_lines)]
68pub async fn execute_api_command(context: &str, args: Vec<String>, cli: &Cli) -> Result<(), Error> {
69 let config_dir = if let Ok(dir) = std::env::var(constants::ENV_APERTURE_CONFIG_DIR) {
70 PathBuf::from(dir)
71 } else {
72 get_config_dir()?
73 };
74 let cache_dir = config_dir.join(constants::DIR_CACHE);
75
76 let manager = ConfigManager::with_fs(OsFileSystem, config_dir.clone());
77 let global_config = manager.load_global_config().ok();
78
79 let spec = loader::load_cached_spec(&cache_dir, context).map_err(|e| match e {
80 Error::Io(_) => Error::spec_not_found(context),
81 _ => e,
82 })?;
83
84 if cli.describe_json {
86 let specs_dir = config_dir.join(constants::DIR_SPECS);
87 let spec_path = specs_dir.join(format!("{context}.yaml"));
88 if !spec_path.exists() {
90 return Err(Error::spec_not_found(context));
91 }
92 let spec_content = std::fs::read_to_string(&spec_path)?;
93 let openapi_spec = crate::spec::parse_openapi(&spec_content)
94 .map_err(|e| Error::invalid_config(format!("Failed to parse OpenAPI spec: {e}")))?;
95 let manifest = crate::agent::generate_capability_manifest_from_openapi(
96 context,
97 &openapi_spec,
98 &spec,
99 global_config.as_ref(),
100 )?;
101 let output = match &cli.jq {
102 Some(jq_filter) => executor::apply_jq_filter(&manifest, jq_filter)?,
103 None => manifest,
104 };
105 println!("{output}");
107 return Ok(());
108 }
109
110 if let Some(batch_file_path) = &cli.batch_file {
112 return execute_batch_operations(
113 context,
114 batch_file_path,
115 &spec,
116 global_config.as_ref(),
117 cli,
118 )
119 .await;
120 }
121
122 let command = generator::generate_command_tree_with_flags(&spec, cli.positional_args);
124 let matches = command
125 .try_get_matches_from(std::iter::once(constants::CLI_ROOT_COMMAND.to_string()).chain(args))
126 .map_err(|e| Error::invalid_command(context, e.to_string()))?;
127
128 if crate::cli::translate::has_show_examples_flag(&matches) {
130 let operation_id = crate::cli::translate::matches_to_operation_id(&spec, &matches)?;
131 let operation = spec
132 .commands
133 .iter()
134 .find(|cmd| cmd.operation_id == operation_id)
135 .ok_or_else(|| Error::spec_not_found(context))?;
136 crate::cli::render::render_examples(operation);
137 return Ok(());
138 }
139
140 let jq_filter = matches
141 .get_one::<String>("jq")
142 .map(String::as_str)
143 .or(cli.jq.as_deref());
144 let output_format = resolve_output_format(&matches, &cli.format);
145
146 let call = crate::cli::translate::matches_to_operation_call(&spec, &matches)?;
148 let mut ctx = crate::cli::translate::cli_to_execution_context(cli, global_config)?;
149 ctx.server_var_args = crate::cli::translate::extract_server_var_args(&matches);
150
151 if ctx.auto_paginate && jq_filter.is_some() {
152 tracing::warn!(
153 "--jq is ignored with --auto-paginate; \
154 pipe NDJSON output through an external jq process instead"
155 );
156 }
157 if ctx.auto_paginate && !matches!(output_format, crate::cli::OutputFormat::Json) {
158 tracing::warn!("--format is ignored with --auto-paginate; output is always NDJSON");
159 }
160
161 if ctx.auto_paginate {
163 let mut stdout = std::io::stdout();
164 let result = crate::pagination::execute_paginated(&spec, call, ctx, &mut stdout).await;
165 return match result {
166 Ok(_) => Ok(()),
167 Err(e) => {
168 let e = enrich_network_error(e);
169 emit_pagination_error_ndjson(cli, &mut stdout, &e);
173 Err(e)
174 }
175 };
176 }
177
178 let result = executor::execute(&spec, call, ctx)
180 .await
181 .map_err(enrich_network_error)?;
182
183 crate::cli::render::render_result(&result, &output_format, jq_filter)?;
184 Ok(())
185}
186
187#[allow(clippy::too_many_lines)]
189pub async fn execute_batch_operations(
190 _context: &str,
191 batch_file_path: &str,
192 spec: &CachedSpec,
193 global_config: Option<&GlobalConfig>,
194 cli: &Cli,
195) -> Result<(), Error> {
196 let batch_file =
197 BatchProcessor::parse_batch_file(std::path::Path::new(batch_file_path)).await?;
198 let batch_config = BatchConfig {
199 max_concurrency: cli.batch_concurrency,
200 rate_limit: cli.batch_rate_limit,
201 continue_on_error: true,
202 show_progress: !cli.quiet && !cli.json_errors,
203 suppress_output: cli.json_errors,
204 };
205 let processor = BatchProcessor::new(batch_config);
206 let result = processor
207 .execute_batch(
208 spec,
209 batch_file,
210 global_config,
211 None,
212 cli.dry_run,
213 &cli.format,
214 None,
215 )
216 .await?;
217
218 let output = Output::new(cli.quiet, cli.json_errors);
219
220 if cli.json_errors {
221 let summary = serde_json::json!({
222 "batch_execution_summary": {
223 "total_operations": result.results.len(),
224 "successful_operations": result.success_count,
225 "failed_operations": result.failure_count,
226 "total_duration_seconds": result.total_duration.as_secs_f64(),
227 "operations": result.results.iter().map(|r| serde_json::json!({
228 "operation_id": r.operation.id,
229 "args": r.operation.args,
230 "success": r.success,
231 "duration_seconds": r.duration.as_secs_f64(),
232 "error": r.error
233 })).collect::<Vec<_>>()
234 }
235 });
236 let json_output = match &cli.jq {
237 Some(jq_filter) => {
238 let summary_json = serde_json::to_string(&summary)
239 .expect("JSON serialization of valid structure cannot fail");
240 executor::apply_jq_filter(&summary_json, jq_filter)?
241 }
242 None => serde_json::to_string_pretty(&summary)
243 .expect("JSON serialization of valid structure cannot fail"),
244 };
245 println!("{json_output}");
247 if result.failure_count > 0 {
249 std::process::exit(1);
250 }
251 return Ok(());
252 }
253
254 output.info("\n=== Batch Execution Summary ===");
255 println!("Total operations: {}", result.results.len());
257 println!("Successful: {}", result.success_count);
259 println!("Failed: {}", result.failure_count);
261 println!("Total time: {:.2}s", result.total_duration.as_secs_f64());
263
264 if result.failure_count == 0 {
265 return Ok(());
266 }
267
268 output.info("\nFailed operations:");
269 for (i, op_result) in result.results.iter().enumerate() {
270 if op_result.success {
271 continue;
272 }
273 println!(
275 " {} - {}: {}",
276 i + 1,
277 op_result.operation.args.join(" "),
278 op_result.error.as_deref().unwrap_or("Unknown error")
279 );
280 }
281
282 if result.failure_count > 0 {
283 std::process::exit(1);
284 }
285 Ok(())
286}
287
288pub async fn execute_shortcut_command(
290 manager: &ConfigManager<OsFileSystem>,
291 args: Vec<String>,
292 cli: &Cli,
293) -> Result<(), Error> {
294 let output = Output::new(cli.quiet, cli.json_errors);
295
296 if args.is_empty() {
297 eprintln!("Error: No command specified");
300 eprintln!("Usage: aperture exec <shortcut> [args...]");
302 eprintln!("Examples:");
304 eprintln!(" aperture exec getUserById --id 123");
306 eprintln!(" aperture exec GET /users/123");
308 eprintln!(" aperture exec users list");
310 std::process::exit(1);
311 }
312
313 let specs = manager.list_specs()?;
314 if specs.is_empty() {
315 output.info("No API specifications found. Use 'aperture config add' to register APIs.");
316 return Ok(());
317 }
318
319 let cache_dir = manager.config_dir().join(constants::DIR_CACHE);
320 let mut all_specs = std::collections::BTreeMap::new();
321 for spec_name in &specs {
322 match loader::load_cached_spec(&cache_dir, spec_name) {
323 Ok(spec) => {
324 all_specs.insert(spec_name.clone(), spec);
325 }
326 Err(e) => tracing::warn!(spec = spec_name, error = %e, "could not load spec"),
327 }
328 }
329 if all_specs.is_empty() {
330 output.info("No valid API specifications found.");
331 return Ok(());
332 }
333
334 let mut resolver = ShortcutResolver::new();
335 resolver.index_specs(&all_specs);
336
337 match resolver.resolve_shortcut(&args) {
338 ResolutionResult::Resolved(shortcut) => {
339 output.info(format!(
340 "Resolved shortcut to: aperture {}",
341 shortcut.full_command.join(" ")
342 ));
343 let context = &shortcut.full_command[1];
344 let operation_args = shortcut.full_command[2..].to_vec();
345 let user_args = if args.len() > count_shortcut_args(&args) {
346 args[count_shortcut_args(&args)..].to_vec()
347 } else {
348 Vec::new()
349 };
350 let final_args = [operation_args, user_args].concat();
351 execute_api_command(context, final_args, cli).await
352 }
353 ResolutionResult::Ambiguous(matches) => {
354 eprintln!("Ambiguous shortcut. Multiple commands match:");
357 eprintln!("{}", resolver.format_ambiguous_suggestions(&matches));
359 eprintln!("\nTip: Use 'aperture search <term>' to explore available commands");
361 std::process::exit(1);
362 }
363 ResolutionResult::NotFound => {
364 eprintln!("No command found for shortcut: {}", args.join(" "));
367 eprintln!("Try one of these:");
369 eprintln!(
371 " aperture search '{}' # Search for similar commands",
372 args[0]
373 );
374 eprintln!(" aperture list-commands <api> # List available commands for an API");
376 eprintln!(" aperture api <api> --help # Show help for an API");
378 std::process::exit(1);
379 }
380 }
381}
382
383fn count_shortcut_args(args: &[String]) -> usize {
384 for (i, arg) in args.iter().enumerate() {
385 if arg.starts_with('-') || arg.contains('=') {
386 return i;
387 }
388 }
389 std::cmp::min(args.len(), 3)
390}
391
392#[cfg(test)]
393mod tests {
394 use super::resolve_output_format;
395 use crate::cli::OutputFormat;
396 use clap::{Arg, Command};
397
398 fn matches_from(args: &[&str]) -> clap::ArgMatches {
399 Command::new("api")
400 .arg(
401 Arg::new("format")
402 .long("format")
403 .value_parser(["json", "yaml", "table"])
404 .default_value("json"),
405 )
406 .get_matches_from(args)
407 }
408
409 #[test]
410 fn resolve_output_format_prefers_cli_value_when_dynamic_match_is_default() {
411 let matches = matches_from(&["api"]);
412 let resolved = resolve_output_format(&matches, &OutputFormat::Yaml);
413
414 assert!(matches!(resolved, OutputFormat::Yaml));
415 }
416
417 #[test]
418 fn resolve_output_format_honors_explicit_json_override() {
419 let matches = matches_from(&["api", "--format", "json"]);
420 let resolved = resolve_output_format(&matches, &OutputFormat::Yaml);
421
422 assert!(matches!(resolved, OutputFormat::Json));
423 }
424}