Skip to main content

aperture_cli/engine/
executor.rs

1use crate::cache::models::{CachedCommand, CachedSecurityScheme, CachedSpec};
2use crate::config::models::GlobalConfig;
3use crate::config::url_resolver::BaseUrlResolver;
4use crate::constants;
5use crate::error::Error;
6use crate::logging;
7use crate::resilience::{
8    calculate_retry_delay_with_header, is_retryable_status, parse_retry_after_value,
9};
10use crate::response_cache::{
11    is_auth_header, scrub_auth_headers, CacheConfig, CacheKey, CachedRequestInfo, CachedResponse,
12    ResponseCache,
13};
14use crate::utils::to_kebab_case;
15use base64::{engine::general_purpose, Engine as _};
16use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
17use reqwest::Method;
18use serde_json::Value;
19use sha2::{Digest, Sha256};
20use std::collections::HashMap;
21use std::str::FromStr;
22use tokio::time::sleep;
23
24#[cfg(feature = "jq")]
25use jaq_core::{Ctx, RcIter};
26#[cfg(feature = "jq")]
27use jaq_json::Val;
28
29/// Represents supported authentication schemes
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum AuthScheme {
32    Bearer,
33    Basic,
34    Token,
35    DSN,
36    ApiKey,
37    Custom(String),
38}
39
40impl From<&str> for AuthScheme {
41    fn from(s: &str) -> Self {
42        match s.to_lowercase().as_str() {
43            constants::AUTH_SCHEME_BEARER => Self::Bearer,
44            constants::AUTH_SCHEME_BASIC => Self::Basic,
45            "token" => Self::Token,
46            "dsn" => Self::DSN,
47            constants::AUTH_SCHEME_APIKEY => Self::ApiKey,
48            _ => Self::Custom(s.to_string()),
49        }
50    }
51}
52
53/// Configuration for request retry behavior.
54#[derive(Debug, Clone)]
55pub struct RetryContext {
56    /// Maximum number of retry attempts (0 = disabled)
57    pub max_attempts: u32,
58    /// Initial delay between retries in milliseconds
59    pub initial_delay_ms: u64,
60    /// Maximum delay cap in milliseconds
61    pub max_delay_ms: u64,
62    /// Whether to force retry on non-idempotent requests without idempotency key
63    pub force_retry: bool,
64    /// HTTP method (used to check idempotency)
65    pub method: Option<String>,
66    /// Whether an idempotency key is set
67    pub has_idempotency_key: bool,
68}
69
70impl Default for RetryContext {
71    fn default() -> Self {
72        Self {
73            max_attempts: 0, // Disabled by default
74            initial_delay_ms: 500,
75            max_delay_ms: 30_000,
76            force_retry: false,
77            method: None,
78            has_idempotency_key: false,
79        }
80    }
81}
82
83impl RetryContext {
84    /// Returns true if retries are enabled.
85    #[must_use]
86    pub const fn is_enabled(&self) -> bool {
87        self.max_attempts > 0
88    }
89
90    /// Returns true if the request method is safe to retry (idempotent or has key).
91    #[must_use]
92    pub fn is_safe_to_retry(&self) -> bool {
93        if self.force_retry || self.has_idempotency_key {
94            return true;
95        }
96
97        // GET, HEAD, PUT, OPTIONS, TRACE are idempotent per HTTP semantics
98        self.method.as_ref().is_some_and(|m| {
99            matches!(
100                m.to_uppercase().as_str(),
101                "GET" | "HEAD" | "PUT" | "OPTIONS" | "TRACE"
102            )
103        })
104    }
105}
106
107// Helper functions
108
109/// Build HTTP client with default timeout
110fn build_http_client() -> Result<reqwest::Client, Error> {
111    reqwest::Client::builder()
112        .timeout(std::time::Duration::from_secs(30))
113        .build()
114        .map_err(|e| {
115            Error::request_failed(
116                reqwest::StatusCode::INTERNAL_SERVER_ERROR,
117                format!("Failed to create HTTP client: {e}"),
118            )
119        })
120}
121
122/// Send HTTP request and get response
123async fn send_request(
124    request: reqwest::RequestBuilder,
125    secret_ctx: Option<&logging::SecretContext>,
126) -> Result<(reqwest::StatusCode, HashMap<String, String>, String), Error> {
127    let start_time = std::time::Instant::now();
128
129    let response = request
130        .send()
131        .await
132        .map_err(|e| Error::network_request_failed(e.to_string()))?;
133
134    let status = response.status();
135    let duration_ms = start_time.elapsed().as_millis();
136
137    // Copy headers before consuming response
138    let mut response_headers_map = reqwest::header::HeaderMap::new();
139    for (name, value) in response.headers() {
140        response_headers_map.insert(name.clone(), value.clone());
141    }
142
143    let response_headers: HashMap<String, String> = response
144        .headers()
145        .iter()
146        .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
147        .collect();
148
149    let response_text = response
150        .text()
151        .await
152        .map_err(|e| Error::response_read_error(e.to_string()))?;
153
154    // Log response with secret redaction
155    logging::log_response(
156        status.as_u16(),
157        duration_ms,
158        Some(&response_headers_map),
159        Some(&response_text),
160        logging::get_max_body_len(),
161        secret_ctx,
162    );
163
164    Ok((status, response_headers, response_text))
165}
166
167/// Send HTTP request with retry logic
168#[allow(clippy::too_many_arguments)]
169#[allow(clippy::too_many_lines)]
170async fn send_request_with_retry(
171    client: &reqwest::Client,
172    method: Method,
173    url: &str,
174    headers: HeaderMap,
175    body: Option<String>,
176    retry_context: Option<&RetryContext>,
177    operation: &CachedCommand,
178    secret_ctx: Option<&logging::SecretContext>,
179) -> Result<(reqwest::StatusCode, HashMap<String, String>, String), Error> {
180    use crate::resilience::RetryConfig;
181
182    // Log the request with secret redaction
183    logging::log_request(
184        method.as_str(),
185        url,
186        Some(&headers),
187        body.as_deref(),
188        secret_ctx,
189    );
190
191    // If no retry context or retries disabled, just send once
192    let Some(ctx) = retry_context else {
193        let request = build_request(client, method, url, headers, body);
194        return send_request(request, secret_ctx).await;
195    };
196
197    if !ctx.is_enabled() {
198        let request = build_request(client, method, url, headers, body);
199        return send_request(request, secret_ctx).await;
200    }
201
202    // Check if safe to retry non-GET requests
203    if !ctx.is_safe_to_retry() {
204        tracing::warn!(
205            method = %method,
206            operation_id = %operation.operation_id,
207            "Retries disabled - method is not idempotent and no idempotency key provided. \
208             Use --force-retry or provide --idempotency-key"
209        );
210        let request = build_request(client, method.clone(), url, headers, body);
211        return send_request(request, secret_ctx).await;
212    }
213
214    // Create a RetryConfig from the RetryContext
215    let retry_config = RetryConfig {
216        max_attempts: ctx.max_attempts as usize,
217        initial_delay_ms: ctx.initial_delay_ms,
218        max_delay_ms: ctx.max_delay_ms,
219        backoff_multiplier: 2.0,
220        jitter: true,
221    };
222
223    let max_attempts = ctx.max_attempts;
224    let mut attempt: u32 = 0;
225    let mut last_error: Option<Error> = None;
226    let mut last_status: Option<reqwest::StatusCode> = None;
227    let mut last_response_headers: Option<HashMap<String, String>> = None;
228    let mut last_response_text: Option<String> = None;
229
230    while attempt < max_attempts {
231        attempt += 1;
232
233        let request = build_request(client, method.clone(), url, headers.clone(), body.clone());
234        let result = send_request(request, secret_ctx).await;
235
236        match result {
237            Ok((status, response_headers, response_text)) => {
238                // Success - return immediately
239                if status.is_success() {
240                    return Ok((status, response_headers, response_text));
241                }
242
243                // Check if we should retry this status code
244                if !is_retryable_status(status.as_u16()) {
245                    return Ok((status, response_headers, response_text));
246                }
247
248                // Parse Retry-After header if present
249                let retry_after = response_headers
250                    .get("retry-after")
251                    .and_then(|v| parse_retry_after_value(v));
252
253                // Calculate delay using the retry config
254                let delay = calculate_retry_delay_with_header(
255                    &retry_config,
256                    (attempt - 1) as usize, // 0-indexed for delay calculation
257                    retry_after,
258                );
259
260                // Check if we have more attempts
261                if attempt < max_attempts {
262                    tracing::warn!(
263                        attempt,
264                        max_attempts,
265                        method = %method,
266                        operation_id = %operation.operation_id,
267                        status = status.as_u16(),
268                        delay_ms = delay.as_millis(),
269                        "Retrying after HTTP error"
270                    );
271                    sleep(delay).await;
272                }
273
274                // Save for potential final error
275                last_status = Some(status);
276                last_response_headers = Some(response_headers);
277                last_response_text = Some(response_text);
278            }
279            Err(e) => {
280                // Network error - check if we should retry
281                let should_retry = matches!(&e, Error::Network(_));
282
283                if !should_retry {
284                    return Err(e);
285                }
286
287                // Calculate delay
288                let delay =
289                    calculate_retry_delay_with_header(&retry_config, (attempt - 1) as usize, None);
290
291                if attempt < max_attempts {
292                    tracing::warn!(
293                        attempt,
294                        max_attempts,
295                        method = %method,
296                        operation_id = %operation.operation_id,
297                        delay_ms = delay.as_millis(),
298                        error = %e,
299                        "Retrying after network error"
300                    );
301                    sleep(delay).await;
302                }
303
304                last_error = Some(e);
305            }
306        }
307    }
308
309    // All retries exhausted - return last result
310    if let (Some(status), Some(headers), Some(text)) =
311        (last_status, last_response_headers, last_response_text)
312    {
313        tracing::warn!(
314            method = %method,
315            operation_id = %operation.operation_id,
316            max_attempts,
317            "Retry exhausted"
318        );
319        return Ok((status, headers, text));
320    }
321
322    // Return detailed retry error if we have a last error
323    if let Some(e) = last_error {
324        tracing::warn!(
325            method = %method,
326            operation_id = %operation.operation_id,
327            max_attempts,
328            "Retry exhausted"
329        );
330        // Return detailed retry error with full context
331        return Err(Error::retry_limit_exceeded_detailed(
332            max_attempts,
333            attempt,
334            e.to_string(),
335            ctx.initial_delay_ms,
336            ctx.max_delay_ms,
337            None,
338            &operation.operation_id,
339        ));
340    }
341
342    // Should not happen, but handle gracefully
343    Err(Error::retry_limit_exceeded_detailed(
344        max_attempts,
345        attempt,
346        "Request failed with no response",
347        ctx.initial_delay_ms,
348        ctx.max_delay_ms,
349        None,
350        &operation.operation_id,
351    ))
352}
353
354/// Build a request from components
355fn build_request(
356    client: &reqwest::Client,
357    method: Method,
358    url: &str,
359    headers: HeaderMap,
360    body: Option<String>,
361) -> reqwest::RequestBuilder {
362    let mut request = client.request(method, url).headers(headers);
363    if let Some(json_body) = body.and_then(|s| serde_json::from_str::<Value>(&s).ok()) {
364        request = request.json(&json_body);
365    }
366    request
367}
368
369/// Handle HTTP error responses
370fn handle_http_error(
371    status: reqwest::StatusCode,
372    response_text: String,
373    spec: &CachedSpec,
374    operation: &CachedCommand,
375) -> Error {
376    let api_name = spec.name.clone();
377    let operation_id = Some(operation.operation_id.clone());
378
379    let security_schemes: Vec<String> = operation
380        .security_requirements
381        .iter()
382        .filter_map(|scheme_name| {
383            spec.security_schemes
384                .get(scheme_name)
385                .and_then(|scheme| scheme.aperture_secret.as_ref())
386                .map(|aperture_secret| aperture_secret.name.clone())
387        })
388        .collect();
389
390    Error::http_error_with_context(
391        status.as_u16(),
392        if response_text.is_empty() {
393            constants::EMPTY_RESPONSE.to_string()
394        } else {
395            response_text
396        },
397        api_name,
398        operation_id,
399        &security_schemes,
400    )
401}
402
403/// Prepare cache context if caching is enabled
404fn prepare_cache_context(
405    cache_config: Option<&CacheConfig>,
406    spec_name: &str,
407    operation_id: &str,
408    method: &reqwest::Method,
409    url: &str,
410    headers: &reqwest::header::HeaderMap,
411    body: Option<&str>,
412) -> Result<Option<(CacheKey, ResponseCache)>, Error> {
413    let Some(cache_cfg) = cache_config else {
414        return Ok(None);
415    };
416
417    if !cache_cfg.enabled {
418        return Ok(None);
419    }
420
421    // Skip caching for authenticated requests unless explicitly allowed
422    let has_auth_headers = headers.iter().any(|(k, _)| is_auth_header(k.as_str()));
423    if has_auth_headers && !cache_cfg.allow_authenticated {
424        return Ok(None);
425    }
426
427    let header_map: HashMap<String, String> = headers
428        .iter()
429        .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
430        .collect();
431
432    let cache_key = CacheKey::from_request(
433        spec_name,
434        operation_id,
435        method.as_ref(),
436        url,
437        &header_map,
438        body,
439    )?;
440
441    let response_cache = ResponseCache::new(cache_cfg.clone())?;
442    Ok(Some((cache_key, response_cache)))
443}
444
445/// Check cache for existing response
446async fn check_cache(
447    cache_context: Option<&(CacheKey, ResponseCache)>,
448) -> Result<Option<CachedResponse>, Error> {
449    if let Some((cache_key, response_cache)) = cache_context {
450        response_cache.get(cache_key).await
451    } else {
452        Ok(None)
453    }
454}
455
456/// Store response in cache
457#[allow(clippy::too_many_arguments)]
458async fn store_in_cache(
459    cache_context: Option<(CacheKey, ResponseCache)>,
460    response_text: &str,
461    status: reqwest::StatusCode,
462    response_headers: &HashMap<String, String>,
463    method: reqwest::Method,
464    url: String,
465    headers: &reqwest::header::HeaderMap,
466    body: Option<&str>,
467    cache_config: Option<&CacheConfig>,
468) -> Result<(), Error> {
469    let Some((cache_key, response_cache)) = cache_context else {
470        return Ok(());
471    };
472
473    // Convert headers to HashMap and scrub auth headers before caching
474    let raw_headers: HashMap<String, String> = headers
475        .iter()
476        .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
477        .collect();
478    let scrubbed_headers = scrub_auth_headers(&raw_headers);
479
480    let cached_request_info = CachedRequestInfo {
481        method: method.to_string(),
482        url,
483        headers: scrubbed_headers,
484        body_hash: body.map(|b| {
485            let mut hasher = Sha256::new();
486            hasher.update(b.as_bytes());
487            format!("{:x}", hasher.finalize())
488        }),
489    };
490
491    let cache_ttl = cache_config.and_then(|cfg| {
492        if cfg.default_ttl.as_secs() > 0 {
493            Some(cfg.default_ttl)
494        } else {
495            None
496        }
497    });
498
499    response_cache
500        .store(
501            &cache_key,
502            response_text,
503            status.as_u16(),
504            response_headers,
505            cached_request_info,
506            cache_ttl,
507        )
508        .await?;
509
510    Ok(())
511}
512
513/// Legacy compatibility wrapper retained for existing tests and callers.
514///
515/// The implementation lives in the CLI layer to keep this engine module free
516/// of direct clap/rendering dependencies.
517pub use crate::cli::legacy_execute::execute_request;
518
519/// Validates that a header value doesn't contain control characters
520fn validate_header_value(name: &str, value: &str) -> Result<(), Error> {
521    if value.chars().any(|c| c == '\r' || c == '\n' || c == '\0') {
522        return Err(Error::invalid_header_value(
523            name,
524            "Header value contains invalid control characters (newline, carriage return, or null)",
525        ));
526    }
527    Ok(())
528}
529
530/// Parses a custom header string in the format "Name: Value" or "Name:Value"
531fn parse_custom_header(header_str: &str) -> Result<(String, String), Error> {
532    // Find the colon separator
533    let colon_pos = header_str
534        .find(':')
535        .ok_or_else(|| Error::invalid_header_format(header_str))?;
536
537    let name = header_str[..colon_pos].trim();
538    let value = header_str[colon_pos + 1..].trim();
539
540    if name.is_empty() {
541        return Err(Error::empty_header_name());
542    }
543
544    // Support environment variable expansion in header values
545    let expanded_value = if value.starts_with("${") && value.ends_with('}') {
546        // Extract environment variable name
547        let var_name = &value[2..value.len() - 1];
548        std::env::var(var_name).unwrap_or_else(|_| value.to_string())
549    } else {
550        value.to_string()
551    };
552
553    // Validate the header value
554    validate_header_value(name, &expanded_value)?;
555
556    Ok((name.to_string(), expanded_value))
557}
558
559/// Adds an authentication header based on a security scheme
560#[allow(clippy::too_many_lines)]
561fn add_authentication_header(
562    headers: &mut HeaderMap,
563    security_scheme: &CachedSecurityScheme,
564    api_name: &str,
565    global_config: Option<&GlobalConfig>,
566) -> Result<(), Error> {
567    tracing::debug!(
568        scheme_name = %security_scheme.name,
569        scheme_type = %security_scheme.scheme_type,
570        "Adding authentication header"
571    );
572
573    // Priority 1: Check config-based secrets first
574    let secret_config = global_config
575        .and_then(|config| config.api_configs.get(api_name))
576        .and_then(|api_config| api_config.secrets.get(&security_scheme.name));
577
578    let (secret_value, env_var_name) = match (secret_config, &security_scheme.aperture_secret) {
579        (Some(config_secret), _) => {
580            // Use config-based secret
581            let secret_value = std::env::var(&config_secret.name)
582                .map_err(|_| Error::secret_not_set(&security_scheme.name, &config_secret.name))?;
583            (secret_value, config_secret.name.clone())
584        }
585        (None, Some(aperture_secret)) => {
586            // Priority 2: Fall back to x-aperture-secret extension
587            let secret_value = std::env::var(&aperture_secret.name)
588                .map_err(|_| Error::secret_not_set(&security_scheme.name, &aperture_secret.name))?;
589            (secret_value, aperture_secret.name.clone())
590        }
591        (None, None) => {
592            // No authentication configuration found - skip this scheme
593            return Ok(());
594        }
595    };
596
597    let source = if secret_config.is_some() {
598        "config"
599    } else {
600        "x-aperture-secret"
601    };
602    tracing::debug!(
603        source,
604        scheme_name = %security_scheme.name,
605        env_var = %env_var_name,
606        "Resolved secret"
607    );
608
609    // Validate the secret doesn't contain control characters
610    validate_header_value(constants::HEADER_AUTHORIZATION, &secret_value)?;
611
612    // Build the appropriate header based on scheme type
613    match security_scheme.scheme_type.as_str() {
614        constants::AUTH_SCHEME_APIKEY => {
615            let (Some(location), Some(param_name)) =
616                (&security_scheme.location, &security_scheme.parameter_name)
617            else {
618                return Ok(());
619            };
620
621            if location == "header" {
622                let header_name = HeaderName::from_str(param_name)
623                    .map_err(|e| Error::invalid_header_name(param_name, e.to_string()))?;
624                let header_value = HeaderValue::from_str(&secret_value)
625                    .map_err(|e| Error::invalid_header_value(param_name, e.to_string()))?;
626                headers.insert(header_name, header_value);
627            }
628            // Note: query and cookie locations are handled differently in request building
629        }
630        "http" => {
631            let Some(scheme_str) = &security_scheme.scheme else {
632                return Ok(());
633            };
634
635            let auth_scheme: AuthScheme = scheme_str.as_str().into();
636            let auth_value = match &auth_scheme {
637                AuthScheme::Bearer => {
638                    format!("Bearer {secret_value}")
639                }
640                AuthScheme::Basic => {
641                    // Basic auth expects "username:password" format in the secret
642                    // The secret should contain the raw "username:password" string
643                    // We'll base64 encode it before adding to the header
644                    let encoded = general_purpose::STANDARD.encode(&secret_value);
645                    format!("Basic {encoded}")
646                }
647                AuthScheme::Token
648                | AuthScheme::DSN
649                | AuthScheme::ApiKey
650                | AuthScheme::Custom(_) => {
651                    // Treat any other HTTP scheme as a bearer-like token
652                    // Format: "Authorization: <scheme> <token>"
653                    // This supports Token, ApiKey, DSN, and any custom schemes
654                    format!("{scheme_str} {secret_value}")
655                }
656            };
657
658            let header_value = HeaderValue::from_str(&auth_value).map_err(|e| {
659                Error::invalid_header_value(constants::HEADER_AUTHORIZATION, e.to_string())
660            })?;
661            headers.insert(constants::HEADER_AUTHORIZATION, header_value);
662
663            tracing::debug!(scheme = %scheme_str, "Added HTTP authentication header");
664        }
665        _ => {
666            return Err(Error::unsupported_security_scheme(
667                &security_scheme.scheme_type,
668            ));
669        }
670    }
671
672    Ok(())
673}
674
675// ── New domain-type-based API ───────────────────────────────────────
676
677/// Executes an API operation using CLI-agnostic domain types.
678///
679/// This is the primary entry point for the execution engine. It accepts
680/// pre-extracted parameters in [`OperationCall`] and execution configuration
681/// in [`ExecutionContext`], returning a structured [`ExecutionResult`]
682/// instead of printing directly.
683///
684/// # Errors
685///
686/// Returns errors for authentication failures, network issues, or response
687/// validation problems.
688#[allow(clippy::too_many_lines)]
689pub async fn execute(
690    spec: &CachedSpec,
691    call: crate::invocation::OperationCall,
692    ctx: crate::invocation::ExecutionContext,
693) -> Result<crate::invocation::ExecutionResult, Error> {
694    use crate::invocation::ExecutionResult;
695
696    // Find the operation by operation_id
697    let operation = find_operation_by_id(spec, &call.operation_id)?;
698
699    // Resolve base URL
700    let resolver = BaseUrlResolver::new(spec);
701    let resolver = if let Some(ref config) = ctx.global_config {
702        resolver.with_global_config(config)
703    } else {
704        resolver
705    };
706    let base_url =
707        resolver.resolve_with_variables(ctx.base_url.as_deref(), &ctx.server_var_args)?;
708
709    // Build the full URL from pre-extracted parameters
710    let url = build_url_from_params(
711        &base_url,
712        &operation.path,
713        &call.path_params,
714        &call.query_params,
715    )?;
716
717    // Create HTTP client
718    let client = build_http_client()?;
719
720    // Build headers from pre-extracted parameters
721    let mut headers = build_headers_from_params(
722        spec,
723        operation,
724        &call.header_params,
725        &call.custom_headers,
726        &spec.name,
727        ctx.global_config.as_ref(),
728    )?;
729
730    // Add idempotency key if provided
731    if let Some(ref key) = ctx.idempotency_key {
732        headers.insert(
733            HeaderName::from_static("idempotency-key"),
734            HeaderValue::from_str(key).map_err(|_| Error::invalid_idempotency_key())?,
735        );
736    }
737
738    // Determine HTTP method
739    let method = Method::from_str(&operation.method)
740        .map_err(|_| Error::invalid_http_method(&operation.method))?;
741
742    let headers_clone = headers.clone();
743
744    // Prepare cache context
745    let cache_context = prepare_cache_context(
746        ctx.cache_config.as_ref(),
747        &spec.name,
748        &operation.operation_id,
749        &method,
750        &url,
751        &headers_clone,
752        call.body.as_deref(),
753    )?;
754
755    // Check cache for existing response
756    if let Some(cached_response) = check_cache(cache_context.as_ref()).await? {
757        return Ok(ExecutionResult::Cached {
758            body: cached_response.body,
759        });
760    }
761
762    // Handle dry-run mode
763    if ctx.dry_run {
764        let headers_map: HashMap<String, String> = headers_clone
765            .iter()
766            .map(|(k, v)| {
767                let value = if logging::should_redact_header(k.as_str()) {
768                    "[REDACTED]".to_string()
769                } else {
770                    v.to_str().unwrap_or("<binary>").to_string()
771                };
772                (k.as_str().to_string(), value)
773            })
774            .collect();
775
776        let request_info = serde_json::json!({
777            "dry_run": true,
778            "method": method.to_string(),
779            "url": url,
780            "headers": headers_map,
781            "body": call.body,
782            "operation_id": operation.operation_id
783        });
784
785        return Ok(ExecutionResult::DryRun { request_info });
786    }
787
788    // Build retry context with method information
789    let retry_ctx = ctx.retry_context.map(|mut rc| {
790        rc.method = Some(method.to_string());
791        rc
792    });
793
794    // Build secret context for dynamic secret redaction in logs
795    let secret_ctx =
796        logging::SecretContext::from_spec_and_config(spec, &spec.name, ctx.global_config.as_ref());
797
798    // Send request with retry support
799    let (status, response_headers, response_text) = send_request_with_retry(
800        &client,
801        method.clone(),
802        &url,
803        headers,
804        call.body.clone(),
805        retry_ctx.as_ref(),
806        operation,
807        Some(&secret_ctx),
808    )
809    .await?;
810
811    // Check if request was successful
812    if !status.is_success() {
813        return Err(handle_http_error(status, response_text, spec, operation));
814    }
815
816    // Store response in cache
817    store_in_cache(
818        cache_context,
819        &response_text,
820        status,
821        &response_headers,
822        method,
823        url,
824        &headers_clone,
825        call.body.as_deref(),
826        ctx.cache_config.as_ref(),
827    )
828    .await?;
829
830    if response_text.is_empty() {
831        Ok(ExecutionResult::Empty)
832    } else {
833        Ok(ExecutionResult::Success {
834            body: response_text,
835            status: status.as_u16(),
836            headers: response_headers,
837        })
838    }
839}
840
841/// Finds an operation by its `operation_id` in the spec.
842fn find_operation_by_id<'a>(
843    spec: &'a CachedSpec,
844    operation_id: &str,
845) -> Result<&'a CachedCommand, Error> {
846    spec.commands
847        .iter()
848        .find(|cmd| cmd.operation_id == operation_id)
849        .ok_or_else(|| {
850            let kebab_id = to_kebab_case(operation_id);
851            let suggestions = crate::suggestions::suggest_similar_operations(spec, &kebab_id);
852            Error::operation_not_found_with_suggestions(operation_id, &suggestions)
853        })
854}
855
856/// Builds the full URL from pre-extracted path and query parameter maps.
857fn build_url_from_params(
858    base_url: &str,
859    path_template: &str,
860    path_params: &HashMap<String, String>,
861    query_params: &HashMap<String, String>,
862) -> Result<String, Error> {
863    let mut url = format!("{}{}", base_url.trim_end_matches('/'), path_template);
864
865    // Substitute path parameters: replace {param} with values from the map
866    let mut start = 0;
867    while let Some(open) = url[start..].find('{') {
868        let open_pos = start + open;
869        let Some(close) = url[open_pos..].find('}') else {
870            break;
871        };
872        let close_pos = open_pos + close;
873        let param_name = url[open_pos + 1..close_pos].to_string();
874
875        let value = path_params
876            .get(&param_name)
877            .ok_or_else(|| Error::missing_path_parameter(&param_name))?;
878
879        url.replace_range(open_pos..=close_pos, value);
880        start = open_pos + value.len();
881    }
882
883    // Append query parameters
884    if !query_params.is_empty() {
885        let mut qs_pairs: Vec<(&String, &String)> = query_params.iter().collect();
886        qs_pairs.sort_by(|(k1, _), (k2, _)| k1.cmp(k2));
887
888        let qs: Vec<String> = qs_pairs
889            .into_iter()
890            .map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
891            .collect();
892
893        url.push('?');
894        url.push_str(&qs.join("&"));
895    }
896
897    Ok(url)
898}
899
900/// Builds HTTP headers from pre-extracted header parameter maps.
901#[allow(clippy::too_many_arguments)]
902fn build_headers_from_params(
903    spec: &CachedSpec,
904    operation: &CachedCommand,
905    header_params: &HashMap<String, String>,
906    custom_headers: &[String],
907    api_name: &str,
908    global_config: Option<&GlobalConfig>,
909) -> Result<HeaderMap, Error> {
910    let mut headers = HeaderMap::new();
911
912    // Default headers
913    headers.insert("User-Agent", HeaderValue::from_static("aperture/0.1.0"));
914    headers.insert(
915        constants::HEADER_ACCEPT,
916        HeaderValue::from_static(constants::CONTENT_TYPE_JSON),
917    );
918
919    // Add header parameters from the pre-extracted map
920    for (name, value) in header_params {
921        let header_name = HeaderName::from_str(name)
922            .map_err(|e| Error::invalid_header_name(name, e.to_string()))?;
923        let header_value = HeaderValue::from_str(value)
924            .map_err(|e| Error::invalid_header_value(name, e.to_string()))?;
925        headers.insert(header_name, header_value);
926    }
927
928    // Add authentication headers
929    for security_scheme_name in &operation.security_requirements {
930        let Some(security_scheme) = spec.security_schemes.get(security_scheme_name) else {
931            continue;
932        };
933        add_authentication_header(&mut headers, security_scheme, api_name, global_config)?;
934    }
935
936    // Add custom headers
937    for header_str in custom_headers {
938        let (name, value) = parse_custom_header(header_str)?;
939        let header_name = HeaderName::from_str(&name)
940            .map_err(|e| Error::invalid_header_name(&name, e.to_string()))?;
941        let header_value = HeaderValue::from_str(&value)
942            .map_err(|e| Error::invalid_header_value(&name, e.to_string()))?;
943        headers.insert(header_name, header_value);
944    }
945
946    Ok(headers)
947}
948
949/// Applies a JQ filter to the response text
950///
951/// # Errors
952///
953/// Returns an error if:
954/// - The response text is not valid JSON
955/// - The JQ filter expression is invalid
956/// - The filter execution fails
957pub fn apply_jq_filter(response_text: &str, filter: &str) -> Result<String, Error> {
958    // Parse the response as JSON
959    let json_value: Value = serde_json::from_str(response_text)
960        .map_err(|e| Error::jq_filter_error(filter, format!("Response is not valid JSON: {e}")))?;
961
962    #[cfg(feature = "jq")]
963    {
964        // Use jaq v2.x (pure Rust implementation)
965        use jaq_core::load::{Arena, File, Loader};
966        use jaq_core::Compiler;
967
968        // Create the program from the filter string
969        let program = File {
970            code: filter,
971            path: (),
972        };
973
974        // Collect both standard library and JSON definitions into vectors
975        // This avoids hanging issues with lazy iterator chains
976        let defs: Vec<_> = jaq_std::defs().chain(jaq_json::defs()).collect();
977        let funs: Vec<_> = jaq_std::funs().chain(jaq_json::funs()).collect();
978
979        // Create loader with both standard library and JSON definitions
980        let loader = Loader::new(defs);
981        let arena = Arena::default();
982
983        // Parse the filter
984        let modules = match loader.load(&arena, program) {
985            Ok(modules) => modules,
986            Err(errs) => {
987                return Err(Error::jq_filter_error(
988                    filter,
989                    format!("Parse error: {:?}", errs),
990                ));
991            }
992        };
993
994        // Compile the filter with both standard library and JSON functions
995        let filter_fn = match Compiler::default().with_funs(funs).compile(modules) {
996            Ok(filter) => filter,
997            Err(errs) => {
998                return Err(Error::jq_filter_error(
999                    filter,
1000                    format!("Compilation error: {:?}", errs),
1001                ));
1002            }
1003        };
1004
1005        // Convert serde_json::Value to jaq Val
1006        let jaq_value = Val::from(json_value);
1007
1008        // Execute the filter
1009        let inputs = RcIter::new(core::iter::empty());
1010        let ctx = Ctx::new([], &inputs);
1011
1012        // Run the filter on the input value
1013        let output = filter_fn.run((ctx, jaq_value));
1014
1015        // Collect all results
1016        let results: Result<Vec<Val>, _> = output.collect();
1017
1018        match results {
1019            Ok(vals) => {
1020                if vals.is_empty() {
1021                    return Ok(constants::NULL_VALUE.to_string());
1022                }
1023
1024                if vals.len() == 1 {
1025                    // Single result - convert back to JSON
1026                    let json_val = serde_json::Value::from(vals[0].clone());
1027                    return serde_json::to_string_pretty(&json_val).map_err(|e| {
1028                        Error::serialization_error(format!("Failed to serialize result: {e}"))
1029                    });
1030                }
1031
1032                // Multiple results - return as JSON array
1033                let json_vals: Vec<Value> = vals.into_iter().map(serde_json::Value::from).collect();
1034                let array = Value::Array(json_vals);
1035                serde_json::to_string_pretty(&array).map_err(|e| {
1036                    Error::serialization_error(format!("Failed to serialize results: {e}"))
1037                })
1038            }
1039            Err(e) => Err(Error::jq_filter_error(
1040                format!("{:?}", filter),
1041                format!("Filter execution error: {e}"),
1042            )),
1043        }
1044    }
1045
1046    #[cfg(not(feature = "jq"))]
1047    {
1048        // Basic JQ-like functionality without full jq library
1049        apply_basic_jq_filter(&json_value, filter)
1050    }
1051}
1052
1053#[cfg(not(feature = "jq"))]
1054/// Basic JQ-like functionality for common cases
1055fn apply_basic_jq_filter(json_value: &Value, filter: &str) -> Result<String, Error> {
1056    // Check if the filter uses advanced features
1057    let uses_advanced_features = filter.contains('[')
1058        || filter.contains(']')
1059        || filter.contains('|')
1060        || filter.contains('(')
1061        || filter.contains(')')
1062        || filter.contains("select")
1063        || filter.contains("map")
1064        || filter.contains("length");
1065
1066    if uses_advanced_features {
1067        tracing::warn!(
1068            "Advanced JQ features require building with --features jq. \
1069             Currently only basic field access is supported (e.g., '.field', '.nested.field'). \
1070             To enable full JQ support: cargo install aperture-cli --features jq"
1071        );
1072    }
1073
1074    let result = match filter {
1075        "." => json_value.clone(),
1076        ".[]" => {
1077            // Handle array iteration
1078            match json_value {
1079                Value::Array(arr) => {
1080                    // Return array elements as a JSON array
1081                    Value::Array(arr.clone())
1082                }
1083                Value::Object(obj) => {
1084                    // Return object values as an array
1085                    Value::Array(obj.values().cloned().collect())
1086                }
1087                _ => Value::Null,
1088            }
1089        }
1090        ".length" => {
1091            // Handle length operation
1092            match json_value {
1093                Value::Array(arr) => Value::Number(arr.len().into()),
1094                Value::Object(obj) => Value::Number(obj.len().into()),
1095                Value::String(s) => Value::Number(s.len().into()),
1096                _ => Value::Null,
1097            }
1098        }
1099        filter if filter.starts_with(".[].") => {
1100            // Handle array map like .[].name
1101            let field_path = &filter[4..]; // Remove ".[].""
1102            match json_value {
1103                Value::Array(arr) => {
1104                    let mapped: Vec<Value> = arr
1105                        .iter()
1106                        .map(|item| get_nested_field(item, field_path))
1107                        .collect();
1108                    Value::Array(mapped)
1109                }
1110                _ => Value::Null,
1111            }
1112        }
1113        filter if filter.starts_with('.') => {
1114            // Handle simple field access like .name, .metadata.role
1115            let field_path = &filter[1..]; // Remove the leading dot
1116            get_nested_field(json_value, field_path)
1117        }
1118        _ => {
1119            return Err(Error::jq_filter_error(filter, "Unsupported JQ filter. Only basic field access like '.name' or '.metadata.role' is supported without the full jq library."));
1120        }
1121    };
1122
1123    serde_json::to_string_pretty(&result).map_err(|e| {
1124        Error::serialization_error(format!("Failed to serialize filtered result: {e}"))
1125    })
1126}
1127
1128#[cfg(not(feature = "jq"))]
1129/// Get a nested field from JSON using dot notation
1130fn get_nested_field(json_value: &Value, field_path: &str) -> Value {
1131    let parts: Vec<&str> = field_path.split('.').collect();
1132    let mut current = json_value;
1133
1134    for part in parts {
1135        if part.is_empty() {
1136            continue;
1137        }
1138
1139        // Handle array index notation like [0]
1140        if part.starts_with('[') && part.ends_with(']') {
1141            let index_str = &part[1..part.len() - 1];
1142            let Ok(index) = index_str.parse::<usize>() else {
1143                return Value::Null;
1144            };
1145
1146            match current {
1147                Value::Array(arr) => {
1148                    let Some(item) = arr.get(index) else {
1149                        return Value::Null;
1150                    };
1151                    current = item;
1152                }
1153                _ => return Value::Null,
1154            }
1155            continue;
1156        }
1157
1158        match current {
1159            Value::Object(obj) => {
1160                if let Some(field) = obj.get(part) {
1161                    current = field;
1162                } else {
1163                    return Value::Null;
1164                }
1165            }
1166            Value::Array(arr) => {
1167                // Handle numeric string as array index
1168                let Ok(index) = part.parse::<usize>() else {
1169                    return Value::Null;
1170                };
1171
1172                let Some(item) = arr.get(index) else {
1173                    return Value::Null;
1174                };
1175                current = item;
1176            }
1177            _ => return Value::Null,
1178        }
1179    }
1180
1181    current.clone()
1182}
1183
1184#[cfg(test)]
1185mod tests {
1186    use super::*;
1187
1188    #[test]
1189    fn test_build_url_from_params_sorts_query_parameters() {
1190        let mut query = std::collections::HashMap::new();
1191        query.insert("b".to_string(), "2".to_string());
1192        query.insert("a".to_string(), "1".to_string());
1193
1194        let url = build_url_from_params(
1195            "https://example.com",
1196            "/items",
1197            &std::collections::HashMap::new(),
1198            &query,
1199        )
1200        .expect("url build should succeed");
1201
1202        assert_eq!(url, "https://example.com/items?a=1&b=2");
1203    }
1204
1205    #[test]
1206    fn test_apply_jq_filter_simple_field_access() {
1207        let json = r#"{"name": "Alice", "age": 30}"#;
1208        let result = apply_jq_filter(json, ".name").unwrap();
1209        let parsed: Value = serde_json::from_str(&result).unwrap();
1210        assert_eq!(parsed, serde_json::json!("Alice"));
1211    }
1212
1213    #[test]
1214    fn test_apply_jq_filter_nested_field_access() {
1215        let json = r#"{"user": {"name": "Bob", "id": 123}}"#;
1216        let result = apply_jq_filter(json, ".user.name").unwrap();
1217        let parsed: Value = serde_json::from_str(&result).unwrap();
1218        assert_eq!(parsed, serde_json::json!("Bob"));
1219    }
1220
1221    #[cfg(feature = "jq")]
1222    #[test]
1223    fn test_apply_jq_filter_array_index() {
1224        let json = r#"{"items": ["first", "second", "third"]}"#;
1225        let result = apply_jq_filter(json, ".items[1]").unwrap();
1226        let parsed: Value = serde_json::from_str(&result).unwrap();
1227        assert_eq!(parsed, serde_json::json!("second"));
1228    }
1229
1230    #[cfg(feature = "jq")]
1231    #[test]
1232    fn test_apply_jq_filter_array_iteration() {
1233        let json = r#"[{"id": 1}, {"id": 2}, {"id": 3}]"#;
1234        let result = apply_jq_filter(json, ".[].id").unwrap();
1235        let parsed: Value = serde_json::from_str(&result).unwrap();
1236        // JQ returns multiple results as an array
1237        assert_eq!(parsed, serde_json::json!([1, 2, 3]));
1238    }
1239
1240    #[cfg(feature = "jq")]
1241    #[test]
1242    fn test_apply_jq_filter_complex_expression() {
1243        let json = r#"{"users": [{"name": "Alice", "age": 30}, {"name": "Bob", "age": 25}]}"#;
1244        let result = apply_jq_filter(json, ".users | map(.name)").unwrap();
1245        let parsed: Value = serde_json::from_str(&result).unwrap();
1246        assert_eq!(parsed, serde_json::json!(["Alice", "Bob"]));
1247    }
1248
1249    #[cfg(feature = "jq")]
1250    #[test]
1251    fn test_apply_jq_filter_select() {
1252        let json =
1253            r#"[{"id": 1, "active": true}, {"id": 2, "active": false}, {"id": 3, "active": true}]"#;
1254        let result = apply_jq_filter(json, "[.[] | select(.active)]").unwrap();
1255        let parsed: Value = serde_json::from_str(&result).unwrap();
1256        assert_eq!(
1257            parsed,
1258            serde_json::json!([{"id": 1, "active": true}, {"id": 3, "active": true}])
1259        );
1260    }
1261
1262    #[test]
1263    fn test_apply_jq_filter_invalid_json() {
1264        let json = "not valid json";
1265        let result = apply_jq_filter(json, ".field");
1266        assert!(result.is_err());
1267        if let Err(err) = result {
1268            let error_msg = err.to_string();
1269            assert!(error_msg.contains("JQ filter error"));
1270            assert!(error_msg.contains(".field"));
1271            assert!(error_msg.contains("Response is not valid JSON"));
1272        } else {
1273            panic!("Expected error");
1274        }
1275    }
1276
1277    #[cfg(feature = "jq")]
1278    #[test]
1279    fn test_apply_jq_filter_invalid_expression() {
1280        let json = r#"{"name": "test"}"#;
1281        let result = apply_jq_filter(json, "invalid..expression");
1282        assert!(result.is_err());
1283        if let Err(err) = result {
1284            let error_msg = err.to_string();
1285            assert!(error_msg.contains("JQ filter error") || error_msg.contains("Parse error"));
1286            assert!(error_msg.contains("invalid..expression"));
1287        } else {
1288            panic!("Expected error");
1289        }
1290    }
1291
1292    #[test]
1293    fn test_apply_jq_filter_null_result() {
1294        let json = r#"{"name": "test"}"#;
1295        let result = apply_jq_filter(json, ".missing_field").unwrap();
1296        let parsed: Value = serde_json::from_str(&result).unwrap();
1297        assert_eq!(parsed, serde_json::json!(null));
1298    }
1299
1300    #[cfg(feature = "jq")]
1301    #[test]
1302    fn test_apply_jq_filter_arithmetic() {
1303        let json = r#"{"x": 10, "y": 20}"#;
1304        let result = apply_jq_filter(json, ".x + .y").unwrap();
1305        let parsed: Value = serde_json::from_str(&result).unwrap();
1306        assert_eq!(parsed, serde_json::json!(30));
1307    }
1308
1309    #[cfg(feature = "jq")]
1310    #[test]
1311    fn test_apply_jq_filter_string_concatenation() {
1312        let json = r#"{"first": "Hello", "second": "World"}"#;
1313        let result = apply_jq_filter(json, r#".first + " " + .second"#).unwrap();
1314        let parsed: Value = serde_json::from_str(&result).unwrap();
1315        assert_eq!(parsed, serde_json::json!("Hello World"));
1316    }
1317
1318    #[cfg(feature = "jq")]
1319    #[test]
1320    fn test_apply_jq_filter_length() {
1321        let json = r#"{"items": [1, 2, 3, 4, 5]}"#;
1322        let result = apply_jq_filter(json, ".items | length").unwrap();
1323        let parsed: Value = serde_json::from_str(&result).unwrap();
1324        assert_eq!(parsed, serde_json::json!(5));
1325    }
1326}