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#[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#[derive(Debug, Clone)]
55pub struct RetryContext {
56 pub max_attempts: u32,
58 pub initial_delay_ms: u64,
60 pub max_delay_ms: u64,
62 pub force_retry: bool,
64 pub method: Option<String>,
66 pub has_idempotency_key: bool,
68}
69
70impl Default for RetryContext {
71 fn default() -> Self {
72 Self {
73 max_attempts: 0, 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 #[must_use]
86 pub const fn is_enabled(&self) -> bool {
87 self.max_attempts > 0
88 }
89
90 #[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 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
107fn 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
122async 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 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 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#[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 logging::log_request(
184 method.as_str(),
185 url,
186 Some(&headers),
187 body.as_deref(),
188 secret_ctx,
189 );
190
191 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 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 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 if status.is_success() {
240 return Ok((status, response_headers, response_text));
241 }
242
243 if !is_retryable_status(status.as_u16()) {
245 return Ok((status, response_headers, response_text));
246 }
247
248 let retry_after = response_headers
250 .get("retry-after")
251 .and_then(|v| parse_retry_after_value(v));
252
253 let delay = calculate_retry_delay_with_header(
255 &retry_config,
256 (attempt - 1) as usize, retry_after,
258 );
259
260 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 last_status = Some(status);
276 last_response_headers = Some(response_headers);
277 last_response_text = Some(response_text);
278 }
279 Err(e) => {
280 let should_retry = matches!(&e, Error::Network(_));
282
283 if !should_retry {
284 return Err(e);
285 }
286
287 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 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 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 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 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
354fn 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
369fn 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
403fn 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 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
445async 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#[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 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
513pub use crate::cli::legacy_execute::execute_request;
518
519fn 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
530fn parse_custom_header(header_str: &str) -> Result<(String, String), Error> {
532 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 let expanded_value = if value.starts_with("${") && value.ends_with('}') {
546 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_header_value(name, &expanded_value)?;
555
556 Ok((name.to_string(), expanded_value))
557}
558
559#[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 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 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 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 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_header_value(constants::HEADER_AUTHORIZATION, &secret_value)?;
611
612 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 }
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 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 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#[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 let operation = find_operation_by_id(spec, &call.operation_id)?;
698
699 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 let url = build_url_from_params(
711 &base_url,
712 &operation.path,
713 &call.path_params,
714 &call.query_params,
715 )?;
716
717 let client = build_http_client()?;
719
720 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 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 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 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 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 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 let retry_ctx = ctx.retry_context.map(|mut rc| {
790 rc.method = Some(method.to_string());
791 rc
792 });
793
794 let secret_ctx =
796 logging::SecretContext::from_spec_and_config(spec, &spec.name, ctx.global_config.as_ref());
797
798 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 if !status.is_success() {
813 return Err(handle_http_error(status, response_text, spec, operation));
814 }
815
816 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
841fn 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
856fn 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 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(¶m_name)
877 .ok_or_else(|| Error::missing_path_parameter(¶m_name))?;
878
879 url.replace_range(open_pos..=close_pos, value);
880 start = open_pos + value.len();
881 }
882
883 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#[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 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 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 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 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
949pub fn apply_jq_filter(response_text: &str, filter: &str) -> Result<String, Error> {
958 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_core::load::{Arena, File, Loader};
966 use jaq_core::Compiler;
967
968 let program = File {
970 code: filter,
971 path: (),
972 };
973
974 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 let loader = Loader::new(defs);
981 let arena = Arena::default();
982
983 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 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 let jaq_value = Val::from(json_value);
1007
1008 let inputs = RcIter::new(core::iter::empty());
1010 let ctx = Ctx::new([], &inputs);
1011
1012 let output = filter_fn.run((ctx, jaq_value));
1014
1015 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 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 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 apply_basic_jq_filter(&json_value, filter)
1050 }
1051}
1052
1053#[cfg(not(feature = "jq"))]
1054fn apply_basic_jq_filter(json_value: &Value, filter: &str) -> Result<String, Error> {
1056 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 match json_value {
1079 Value::Array(arr) => {
1080 Value::Array(arr.clone())
1082 }
1083 Value::Object(obj) => {
1084 Value::Array(obj.values().cloned().collect())
1086 }
1087 _ => Value::Null,
1088 }
1089 }
1090 ".length" => {
1091 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 let field_path = &filter[4..]; 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 let field_path = &filter[1..]; 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"))]
1129fn 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 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 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 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}