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