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