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