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::error::Error;
5use clap::ArgMatches;
6use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
7use reqwest::Method;
8use serde_json::Value;
9use std::str::FromStr;
10
11/// Executes HTTP requests based on parsed CLI arguments and cached spec data.
12///
13/// This module handles the mapping from CLI arguments back to API operations,
14/// resolves authentication secrets, builds HTTP requests, and validates responses.
15///
16/// # Arguments
17/// * `spec` - The cached specification containing operation details
18/// * `matches` - Parsed CLI arguments from clap
19/// * `base_url` - Optional base URL override. If None, uses `BaseUrlResolver`
20/// * `dry_run` - If true, show request details without executing
21/// * `idempotency_key` - Optional idempotency key for safe retries
22/// * `global_config` - Optional global configuration for URL resolution
23///
24/// # Returns
25/// * `Ok(())` - Request executed successfully or dry-run completed
26/// * `Err(Error)` - Request failed or validation error
27///
28/// # Errors
29/// Returns errors for authentication failures, network issues, or response validation
30///
31/// # Panics
32/// Panics if JSON serialization of dry-run information fails (extremely unlikely)
33pub async fn execute_request(
34    spec: &CachedSpec,
35    matches: &ArgMatches,
36    base_url: Option<&str>,
37    dry_run: bool,
38    idempotency_key: Option<&str>,
39    global_config: Option<&GlobalConfig>,
40) -> Result<(), Error> {
41    // Find the operation from the command hierarchy
42    let operation = find_operation(spec, matches)?;
43
44    // Resolve base URL using the new priority hierarchy
45    let resolver = BaseUrlResolver::new(spec);
46    let resolver = if let Some(config) = global_config {
47        resolver.with_global_config(config)
48    } else {
49        resolver
50    };
51    let base_url = resolver.resolve(base_url);
52
53    // Build the full URL with path parameters
54    let url = build_url(&base_url, &operation.path, operation, matches)?;
55
56    // Create HTTP client
57    let client = reqwest::Client::new();
58
59    // Build headers including authentication and idempotency
60    let mut headers = build_headers(spec, operation, matches)?;
61
62    // Add idempotency key if provided
63    if let Some(key) = idempotency_key {
64        headers.insert(
65            HeaderName::from_static("idempotency-key"),
66            HeaderValue::from_str(key).map_err(|_| Error::InvalidIdempotencyKey)?,
67        );
68    }
69
70    // Build request
71    let method = Method::from_str(&operation.method).map_err(|_| Error::InvalidHttpMethod {
72        method: operation.method.clone(),
73    })?;
74
75    let headers_clone = headers.clone(); // For dry-run output
76    let mut request = client.request(method.clone(), &url).headers(headers);
77
78    // Add request body if present
79    // Get to the deepest subcommand matches
80    let mut current_matches = matches;
81    while let Some((_name, sub_matches)) = current_matches.subcommand() {
82        current_matches = sub_matches;
83    }
84
85    // Only check for body if the operation expects one
86    if operation.request_body.is_some() {
87        if let Some(body_value) = current_matches.get_one::<String>("body") {
88            let json_body: Value =
89                serde_json::from_str(body_value).map_err(|e| Error::InvalidJsonBody {
90                    reason: e.to_string(),
91                })?;
92            request = request.json(&json_body);
93        }
94    }
95
96    // Handle dry-run mode - show request details without executing
97    if dry_run {
98        let dry_run_info = serde_json::json!({
99            "dry_run": true,
100            "method": operation.method,
101            "url": url,
102            "headers": headers_clone.iter().map(|(k, v)| (k.as_str(), v.to_str().unwrap_or("<binary>"))).collect::<std::collections::HashMap<_, _>>(),
103            "operation_id": operation.operation_id
104        });
105        println!("{}", serde_json::to_string_pretty(&dry_run_info).unwrap());
106        return Ok(());
107    }
108
109    // Execute request
110    println!("Executing {method} {url}");
111    let response = request.send().await.map_err(|e| Error::RequestFailed {
112        reason: e.to_string(),
113    })?;
114
115    let status = response.status();
116    let response_text = response
117        .text()
118        .await
119        .map_err(|e| Error::ResponseReadError {
120            reason: e.to_string(),
121        })?;
122
123    // Check if request was successful
124    if !status.is_success() {
125        return Err(Error::HttpError {
126            status: status.as_u16(),
127            body: if response_text.is_empty() {
128                "(empty response)".to_string()
129            } else {
130                response_text
131            },
132        });
133    }
134
135    // Print response
136    if !response_text.is_empty() {
137        // Try to pretty-print JSON
138        if let Ok(json_value) = serde_json::from_str::<Value>(&response_text) {
139            if let Ok(pretty) = serde_json::to_string_pretty(&json_value) {
140                println!("{pretty}");
141            } else {
142                println!("{response_text}");
143            }
144        } else {
145            println!("{response_text}");
146        }
147    }
148
149    Ok(())
150}
151
152/// Finds the operation from the command hierarchy
153fn find_operation<'a>(
154    spec: &'a CachedSpec,
155    matches: &ArgMatches,
156) -> Result<&'a CachedCommand, Error> {
157    // Get the subcommand path from matches
158    let mut current_matches = matches;
159    let mut subcommand_path = Vec::new();
160
161    while let Some((name, sub_matches)) = current_matches.subcommand() {
162        subcommand_path.push(name);
163        current_matches = sub_matches;
164    }
165
166    // For now, just find the first matching operation
167    // In a real implementation, we'd match based on the full path
168    if let Some(operation_name) = subcommand_path.last() {
169        for command in &spec.commands {
170            // Convert operation_id to kebab-case for comparison
171            let kebab_id = to_kebab_case(&command.operation_id);
172            if &kebab_id == operation_name || command.method.to_lowercase() == *operation_name {
173                return Ok(command);
174            }
175        }
176    }
177
178    Err(Error::OperationNotFound)
179}
180
181/// Builds the full URL with path parameters substituted
182fn build_url(
183    base_url: &str,
184    path_template: &str,
185    operation: &CachedCommand,
186    matches: &ArgMatches,
187) -> Result<String, Error> {
188    let mut url = format!("{}{}", base_url.trim_end_matches('/'), path_template);
189
190    // Get to the deepest subcommand matches
191    let mut current_matches = matches;
192    while let Some((_name, sub_matches)) = current_matches.subcommand() {
193        current_matches = sub_matches;
194    }
195
196    // Substitute path parameters
197    // Look for {param} patterns and replace with values from matches
198    let mut start = 0;
199    while let Some(open) = url[start..].find('{') {
200        let open_pos = start + open;
201        if let Some(close) = url[open_pos..].find('}') {
202            let close_pos = open_pos + close;
203            let param_name = &url[open_pos + 1..close_pos];
204
205            if let Some(value) = current_matches.get_one::<String>(param_name) {
206                url.replace_range(open_pos..=close_pos, value);
207                start = open_pos + value.len();
208            } else {
209                return Err(Error::MissingPathParameter {
210                    name: param_name.to_string(),
211                });
212            }
213        } else {
214            break;
215        }
216    }
217
218    // Add query parameters
219    let mut query_params = Vec::new();
220    for arg in current_matches.ids() {
221        let arg_str = arg.as_str();
222        // Skip non-query args - only process query parameters from the operation
223        let is_query_param = operation
224            .parameters
225            .iter()
226            .any(|p| p.name == arg_str && p.location == "query");
227        if is_query_param {
228            if let Some(value) = current_matches.get_one::<String>(arg_str) {
229                query_params.push(format!("{}={}", arg_str, urlencoding::encode(value)));
230            }
231        }
232    }
233
234    if !query_params.is_empty() {
235        url.push('?');
236        url.push_str(&query_params.join("&"));
237    }
238
239    Ok(url)
240}
241
242/// Builds headers including authentication
243fn build_headers(
244    spec: &CachedSpec,
245    operation: &CachedCommand,
246    matches: &ArgMatches,
247) -> Result<HeaderMap, Error> {
248    let mut headers = HeaderMap::new();
249
250    // Add default headers
251    headers.insert("User-Agent", HeaderValue::from_static("aperture/0.1.0"));
252    headers.insert("Accept", HeaderValue::from_static("application/json"));
253
254    // Get to the deepest subcommand matches
255    let mut current_matches = matches;
256    while let Some((_name, sub_matches)) = current_matches.subcommand() {
257        current_matches = sub_matches;
258    }
259
260    // Add header parameters from matches
261    for param in &operation.parameters {
262        if param.location == "header" {
263            if let Some(value) = current_matches.get_one::<String>(&param.name) {
264                let header_name =
265                    HeaderName::from_str(&param.name).map_err(|e| Error::InvalidHeaderName {
266                        name: param.name.clone(),
267                        reason: e.to_string(),
268                    })?;
269                let header_value =
270                    HeaderValue::from_str(value).map_err(|e| Error::InvalidHeaderValue {
271                        name: param.name.clone(),
272                        reason: e.to_string(),
273                    })?;
274                headers.insert(header_name, header_value);
275            }
276        }
277    }
278
279    // Add authentication headers based on security requirements
280    for security_scheme_name in &operation.security_requirements {
281        if let Some(security_scheme) = spec.security_schemes.get(security_scheme_name) {
282            add_authentication_header(&mut headers, security_scheme)?;
283        }
284    }
285
286    // Add custom headers from --header/-H flags
287    // Use try_get_many to avoid panic when header arg doesn't exist
288    if let Ok(Some(custom_headers)) = current_matches.try_get_many::<String>("header") {
289        for header_str in custom_headers {
290            let (name, value) = parse_custom_header(header_str)?;
291            let header_name =
292                HeaderName::from_str(&name).map_err(|e| Error::InvalidHeaderName {
293                    name: name.clone(),
294                    reason: e.to_string(),
295                })?;
296            let header_value =
297                HeaderValue::from_str(&value).map_err(|e| Error::InvalidHeaderValue {
298                    name: name.clone(),
299                    reason: e.to_string(),
300                })?;
301            headers.insert(header_name, header_value);
302        }
303    }
304
305    Ok(headers)
306}
307
308/// Parses a custom header string in the format "Name: Value" or "Name:Value"
309fn parse_custom_header(header_str: &str) -> Result<(String, String), Error> {
310    // Find the colon separator
311    let colon_pos = header_str
312        .find(':')
313        .ok_or_else(|| Error::InvalidHeaderFormat {
314            header: header_str.to_string(),
315        })?;
316
317    let name = header_str[..colon_pos].trim();
318    let value = header_str[colon_pos + 1..].trim();
319
320    if name.is_empty() {
321        return Err(Error::EmptyHeaderName);
322    }
323
324    // Support environment variable expansion in header values
325    let expanded_value = if value.starts_with("${") && value.ends_with('}') {
326        // Extract environment variable name
327        let var_name = &value[2..value.len() - 1];
328        std::env::var(var_name).unwrap_or_else(|_| value.to_string())
329    } else {
330        value.to_string()
331    };
332
333    Ok((name.to_string(), expanded_value))
334}
335
336/// Adds an authentication header based on a security scheme
337fn add_authentication_header(
338    headers: &mut HeaderMap,
339    security_scheme: &CachedSecurityScheme,
340) -> Result<(), Error> {
341    // Only process schemes that have aperture_secret mappings
342    if let Some(aperture_secret) = &security_scheme.aperture_secret {
343        // Read the secret from the environment variable
344        let secret_value =
345            std::env::var(&aperture_secret.name).map_err(|_| Error::SecretNotSet {
346                scheme_name: security_scheme.name.clone(),
347                env_var: aperture_secret.name.clone(),
348            })?;
349
350        // Build the appropriate header based on scheme type
351        match security_scheme.scheme_type.as_str() {
352            "apiKey" => {
353                if let (Some(location), Some(param_name)) =
354                    (&security_scheme.location, &security_scheme.parameter_name)
355                {
356                    if location == "header" {
357                        let header_name = HeaderName::from_str(param_name).map_err(|e| {
358                            Error::InvalidHeaderName {
359                                name: param_name.clone(),
360                                reason: e.to_string(),
361                            }
362                        })?;
363                        let header_value = HeaderValue::from_str(&secret_value).map_err(|e| {
364                            Error::InvalidHeaderValue {
365                                name: param_name.clone(),
366                                reason: e.to_string(),
367                            }
368                        })?;
369                        headers.insert(header_name, header_value);
370                    }
371                    // Note: query and cookie locations are handled differently in request building
372                }
373            }
374            "http" => {
375                if let Some(scheme) = &security_scheme.scheme {
376                    match scheme.as_str() {
377                        "bearer" => {
378                            let auth_value = format!("Bearer {secret_value}");
379                            let header_value = HeaderValue::from_str(&auth_value).map_err(|e| {
380                                Error::InvalidHeaderValue {
381                                    name: "Authorization".to_string(),
382                                    reason: e.to_string(),
383                                }
384                            })?;
385                            headers.insert("Authorization", header_value);
386                        }
387                        "basic" => {
388                            // Basic auth expects "username:password" format in the secret
389                            let auth_value = format!("Basic {secret_value}");
390                            let header_value = HeaderValue::from_str(&auth_value).map_err(|e| {
391                                Error::InvalidHeaderValue {
392                                    name: "Authorization".to_string(),
393                                    reason: e.to_string(),
394                                }
395                            })?;
396                            headers.insert("Authorization", header_value);
397                        }
398                        _ => {
399                            return Err(Error::UnsupportedAuthScheme {
400                                scheme: scheme.clone(),
401                            });
402                        }
403                    }
404                }
405            }
406            _ => {
407                return Err(Error::UnsupportedSecurityScheme {
408                    scheme_type: security_scheme.scheme_type.clone(),
409                });
410            }
411        }
412    }
413
414    Ok(())
415}
416
417/// Converts a string to kebab-case (copied from generator.rs)
418fn to_kebab_case(s: &str) -> String {
419    let mut result = String::new();
420    let mut prev_lowercase = false;
421
422    for (i, ch) in s.chars().enumerate() {
423        if ch.is_uppercase() && i > 0 && prev_lowercase {
424            result.push('-');
425        }
426        result.push(ch.to_ascii_lowercase());
427        prev_lowercase = ch.is_lowercase();
428    }
429
430    result
431}