veracode_platform/
client.rs

1//! Core Veracode API client implementation.
2//!
3//! This module contains the foundational client for making authenticated requests
4//! to the Veracode API, including HMAC authentication and HTTP request handling.
5
6use hex;
7use hmac::{Hmac, Mac};
8use log::{info, warn};
9use reqwest::{Client, multipart};
10use secrecy::ExposeSecret;
11use serde::Serialize;
12use sha2::Sha256;
13use std::borrow::Cow;
14use std::collections::HashMap;
15use std::fs::File;
16use std::io::{Read, Seek, SeekFrom};
17use std::sync::Arc;
18use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
19use url::Url;
20
21use crate::{VeracodeConfig, VeracodeError};
22
23// Type aliases for HMAC
24type HmacSha256 = Hmac<Sha256>;
25
26// Constants for authentication error messages to avoid repeated allocations
27const INVALID_URL_MSG: &str = "Invalid URL";
28const INVALID_API_KEY_MSG: &str = "Invalid API key format - must be hex string";
29const INVALID_NONCE_MSG: &str = "Invalid nonce format";
30const HMAC_CREATION_FAILED_MSG: &str = "Failed to create HMAC";
31
32/// Core Veracode API client.
33///
34/// This struct provides the foundational HTTP client with HMAC authentication
35/// for making requests to any Veracode API endpoint.
36#[derive(Clone)]
37pub struct VeracodeClient {
38    config: VeracodeConfig,
39    client: Client,
40}
41
42impl VeracodeClient {
43    /// Build URL with query parameters - centralized helper
44    fn build_url_with_params(&self, endpoint: &str, query_params: &[(&str, &str)]) -> String {
45        // Pre-allocate string capacity for better performance
46        let estimated_capacity = self
47            .config
48            .base_url
49            .len()
50            .saturating_add(endpoint.len())
51            .saturating_add(query_params.len().saturating_mul(32)); // Rough estimate for query params
52
53        let mut url = String::with_capacity(estimated_capacity);
54        url.push_str(&self.config.base_url);
55        url.push_str(endpoint);
56
57        if !query_params.is_empty() {
58            url.push('?');
59            for (i, (key, value)) in query_params.iter().enumerate() {
60                if i > 0 {
61                    url.push('&');
62                }
63                url.push_str(&urlencoding::encode(key));
64                url.push('=');
65                url.push_str(&urlencoding::encode(value));
66            }
67        }
68
69        url
70    }
71
72    /// Create a new Veracode API client.
73    ///
74    /// # Arguments
75    ///
76    /// * `config` - Configuration containing API credentials and settings
77    ///
78    /// # Returns
79    ///
80    /// A new `VeracodeClient` instance ready to make API calls.
81    ///
82    /// # Errors
83    ///
84    /// Returns an error if the API request fails, the resource is not found,
85    /// or authentication/authorization fails.
86    pub fn new(config: VeracodeConfig) -> Result<Self, VeracodeError> {
87        let mut client_builder = Client::builder();
88
89        // Use the certificate validation setting from config
90        if !config.validate_certificates {
91            client_builder = client_builder
92                .danger_accept_invalid_certs(true)
93                .danger_accept_invalid_hostnames(true);
94        }
95
96        // Configure HTTP timeouts from config
97        client_builder = client_builder
98            .connect_timeout(Duration::from_secs(config.connect_timeout))
99            .timeout(Duration::from_secs(config.request_timeout));
100
101        // Configure proxy if specified
102        if let Some(proxy_url) = &config.proxy_url {
103            let mut proxy = reqwest::Proxy::all(proxy_url)
104                .map_err(|e| VeracodeError::InvalidConfig(format!("Invalid proxy URL: {e}")))?;
105
106            // Add basic authentication if credentials are provided
107            if let (Some(username), Some(password)) =
108                (&config.proxy_username, &config.proxy_password)
109            {
110                proxy = proxy.basic_auth(username.expose_secret(), password.expose_secret());
111            }
112
113            client_builder = client_builder.proxy(proxy);
114        }
115
116        let client = client_builder.build().map_err(VeracodeError::Http)?;
117        Ok(Self { config, client })
118    }
119
120    /// Get the base URL for API requests.
121    #[must_use]
122    pub fn base_url(&self) -> &str {
123        &self.config.base_url
124    }
125
126    /// Get access to the configuration
127    #[must_use]
128    pub fn config(&self) -> &VeracodeConfig {
129        &self.config
130    }
131
132    /// Get access to the underlying reqwest client
133    #[must_use]
134    pub fn client(&self) -> &Client {
135        &self.client
136    }
137
138    /// Execute an HTTP request with retry logic and exponential backoff.
139    ///
140    /// This method implements the retry strategy defined in the client's configuration.
141    /// It will retry requests that fail due to transient errors (network issues,
142    /// server errors, rate limiting) using exponential backoff. For rate limiting (429),
143    /// it uses intelligent delays based on Veracode's minute-window rate limits.
144    ///
145    /// # Arguments
146    ///
147    ///
148    /// # Errors
149    ///
150    /// Returns an error if the API request fails, the resource is not found,
151    /// or authentication/authorization fails.
152    /// * `request_builder` - A closure that creates the `reqwest::RequestBuilder`
153    /// * `operation_name` - A human-readable name for logging/error messages
154    ///
155    /// # Returns
156    ///
157    ///
158    /// # Errors
159    ///
160    /// Returns an error if the API request fails, the resource is not found,
161    /// or authentication/authorization fails.
162    /// A `Result` containing the HTTP response or a `VeracodeError`.
163    async fn execute_with_retry<F>(
164        &self,
165        request_builder: F,
166        operation_name: Cow<'_, str>,
167    ) -> Result<reqwest::Response, VeracodeError>
168    where
169        F: Fn() -> reqwest::RequestBuilder,
170    {
171        let retry_config = &self.config.retry_config;
172        let start_time = Instant::now();
173        let mut total_delay = std::time::Duration::from_millis(0);
174
175        // If retries are disabled, make a single attempt
176        if retry_config.max_attempts == 0 {
177            return match request_builder().send().await {
178                Ok(response) => Ok(response),
179                Err(e) => Err(VeracodeError::Http(e)),
180            };
181        }
182
183        let mut last_error = None;
184        let mut rate_limit_attempts: u32 = 0;
185
186        for attempt in 1..=retry_config.max_attempts.saturating_add(1) {
187            // Build and send the request
188            match request_builder().send().await {
189                Ok(response) => {
190                    // Check for rate limiting before treating as success
191                    if response.status().as_u16() == 429 {
192                        // Extract Retry-After header if present
193                        let retry_after_seconds = response
194                            .headers()
195                            .get("retry-after")
196                            .and_then(|h| h.to_str().ok())
197                            .and_then(|s| s.parse::<u64>().ok());
198
199                        let message = "HTTP 429: Rate limit exceeded".to_string();
200                        let veracode_error = VeracodeError::RateLimited {
201                            retry_after_seconds,
202                            message,
203                        };
204
205                        // Increment rate limit attempt counter
206                        rate_limit_attempts = rate_limit_attempts.saturating_add(1);
207
208                        // Check if we should retry based on rate limit specific limits
209                        if attempt > retry_config.max_attempts
210                            || rate_limit_attempts > retry_config.rate_limit_max_attempts
211                        {
212                            last_error = Some(veracode_error);
213                            break;
214                        }
215
216                        // Calculate rate limit specific delay
217                        let delay = retry_config.calculate_rate_limit_delay(retry_after_seconds);
218                        total_delay = total_delay.saturating_add(delay);
219
220                        // Check total delay limit
221                        if total_delay.as_millis() > retry_config.max_total_delay_ms as u128 {
222                            let msg = format!(
223                                "{} exceeded maximum total retry time of {}ms after {} attempts",
224                                operation_name, retry_config.max_total_delay_ms, attempt
225                            );
226                            last_error = Some(VeracodeError::RetryExhausted(msg));
227                            break;
228                        }
229
230                        // Log rate limit with specific formatting
231                        let wait_time = match retry_after_seconds {
232                            Some(seconds) => format!("{seconds}s (from Retry-After header)"),
233                            None => format!("{}s (until next minute window)", delay.as_secs()),
234                        };
235                        warn!(
236                            "🚦 {operation_name} rate limited on attempt {attempt}, waiting {wait_time}"
237                        );
238
239                        // Wait and continue to next attempt
240                        tokio::time::sleep(delay).await;
241                        last_error = Some(veracode_error);
242                        continue;
243                    }
244
245                    if attempt > 1 {
246                        // Log successful retry for debugging
247                        info!("✅ {operation_name} succeeded on attempt {attempt}");
248                    }
249                    return Ok(response);
250                }
251                Err(e) => {
252                    // For connection errors, network issues, etc., use normal retry logic
253                    let veracode_error = VeracodeError::Http(e);
254
255                    // Check if this is the last attempt or if the error is not retryable
256                    if attempt > retry_config.max_attempts
257                        || !retry_config.is_retryable_error(&veracode_error)
258                    {
259                        last_error = Some(veracode_error);
260                        break;
261                    }
262
263                    // Use normal exponential backoff for non-429 errors
264                    let delay = retry_config.calculate_delay(attempt);
265                    total_delay = total_delay.saturating_add(delay);
266
267                    // Check if we've exceeded the maximum total delay
268                    if total_delay.as_millis() > retry_config.max_total_delay_ms as u128 {
269                        // Format error message once
270                        let msg = format!(
271                            "{} exceeded maximum total retry time of {}ms after {} attempts",
272                            operation_name, retry_config.max_total_delay_ms, attempt
273                        );
274                        last_error = Some(VeracodeError::RetryExhausted(msg));
275                        break;
276                    }
277
278                    // Log retry attempt for debugging
279                    warn!(
280                        "⚠️  {operation_name} failed on attempt {attempt}, retrying in {}ms: {veracode_error}",
281                        delay.as_millis()
282                    );
283
284                    // Wait before next attempt
285                    tokio::time::sleep(delay).await;
286                    last_error = Some(veracode_error);
287                }
288            }
289        }
290
291        // All attempts failed - create error message efficiently
292        match last_error {
293            Some(error) => {
294                let elapsed = start_time.elapsed();
295                match error {
296                    VeracodeError::RetryExhausted(_) => Err(error),
297                    VeracodeError::Http(_)
298                    | VeracodeError::Serialization(_)
299                    | VeracodeError::Authentication(_)
300                    | VeracodeError::InvalidResponse(_)
301                    | VeracodeError::InvalidConfig(_)
302                    | VeracodeError::NotFound(_)
303                    | VeracodeError::RateLimited { .. }
304                    | VeracodeError::Validation(_) => {
305                        let msg = format!(
306                            "{} failed after {} attempts over {}ms: {}",
307                            operation_name,
308                            retry_config.max_attempts.saturating_add(1),
309                            elapsed.as_millis(),
310                            error
311                        );
312                        Err(VeracodeError::RetryExhausted(msg))
313                    }
314                }
315            }
316            None => {
317                let msg = format!(
318                    "{} failed after {} attempts with unknown error",
319                    operation_name,
320                    retry_config.max_attempts.saturating_add(1)
321                );
322                Err(VeracodeError::RetryExhausted(msg))
323            }
324        }
325    }
326
327    /// Generate HMAC signature for authentication based on official Veracode JavaScript implementation
328    fn generate_hmac_signature(
329        &self,
330        method: &str,
331        url: &str,
332        timestamp: u64,
333        nonce: &str,
334    ) -> Result<String, VeracodeError> {
335        let url_parsed = Url::parse(url)
336            .map_err(|_| VeracodeError::Authentication(INVALID_URL_MSG.to_string()))?;
337
338        let path_and_query = match url_parsed.query() {
339            Some(query) => format!("{}?{}", url_parsed.path(), query),
340            None => url_parsed.path().to_string(),
341        };
342
343        let host = url_parsed.host_str().unwrap_or("");
344
345        // Based on the official Veracode JavaScript implementation:
346        // var data = `id=${id}&host=${host}&url=${url}&method=${method}`;
347        let data = format!(
348            "id={}&host={}&url={}&method={}",
349            self.config.credentials.expose_api_id(),
350            host,
351            path_and_query,
352            method
353        );
354
355        let timestamp_str = timestamp.to_string();
356        let ver_str = "vcode_request_version_1";
357
358        // Convert hex strings to bytes
359        let key_bytes = hex::decode(self.config.credentials.expose_api_key())
360            .map_err(|_| VeracodeError::Authentication(INVALID_API_KEY_MSG.to_string()))?;
361
362        let nonce_bytes = hex::decode(nonce)
363            .map_err(|_| VeracodeError::Authentication(INVALID_NONCE_MSG.to_string()))?;
364
365        // Step 1: HMAC(nonce, key)
366        let mut mac1 = HmacSha256::new_from_slice(&key_bytes)
367            .map_err(|_| VeracodeError::Authentication(HMAC_CREATION_FAILED_MSG.to_string()))?;
368        mac1.update(&nonce_bytes);
369        let hashed_nonce = mac1.finalize().into_bytes();
370
371        // Step 2: HMAC(timestamp, hashed_nonce)
372        let mut mac2 = HmacSha256::new_from_slice(&hashed_nonce)
373            .map_err(|_| VeracodeError::Authentication(HMAC_CREATION_FAILED_MSG.to_string()))?;
374        mac2.update(timestamp_str.as_bytes());
375        let hashed_timestamp = mac2.finalize().into_bytes();
376
377        // Step 3: HMAC(ver_str, hashed_timestamp)
378        let mut mac3 = HmacSha256::new_from_slice(&hashed_timestamp)
379            .map_err(|_| VeracodeError::Authentication(HMAC_CREATION_FAILED_MSG.to_string()))?;
380        mac3.update(ver_str.as_bytes());
381        let hashed_ver_str = mac3.finalize().into_bytes();
382
383        // Step 4: HMAC(data, hashed_ver_str)
384        let mut mac4 = HmacSha256::new_from_slice(&hashed_ver_str)
385            .map_err(|_| VeracodeError::Authentication(HMAC_CREATION_FAILED_MSG.to_string()))?;
386        mac4.update(data.as_bytes());
387        let signature = mac4.finalize().into_bytes();
388
389        // Return the hex-encoded signature (lowercase)
390        Ok(hex::encode(signature).to_lowercase())
391    }
392
393    /// Generate authorization header for HMAC authentication
394    ///
395    /// # Errors
396    ///
397    /// Returns an error if the API request fails, the resource is not found,
398    /// or authentication/authorization fails.
399    pub fn generate_auth_header(&self, method: &str, url: &str) -> Result<String, VeracodeError> {
400        #[allow(clippy::cast_possible_truncation)]
401        let timestamp = SystemTime::now()
402            .duration_since(UNIX_EPOCH)
403            .map_err(|e| VeracodeError::Authentication(format!("System time error: {e}")))?
404            .as_millis() as u64; // Use milliseconds like JavaScript
405
406        // Generate a 16-byte random nonce and convert to hex string
407        let nonce_bytes: [u8; 16] = rand::random();
408        let nonce = hex::encode(nonce_bytes);
409
410        let signature = self.generate_hmac_signature(method, url, timestamp, &nonce)?;
411
412        Ok(format!(
413            "VERACODE-HMAC-SHA-256 id={},ts={},nonce={},sig={}",
414            self.config.credentials.expose_api_id(),
415            timestamp,
416            nonce,
417            signature
418        ))
419    }
420
421    /// Make a GET request to the specified endpoint.
422    ///
423    /// # Arguments
424    ///
425    /// * `endpoint` - The API endpoint path (e.g., "/appsec/v1/applications")
426    /// * `query_params` - Optional query parameters as key-value pairs
427    ///
428    /// # Returns
429    ///
430    /// A `Result` containing the HTTP response.
431    ///
432    /// # Errors
433    ///
434    /// Returns an error if the API request fails, the resource is not found,
435    /// or authentication/authorization fails.
436    pub async fn get(
437        &self,
438        endpoint: &str,
439        query_params: Option<&[(String, String)]>,
440    ) -> Result<reqwest::Response, VeracodeError> {
441        // Pre-allocate URL capacity
442        let param_count = query_params.map_or(0, |p| p.len());
443        let estimated_capacity = self
444            .config
445            .base_url
446            .len()
447            .saturating_add(endpoint.len())
448            .saturating_add(param_count.saturating_mul(32));
449        let mut url = String::with_capacity(estimated_capacity);
450        url.push_str(&self.config.base_url);
451        url.push_str(endpoint);
452
453        if let Some(params) = query_params
454            && !params.is_empty()
455        {
456            url.push('?');
457            for (i, (key, value)) in params.iter().enumerate() {
458                if i > 0 {
459                    url.push('&');
460                }
461                url.push_str(key);
462                url.push('=');
463                url.push_str(value);
464            }
465        }
466
467        // Create request builder closure for retry logic
468        let request_builder = || {
469            // Re-generate auth header for each attempt to avoid signature expiry
470            let Ok(auth_header) = self.generate_auth_header("GET", &url) else {
471                return self.client.get("invalid://url");
472            };
473
474            self.client
475                .get(&url)
476                .header("Authorization", auth_header)
477                .header("Content-Type", "application/json")
478        };
479
480        // Use Cow::Borrowed for simple operations when possible
481        let operation_name = if endpoint.len() < 50 {
482            Cow::Owned(format!("GET {endpoint}"))
483        } else {
484            Cow::Borrowed("GET [long endpoint]")
485        };
486        self.execute_with_retry(request_builder, operation_name)
487            .await
488    }
489
490    /// Make a POST request to the specified endpoint.
491    ///
492    /// # Arguments
493    ///
494    /// * `endpoint` - The API endpoint path (e.g., "/appsec/v1/applications")
495    /// * `body` - Optional request body that implements Serialize
496    ///
497    /// # Returns
498    ///
499    /// A `Result` containing the HTTP response.
500    ///
501    /// # Errors
502    ///
503    /// Returns an error if the API request fails, the resource is not found,
504    /// or authentication/authorization fails.
505    pub async fn post<T: Serialize>(
506        &self,
507        endpoint: &str,
508        body: Option<&T>,
509    ) -> Result<reqwest::Response, VeracodeError> {
510        let mut url =
511            String::with_capacity(self.config.base_url.len().saturating_add(endpoint.len()));
512        url.push_str(&self.config.base_url);
513        url.push_str(endpoint);
514
515        // Serialize body once outside the retry loop for efficiency
516        let serialized_body = if let Some(body) = body {
517            Some(serde_json::to_string(body)?)
518        } else {
519            None
520        };
521
522        // Create request builder closure for retry logic
523        let request_builder = || {
524            // Re-generate auth header for each attempt to avoid signature expiry
525            let Ok(auth_header) = self.generate_auth_header("POST", &url) else {
526                return self.client.post("invalid://url");
527            };
528
529            let mut request = self
530                .client
531                .post(&url)
532                .header("Authorization", auth_header)
533                .header("Content-Type", "application/json");
534
535            if let Some(ref body_str) = serialized_body {
536                request = request.body(body_str.clone());
537            }
538
539            request
540        };
541
542        let operation_name = if endpoint.len() < 50 {
543            Cow::Owned(format!("POST {endpoint}"))
544        } else {
545            Cow::Borrowed("POST [long endpoint]")
546        };
547        self.execute_with_retry(request_builder, operation_name)
548            .await
549    }
550
551    /// Make a PUT request to the specified endpoint.
552    ///
553    /// # Arguments
554    ///
555    /// * `endpoint` - The API endpoint path (e.g., "/appsec/v1/applications/guid")
556    /// * `body` - Optional request body that implements Serialize
557    ///
558    /// # Returns
559    ///
560    /// A `Result` containing the HTTP response.
561    ///
562    /// # Errors
563    ///
564    /// Returns an error if the API request fails, the resource is not found,
565    /// or authentication/authorization fails.
566    pub async fn put<T: Serialize>(
567        &self,
568        endpoint: &str,
569        body: Option<&T>,
570    ) -> Result<reqwest::Response, VeracodeError> {
571        let mut url =
572            String::with_capacity(self.config.base_url.len().saturating_add(endpoint.len()));
573        url.push_str(&self.config.base_url);
574        url.push_str(endpoint);
575
576        // Serialize body once outside the retry loop for efficiency
577        let serialized_body = if let Some(body) = body {
578            Some(serde_json::to_string(body)?)
579        } else {
580            None
581        };
582
583        // Create request builder closure for retry logic
584        let request_builder = || {
585            // Re-generate auth header for each attempt to avoid signature expiry
586            let Ok(auth_header) = self.generate_auth_header("PUT", &url) else {
587                return self.client.put("invalid://url");
588            };
589
590            let mut request = self
591                .client
592                .put(&url)
593                .header("Authorization", auth_header)
594                .header("Content-Type", "application/json");
595
596            if let Some(ref body_str) = serialized_body {
597                request = request.body(body_str.clone());
598            }
599
600            request
601        };
602
603        let operation_name = if endpoint.len() < 50 {
604            Cow::Owned(format!("PUT {endpoint}"))
605        } else {
606            Cow::Borrowed("PUT [long endpoint]")
607        };
608        self.execute_with_retry(request_builder, operation_name)
609            .await
610    }
611
612    /// Make a DELETE request to the specified endpoint.
613    ///
614    /// # Arguments
615    ///
616    /// * `endpoint` - The API endpoint path (e.g., "/appsec/v1/applications/guid")
617    ///
618    /// # Returns
619    ///
620    /// A `Result` containing the HTTP response.
621    ///
622    /// # Errors
623    ///
624    /// Returns an error if the API request fails, the resource is not found,
625    /// or authentication/authorization fails.
626    pub async fn delete(&self, endpoint: &str) -> Result<reqwest::Response, VeracodeError> {
627        let mut url =
628            String::with_capacity(self.config.base_url.len().saturating_add(endpoint.len()));
629        url.push_str(&self.config.base_url);
630        url.push_str(endpoint);
631
632        // Create request builder closure for retry logic
633        let request_builder = || {
634            // Re-generate auth header for each attempt to avoid signature expiry
635            let Ok(auth_header) = self.generate_auth_header("DELETE", &url) else {
636                return self.client.delete("invalid://url");
637            };
638
639            self.client
640                .delete(&url)
641                .header("Authorization", auth_header)
642                .header("Content-Type", "application/json")
643        };
644
645        let operation_name = if endpoint.len() < 50 {
646            Cow::Owned(format!("DELETE {endpoint}"))
647        } else {
648            Cow::Borrowed("DELETE [long endpoint]")
649        };
650        self.execute_with_retry(request_builder, operation_name)
651            .await
652    }
653
654    /// Helper method to handle common response processing.
655    ///
656    /// Checks if the response is successful and returns an error if not.
657    ///
658    /// # Arguments
659    ///
660    /// * `response` - The HTTP response to check
661    /// * `context` - A description of the operation being performed (e.g., "get application")
662    ///
663    /// # Returns
664    ///
665    /// A `Result` containing the response if successful, or an error if not.
666    ///
667    /// # Error Context
668    ///
669    /// This method enhances error messages with context about the failed operation
670    /// to improve debugging and user experience.
671    ///
672    /// # Errors
673    ///
674    /// Returns an error if the API request fails, the resource is not found,
675    /// or authentication/authorization fails.
676    pub async fn handle_response(
677        response: reqwest::Response,
678        context: &str,
679    ) -> Result<reqwest::Response, VeracodeError> {
680        if !response.status().is_success() {
681            let status = response.status();
682            let url = response.url().clone();
683            let error_text = response.text().await?;
684            return Err(VeracodeError::InvalidResponse(format!(
685                "Failed to {context}\n  URL: {url}\n  HTTP {status}: {error_text}"
686            )));
687        }
688        Ok(response)
689    }
690
691    /// Make a GET request with full URL construction and query parameter handling.
692    ///
693    /// This is a higher-level method that builds the full URL and handles query parameters.
694    ///
695    /// # Arguments
696    ///
697    /// * `endpoint` - The API endpoint path
698    /// * `query_params` - Optional query parameters
699    ///
700    /// # Returns
701    ///
702    /// A `Result` containing the HTTP response, pre-processed for success/failure.
703    ///
704    /// # Errors
705    ///
706    /// Returns an error if the API request fails, the resource is not found,
707    /// or authentication/authorization fails.
708    pub async fn get_with_query(
709        &self,
710        endpoint: &str,
711        query_params: Option<Vec<(String, String)>>,
712    ) -> Result<reqwest::Response, VeracodeError> {
713        let query_slice = query_params.as_deref();
714        let response = self.get(endpoint, query_slice).await?;
715        Self::handle_response(response, &format!("GET {endpoint}")).await
716    }
717
718    /// Make a POST request with automatic response handling.
719    ///
720    /// # Arguments
721    ///
722    /// * `endpoint` - The API endpoint path
723    /// * `body` - Optional request body
724    ///
725    /// # Returns
726    ///
727    /// A `Result` containing the HTTP response, pre-processed for success/failure.
728    ///
729    /// # Errors
730    ///
731    /// Returns an error if the API request fails, the resource is not found,
732    /// or authentication/authorization fails.
733    pub async fn post_with_response<T: Serialize>(
734        &self,
735        endpoint: &str,
736        body: Option<&T>,
737    ) -> Result<reqwest::Response, VeracodeError> {
738        let response = self.post(endpoint, body).await?;
739        Self::handle_response(response, &format!("POST {endpoint}")).await
740    }
741
742    /// Make a PUT request with automatic response handling.
743    ///
744    /// # Arguments
745    ///
746    /// * `endpoint` - The API endpoint path
747    /// * `body` - Optional request body
748    ///
749    /// # Returns
750    ///
751    /// A `Result` containing the HTTP response, pre-processed for success/failure.
752    ///
753    /// # Errors
754    ///
755    /// Returns an error if the API request fails, the resource is not found,
756    /// or authentication/authorization fails.
757    pub async fn put_with_response<T: Serialize>(
758        &self,
759        endpoint: &str,
760        body: Option<&T>,
761    ) -> Result<reqwest::Response, VeracodeError> {
762        let response = self.put(endpoint, body).await?;
763        Self::handle_response(response, &format!("PUT {endpoint}")).await
764    }
765
766    /// Make a DELETE request with automatic response handling.
767    ///
768    /// # Arguments
769    ///
770    /// * `endpoint` - The API endpoint path
771    ///
772    /// # Returns
773    ///
774    /// A `Result` containing the HTTP response, pre-processed for success/failure.
775    ///
776    /// # Errors
777    ///
778    /// Returns an error if the API request fails, the resource is not found,
779    /// or authentication/authorization fails.
780    pub async fn delete_with_response(
781        &self,
782        endpoint: &str,
783    ) -> Result<reqwest::Response, VeracodeError> {
784        let response = self.delete(endpoint).await?;
785        Self::handle_response(response, &format!("DELETE {endpoint}")).await
786    }
787
788    /// Make paginated GET requests to collect all results.
789    ///
790    /// This method automatically handles pagination by making multiple requests
791    /// and combining all results into a single response.
792    ///
793    /// # Arguments
794    ///
795    /// * `endpoint` - The API endpoint path
796    /// * `base_query_params` - Base query parameters (non-pagination)
797    /// * `page_size` - Number of items per page (default: 500)
798    ///
799    /// # Returns
800    ///
801    /// A `Result` containing all paginated results as a single response body string.
802    ///
803    /// # Errors
804    ///
805    /// Returns an error if the API request fails, the resource is not found,
806    /// or authentication/authorization fails.
807    pub async fn get_paginated(
808        &self,
809        endpoint: &str,
810        base_query_params: Option<Vec<(String, String)>>,
811        page_size: Option<u32>,
812    ) -> Result<String, VeracodeError> {
813        let size = page_size.unwrap_or(500);
814        let mut page: u32 = 0;
815        let mut all_items = Vec::new();
816        let mut page_info = None;
817
818        loop {
819            let mut query_params = base_query_params.clone().unwrap_or_default();
820            query_params.push(("page".to_string(), page.to_string()));
821            query_params.push(("size".to_string(), size.to_string()));
822
823            let response = self.get_with_query(endpoint, Some(query_params)).await?;
824            let response_text = response.text().await?;
825
826            // Try to parse as JSON to extract items and pagination info
827            if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(&response_text) {
828                // Handle embedded response format
829                if let Some(embedded) = json_value.get("_embedded") {
830                    if let Some(items_array) =
831                        embedded.as_object().and_then(|obj| obj.values().next())
832                        && let Some(items) = items_array.as_array()
833                    {
834                        if items.is_empty() {
835                            break; // No more items
836                        }
837                        all_items.extend(items.clone());
838                    }
839                } else if let Some(items) = json_value.as_array() {
840                    // Handle direct array response
841                    if items.is_empty() {
842                        break;
843                    }
844                    all_items.extend(items.clone());
845                } else {
846                    // Single page response, return as-is
847                    return Ok(response_text);
848                }
849
850                // Check pagination info
851                if let Some(page_obj) = json_value.get("page") {
852                    page_info = Some(page_obj.clone());
853                    if let (Some(current), Some(total)) = (
854                        page_obj.get("number").and_then(|n| n.as_u64()),
855                        page_obj.get("totalPages").and_then(|n| n.as_u64()),
856                    ) && current.saturating_add(1) >= total
857                    {
858                        break; // Last page reached
859                    }
860                }
861            } else {
862                // Not JSON or parsing failed, return single response
863                return Ok(response_text);
864            }
865
866            page = page.saturating_add(1);
867
868            // Safety check to prevent infinite loops
869            if page > 100 {
870                break;
871            }
872        }
873
874        // Reconstruct response with all items
875        let combined_response = if let Some(page_info) = page_info {
876            // Use embedded format
877            serde_json::json!({
878                "_embedded": {
879                    "roles": all_items // This key might need to be dynamic
880                },
881                "page": page_info
882            })
883        } else {
884            // Use direct array format
885            serde_json::Value::Array(all_items)
886        };
887
888        Ok(combined_response.to_string())
889    }
890
891    /// Make a GET request with query parameters
892    ///
893    /// # Arguments
894    ///
895    /// * `endpoint` - The API endpoint to call
896    /// * `params` - Query parameters as a slice of tuples
897    ///
898    /// # Returns
899    ///
900    /// A `Result` containing the response or an error.
901    ///
902    /// # Errors
903    ///
904    /// Returns an error if the API request fails, the resource is not found,
905    /// or authentication/authorization fails.
906    pub async fn get_with_params(
907        &self,
908        endpoint: &str,
909        params: &[(&str, &str)],
910    ) -> Result<reqwest::Response, VeracodeError> {
911        let mut url =
912            String::with_capacity(self.config.base_url.len().saturating_add(endpoint.len()));
913        url.push_str(&self.config.base_url);
914        url.push_str(endpoint);
915        let mut request_url =
916            Url::parse(&url).map_err(|e| VeracodeError::InvalidConfig(e.to_string()))?;
917
918        // Add query parameters
919        if !params.is_empty() {
920            let mut query_pairs = request_url.query_pairs_mut();
921            for (key, value) in params {
922                query_pairs.append_pair(key, value);
923            }
924        }
925
926        let auth_header = self.generate_auth_header("GET", request_url.as_str())?;
927
928        let response = self
929            .client
930            .get(request_url)
931            .header("Authorization", auth_header)
932            .header("User-Agent", "Veracode Rust Client")
933            .send()
934            .await?;
935
936        Ok(response)
937    }
938
939    /// Make a POST request with form data
940    ///
941    /// # Arguments
942    ///
943    /// * `endpoint` - The API endpoint to call
944    /// * `params` - Form parameters as a slice of tuples
945    ///
946    /// # Returns
947    ///
948    /// A `Result` containing the response or an error.
949    ///
950    /// # Errors
951    ///
952    /// Returns an error if the API request fails, the resource is not found,
953    /// or authentication/authorization fails.
954    pub async fn post_form(
955        &self,
956        endpoint: &str,
957        params: &[(&str, &str)],
958    ) -> Result<reqwest::Response, VeracodeError> {
959        let mut url =
960            String::with_capacity(self.config.base_url.len().saturating_add(endpoint.len()));
961        url.push_str(&self.config.base_url);
962        url.push_str(endpoint);
963
964        // Build form data - avoid unnecessary allocations
965        let form_data: Vec<(&str, &str)> = params.to_vec();
966
967        let auth_header = self.generate_auth_header("POST", &url)?;
968
969        let response = self
970            .client
971            .post(&url)
972            .header("Authorization", auth_header)
973            .header("User-Agent", "Veracode Rust Client")
974            .form(&form_data)
975            .send()
976            .await?;
977
978        Ok(response)
979    }
980
981    /// Upload a file using multipart form data
982    ///
983    /// # Arguments
984    ///
985    /// * `endpoint` - The API endpoint to call
986    /// * `params` - Additional form parameters
987    /// * `file_field_name` - Name of the file field
988    /// * `filename` - Name of the file
989    /// * `file_data` - File data as bytes
990    ///
991    /// # Returns
992    ///
993    /// A `Result` containing the response or an error.
994    ///
995    /// # Errors
996    ///
997    /// Returns an error if the API request fails, the resource is not found,
998    /// or authentication/authorization fails.
999    pub async fn upload_file_multipart(
1000        &self,
1001        endpoint: &str,
1002        params: HashMap<&str, &str>,
1003        file_field_name: &str,
1004        filename: &str,
1005        file_data: Vec<u8>,
1006    ) -> Result<reqwest::Response, VeracodeError> {
1007        let mut url =
1008            String::with_capacity(self.config.base_url.len().saturating_add(endpoint.len()));
1009        url.push_str(&self.config.base_url);
1010        url.push_str(endpoint);
1011
1012        // Build multipart form
1013        let mut form = multipart::Form::new();
1014
1015        // Add regular form fields
1016        for (key, value) in params {
1017            form = form.text(key.to_string(), value.to_string());
1018        }
1019
1020        // Add file
1021        let part = multipart::Part::bytes(file_data)
1022            .file_name(filename.to_string())
1023            .mime_str("application/octet-stream")
1024            .map_err(|e| VeracodeError::InvalidConfig(e.to_string()))?;
1025
1026        form = form.part(file_field_name.to_string(), part);
1027
1028        let auth_header = self.generate_auth_header("POST", &url)?;
1029
1030        let response = self
1031            .client
1032            .post(&url)
1033            .header("Authorization", auth_header)
1034            .header("User-Agent", "Veracode Rust Client")
1035            .multipart(form)
1036            .send()
1037            .await?;
1038
1039        Ok(response)
1040    }
1041
1042    /// Upload a file using multipart form data with PUT method (for pipeline scans)
1043    ///
1044    /// # Arguments
1045    ///
1046    /// * `url` - The full URL to upload to
1047    /// * `file_field_name` - Name of the file field
1048    /// * `filename` - Name of the file
1049    /// * `file_data` - File data as bytes
1050    /// * `additional_headers` - Additional headers to include
1051    ///
1052    /// # Returns
1053    ///
1054    /// A `Result` containing the response or an error.
1055    ///
1056    /// # Errors
1057    ///
1058    /// Returns an error if the API request fails, the resource is not found,
1059    /// or authentication/authorization fails.
1060    pub async fn upload_file_multipart_put(
1061        &self,
1062        url: &str,
1063        file_field_name: &str,
1064        filename: &str,
1065        file_data: Vec<u8>,
1066        additional_headers: Option<HashMap<&str, &str>>,
1067    ) -> Result<reqwest::Response, VeracodeError> {
1068        // Build multipart form
1069        let part = multipart::Part::bytes(file_data)
1070            .file_name(filename.to_string())
1071            .mime_str("application/octet-stream")
1072            .map_err(|e| VeracodeError::InvalidConfig(e.to_string()))?;
1073
1074        let form = multipart::Form::new().part(file_field_name.to_string(), part);
1075
1076        let auth_header = self.generate_auth_header("PUT", url)?;
1077
1078        let mut request = self
1079            .client
1080            .put(url)
1081            .header("Authorization", auth_header)
1082            .header("User-Agent", "Veracode Rust Client")
1083            .multipart(form);
1084
1085        // Add any additional headers
1086        if let Some(headers) = additional_headers {
1087            for (key, value) in headers {
1088                request = request.header(key, value);
1089            }
1090        }
1091
1092        let response = request.send().await?;
1093        Ok(response)
1094    }
1095
1096    /// Upload a file with query parameters (like Java implementation)
1097    ///
1098    /// This method mimics the Java API wrapper's approach where parameters
1099    /// are added to the query string and the file is uploaded separately.
1100    ///
1101    /// Memory optimization: Uses Cow for strings and Arc for file data to minimize cloning
1102    /// during retry attempts. Automatically retries on transient failures.
1103    ///
1104    /// # Arguments
1105    ///
1106    /// * `endpoint` - The API endpoint to call
1107    /// * `query_params` - Query parameters as key-value pairs
1108    /// * `file_field_name` - Name of the file field
1109    /// * `filename` - Name of the file
1110    /// * `file_data` - File data as bytes
1111    ///
1112    /// # Returns
1113    ///
1114    /// A `Result` containing the response or an error.
1115    ///
1116    /// # Errors
1117    ///
1118    /// Returns an error if the API request fails, the resource is not found,
1119    /// or authentication/authorization fails.
1120    pub async fn upload_file_with_query_params(
1121        &self,
1122        endpoint: &str,
1123        query_params: &[(&str, &str)],
1124        file_field_name: &str,
1125        filename: &str,
1126        file_data: Vec<u8>,
1127    ) -> Result<reqwest::Response, VeracodeError> {
1128        // Build URL with query parameters using centralized helper for consistency
1129        let url = self.build_url_with_params(endpoint, query_params);
1130
1131        // Wrap file data in Arc to avoid cloning during retries
1132        let file_data_arc = Arc::new(file_data);
1133
1134        // Use Cow for strings to minimize allocations - borrow for short strings, own for long ones
1135        let filename_cow: Cow<str> = if filename.len() < 128 {
1136            Cow::Borrowed(filename)
1137        } else {
1138            Cow::Owned(filename.to_string())
1139        };
1140
1141        let field_name_cow: Cow<str> = if file_field_name.len() < 32 {
1142            Cow::Borrowed(file_field_name)
1143        } else {
1144            Cow::Owned(file_field_name.to_string())
1145        };
1146
1147        // Create request builder closure for retry logic
1148        let request_builder = || {
1149            // Clone Arc (cheap - just increments reference count)
1150            let file_data_clone = Arc::clone(&file_data_arc);
1151
1152            // Re-create multipart form for each attempt
1153            let Ok(part) = multipart::Part::bytes((*file_data_clone).clone())
1154                .file_name(filename_cow.to_string())
1155                .mime_str("application/octet-stream")
1156            else {
1157                return self.client.post("invalid://url");
1158            };
1159
1160            let form = multipart::Form::new().part(field_name_cow.to_string(), part);
1161
1162            // Re-generate auth header for each attempt to avoid signature expiry
1163            let Ok(auth_header) = self.generate_auth_header("POST", &url) else {
1164                return self.client.post("invalid://url");
1165            };
1166
1167            self.client
1168                .post(&url)
1169                .header("Authorization", auth_header)
1170                .header("User-Agent", "Veracode Rust Client")
1171                .multipart(form)
1172        };
1173
1174        // Use Cow for operation name based on endpoint length to minimize allocations
1175        let operation_name: Cow<str> = if endpoint.len() < 50 {
1176            Cow::Owned(format!("File Upload POST {endpoint}"))
1177        } else {
1178            Cow::Borrowed("File Upload POST [long endpoint]")
1179        };
1180
1181        self.execute_with_retry(request_builder, operation_name)
1182            .await
1183    }
1184
1185    /// Make a POST request with query parameters (like Java implementation for XML API)
1186    ///
1187    /// This method mimics the Java API wrapper's approach for POST operations
1188    /// where parameters are added to the query string rather than form data.
1189    ///
1190    /// # Arguments
1191    ///
1192    /// * `endpoint` - The API endpoint to call
1193    /// * `query_params` - Query parameters as key-value pairs
1194    ///
1195    /// # Returns
1196    ///
1197    /// A `Result` containing the response or an error.
1198    ///
1199    /// # Errors
1200    ///
1201    /// Returns an error if the API request fails, the resource is not found,
1202    /// or authentication/authorization fails.
1203    pub async fn post_with_query_params(
1204        &self,
1205        endpoint: &str,
1206        query_params: &[(&str, &str)],
1207    ) -> Result<reqwest::Response, VeracodeError> {
1208        // Build URL with query parameters using centralized helper
1209        let url = self.build_url_with_params(endpoint, query_params);
1210
1211        let auth_header = self.generate_auth_header("POST", &url)?;
1212
1213        let response = self
1214            .client
1215            .post(&url)
1216            .header("Authorization", auth_header)
1217            .header("User-Agent", "Veracode Rust Client")
1218            .send()
1219            .await?;
1220
1221        Ok(response)
1222    }
1223
1224    /// Make a GET request with query parameters (like Java implementation for XML API)
1225    ///
1226    /// This method mimics the Java API wrapper's approach for GET operations
1227    /// where parameters are added to the query string.
1228    ///
1229    /// # Arguments
1230    ///
1231    /// * `endpoint` - The API endpoint to call
1232    /// * `query_params` - Query parameters as key-value pairs
1233    ///
1234    /// # Returns
1235    ///
1236    /// A `Result` containing the response or an error.
1237    ///
1238    /// # Errors
1239    ///
1240    /// Returns an error if the API request fails, the resource is not found,
1241    /// or authentication/authorization fails.
1242    pub async fn get_with_query_params(
1243        &self,
1244        endpoint: &str,
1245        query_params: &[(&str, &str)],
1246    ) -> Result<reqwest::Response, VeracodeError> {
1247        // Build URL with query parameters using centralized helper
1248        let url = self.build_url_with_params(endpoint, query_params);
1249
1250        let auth_header = self.generate_auth_header("GET", &url)?;
1251
1252        let response = self
1253            .client
1254            .get(&url)
1255            .header("Authorization", auth_header)
1256            .header("User-Agent", "Veracode Rust Client")
1257            .send()
1258            .await?;
1259
1260        Ok(response)
1261    }
1262
1263    /// Upload a large file using chunked streaming (for uploadlargefile.do)
1264    ///
1265    /// This method implements chunked upload functionality similar to the Java API wrapper.
1266    /// It uploads files in chunks and provides progress tracking capabilities.
1267    ///
1268    /// # Arguments
1269    ///
1270    /// * `endpoint` - The API endpoint to call  
1271    /// * `query_params` - Query parameters as key-value pairs
1272    /// * `file_path` - Path to the file to upload
1273    /// * `content_type` - Content type for the file (default: binary/octet-stream)
1274    /// * `progress_callback` - Optional callback for progress tracking
1275    ///
1276    /// # Returns
1277    ///
1278    /// A `Result` containing the response or an error.
1279    ///
1280    /// # Errors
1281    ///
1282    /// Returns an error if the API request fails, the resource is not found,
1283    /// or authentication/authorization fails.
1284    pub async fn upload_large_file_chunked<F>(
1285        &self,
1286        endpoint: &str,
1287        query_params: &[(&str, &str)],
1288        file_path: &str,
1289        content_type: Option<&str>,
1290        progress_callback: Option<F>,
1291    ) -> Result<reqwest::Response, VeracodeError>
1292    where
1293        F: Fn(u64, u64, f64) + Send + Sync,
1294    {
1295        // Build URL with query parameters using centralized helper
1296        let url = self.build_url_with_params(endpoint, query_params);
1297
1298        // Open file and get size
1299        let mut file = File::open(file_path)
1300            .map_err(|e| VeracodeError::InvalidConfig(format!("Failed to open file: {e}")))?;
1301
1302        let file_size = file
1303            .metadata()
1304            .map_err(|e| VeracodeError::InvalidConfig(format!("Failed to get file size: {e}")))?
1305            .len();
1306
1307        // Check file size limit (2GB for uploadlargefile.do)
1308        #[allow(clippy::arithmetic_side_effects)]
1309        const MAX_FILE_SIZE: u64 = 2 * 1024 * 1024 * 1024; // 2GB
1310        if file_size > MAX_FILE_SIZE {
1311            return Err(VeracodeError::InvalidConfig(format!(
1312                "File size ({file_size} bytes) exceeds maximum limit of {MAX_FILE_SIZE} bytes"
1313            )));
1314        }
1315
1316        // Read entire file for now (can be optimized to streaming later)
1317        file.seek(SeekFrom::Start(0))
1318            .map_err(|e| VeracodeError::InvalidConfig(format!("Failed to seek file: {e}")))?;
1319
1320        #[allow(clippy::cast_possible_truncation)]
1321        let mut file_data = Vec::with_capacity(file_size as usize);
1322        file.read_to_end(&mut file_data)
1323            .map_err(|e| VeracodeError::InvalidConfig(format!("Failed to read file: {e}")))?;
1324
1325        // Memory optimization: Wrap file data in Arc to avoid cloning during retries
1326        let file_data_arc = Arc::new(file_data);
1327        let content_type_cow: Cow<str> =
1328            content_type.map_or(Cow::Borrowed("binary/octet-stream"), |ct| {
1329                if ct.len() < 64 {
1330                    Cow::Borrowed(ct)
1331                } else {
1332                    Cow::Owned(ct.to_string())
1333                }
1334            });
1335
1336        // Create request builder closure for retry logic
1337        let request_builder = || {
1338            // Clone Arc (cheap - just increments reference count)
1339            let file_data_clone = Arc::clone(&file_data_arc);
1340
1341            // Re-generate auth header for each attempt to avoid signature expiry
1342            let Ok(auth_header) = self.generate_auth_header("POST", &url) else {
1343                return self.client.post("invalid://url");
1344            };
1345
1346            self.client
1347                .post(&url)
1348                .header("Authorization", auth_header)
1349                .header("User-Agent", "Veracode Rust Client")
1350                .header("Content-Type", content_type_cow.as_ref())
1351                .header("Content-Length", file_size.to_string())
1352                .body((*file_data_clone).clone())
1353        };
1354
1355        // Track progress if callback provided (do this before retry loop)
1356        if let Some(callback) = progress_callback {
1357            callback(file_size, file_size, 100.0);
1358        }
1359
1360        // Use optimized operation name
1361        let operation_name: Cow<str> = if endpoint.len() < 50 {
1362            Cow::Owned(format!("Large File Upload POST {endpoint}"))
1363        } else {
1364            Cow::Borrowed("Large File Upload POST [long endpoint]")
1365        };
1366
1367        self.execute_with_retry(request_builder, operation_name)
1368            .await
1369    }
1370
1371    /// Upload a file with binary data (optimized for uploadlargefile.do)
1372    ///
1373    /// This method uploads a file as raw binary data without multipart encoding,
1374    /// which is the expected format for the uploadlargefile.do endpoint.
1375    ///
1376    /// Memory optimization: Uses Arc for file data and Cow for strings to minimize
1377    /// allocations during retry attempts. Automatically retries on transient failures.
1378    ///
1379    /// # Arguments
1380    ///
1381    /// * `endpoint` - The API endpoint to call
1382    /// * `query_params` - Query parameters as key-value pairs  
1383    /// * `file_data` - File data as bytes
1384    /// * `content_type` - Content type for the file
1385    ///
1386    /// # Returns
1387    ///
1388    /// A `Result` containing the response or an error.
1389    ///
1390    /// # Errors
1391    ///
1392    /// Returns an error if the API request fails, the resource is not found,
1393    /// or authentication/authorization fails.
1394    pub async fn upload_file_binary(
1395        &self,
1396        endpoint: &str,
1397        query_params: &[(&str, &str)],
1398        file_data: Vec<u8>,
1399        content_type: &str,
1400    ) -> Result<reqwest::Response, VeracodeError> {
1401        // Build URL with query parameters using centralized helper
1402        let url = self.build_url_with_params(endpoint, query_params);
1403
1404        // Memory optimization: Wrap file data in Arc to avoid cloning during retries
1405        let file_data_arc = Arc::new(file_data);
1406        let file_size = file_data_arc.len();
1407
1408        // Use Cow for content type to minimize allocations
1409        let content_type_cow: Cow<str> = if content_type.len() < 64 {
1410            Cow::Borrowed(content_type)
1411        } else {
1412            Cow::Owned(content_type.to_string())
1413        };
1414
1415        // Create request builder closure for retry logic
1416        let request_builder = || {
1417            // Clone Arc (cheap - just increments reference count)
1418            let file_data_clone = Arc::clone(&file_data_arc);
1419
1420            // Re-generate auth header for each attempt to avoid signature expiry
1421            let Ok(auth_header) = self.generate_auth_header("POST", &url) else {
1422                return self.client.post("invalid://url");
1423            };
1424
1425            self.client
1426                .post(&url)
1427                .header("Authorization", auth_header)
1428                .header("User-Agent", "Veracode Rust Client")
1429                .header("Content-Type", content_type_cow.as_ref())
1430                .header("Content-Length", file_size.to_string())
1431                .body((*file_data_clone).clone())
1432        };
1433
1434        // Use optimized operation name based on endpoint length
1435        let operation_name: Cow<str> = if endpoint.len() < 50 {
1436            Cow::Owned(format!("Binary File Upload POST {endpoint}"))
1437        } else {
1438            Cow::Borrowed("Binary File Upload POST [long endpoint]")
1439        };
1440
1441        self.execute_with_retry(request_builder, operation_name)
1442            .await
1443    }
1444}