inf_circle_sdk/helper.rs
1//! Shared helper functions and types used across the SDK
2//!
3//! This module provides common utilities, error types, HTTP client functionality,
4//! and cryptographic helpers used throughout the Circle SDK.
5//!
6//! # Main Components
7//!
8//! - [`CircleError`]: Comprehensive error type for all SDK operations
9//! - [`CircleResult`]: Type alias for `Result<T, CircleError>`
10//! - [`HttpClient`]: Configured HTTP client for Circle API requests
11//! - [`encrypt_entity_secret`]: RSA-OAEP encryption for entity secrets
12//! - Serialization helpers for API compatibility
13//!
14//! # Error Handling
15//!
16//! All SDK operations return `CircleResult<T>` which provides detailed error information:
17//!
18//! ```rust
19//! use inf_circle_sdk::helper::{CircleError, CircleResult};
20//!
21//! fn example_function() -> CircleResult<String> {
22//! // Environment variable errors
23//! // HTTP request errors
24//! // JSON parsing errors
25//! // API errors with status codes
26//! // etc.
27//! Ok("Success".to_string())
28//! }
29//! ```
30
31use chrono::{DateTime, Utc};
32use reqwest::{Client, Method, RequestBuilder, Response};
33use serde::{Deserialize, Serialize, Serializer};
34use std::collections::HashMap;
35use thiserror::Error;
36use url::Url;
37
38// Cryptography imports
39use anyhow::{anyhow, Result as AnyhowResult};
40use base64::{engine::general_purpose, Engine};
41use rsa::{pkcs1::DecodeRsaPublicKey, pkcs8::DecodePublicKey, Oaep, RsaPublicKey};
42use sha2::Sha256;
43
44/// Result type alias for Circle SDK operations
45pub type CircleResult<T> = Result<T, CircleError>;
46
47/// Comprehensive error type for Circle SDK operations
48///
49/// This enum covers all possible errors that can occur when using the Circle SDK,
50/// including environment configuration errors, HTTP request failures, JSON parsing issues,
51/// and API-specific errors with status codes.
52///
53/// # Variants
54///
55/// - `EnvVar`: Missing or invalid environment variables
56/// - `Http`: HTTP request failures (network errors, timeouts, etc.)
57/// - `Json`: JSON serialization/deserialization errors
58/// - `Url`: URL parsing errors
59/// - `Api`: Circle API errors with HTTP status code and message
60/// - `Config`: Invalid SDK configuration
61/// - `Uuid`: UUID parsing or generation errors
62#[derive(Error, Debug)]
63pub enum CircleError {
64 #[error("Environment variable error: {0}")]
65 EnvVar(String),
66
67 #[error("HTTP request error: {0}")]
68 Http(#[from] reqwest::Error),
69
70 #[error("JSON serialization/deserialization error: {0}")]
71 Json(#[from] serde_json::Error),
72
73 #[error("URL parsing error: {0}")]
74 Url(#[from] url::ParseError),
75
76 #[error("API error: {status} - {message}")]
77 Api { status: u16, message: String },
78
79 #[error("Invalid configuration: {0}")]
80 Config(String),
81
82 #[error("UUID error: {0}")]
83 Uuid(#[from] uuid::Error),
84}
85
86/// Standard Circle API response wrapper
87#[derive(Debug, Deserialize, Serialize)]
88pub struct CircleResponse<T> {
89 pub data: T,
90}
91
92/// Standard Circle API error response
93#[derive(Debug, Deserialize, Serialize)]
94pub struct CircleErrorResponse {
95 pub code: Option<i32>,
96 pub message: String,
97}
98
99/// Helper function to serialize u32 as string
100pub fn serialize_u32_as_string<S>(value: &Option<u32>, serializer: S) -> Result<S::Ok, S::Error>
101where
102 S: Serializer,
103{
104 match value {
105 Some(val) => serializer.serialize_str(&val.to_string()),
106 None => serializer.serialize_none(),
107 }
108}
109
110/// Helper function to serialize DateTime as string
111pub fn serialize_datetime_as_string<S>(
112 dt: &Option<DateTime<Utc>>,
113 serializer: S,
114) -> Result<S::Ok, S::Error>
115where
116 S: Serializer,
117{
118 match dt {
119 Some(dt) => serializer.serialize_str(&dt.to_rfc3339()),
120 None => serializer.serialize_none(),
121 }
122}
123
124pub fn serialize_bool_as_string<S>(value: &Option<bool>, serializer: S) -> Result<S::Ok, S::Error>
125where
126 S: Serializer,
127{
128 match value {
129 Some(val) => serializer.serialize_str(&val.to_string()),
130 None => serializer.serialize_none(),
131 }
132}
133
134/// Common query parameters for pagination
135#[derive(Debug, Serialize, Default, Clone, Deserialize)]
136pub struct PaginationParams {
137 #[serde(rename = "pageAfter", skip_serializing_if = "Option::is_none")]
138 pub page_after: Option<String>,
139
140 #[serde(rename = "pageBefore", skip_serializing_if = "Option::is_none")]
141 pub page_before: Option<String>,
142
143 #[serde(
144 rename = "pageSize",
145 skip_serializing_if = "Option::is_none",
146 serialize_with = "serialize_u32_as_string"
147 )]
148 pub page_size: Option<u32>,
149}
150
151/// HTTP client wrapper with common functionality
152///
153/// Handles HTTP requests to the Circle API with automatic header management,
154/// authentication, and response parsing.
155#[derive(Clone)]
156pub struct HttpClient {
157 client: Client,
158 base_url: Url,
159 api_key: Option<String>,
160}
161
162impl HttpClient {
163 /// Create a new HTTP client with base URL
164 pub fn new(base_url: &str) -> CircleResult<Self> {
165 let client = Client::new();
166 let base_url = Url::parse(base_url)?;
167
168 Ok(Self {
169 client,
170 base_url,
171 api_key: None,
172 })
173 }
174
175 /// Create a new HTTP client with base URL and API key
176 pub fn with_api_key(base_url: &str, api_key: String) -> CircleResult<Self> {
177 let mut client = Self::new(base_url)?;
178 client.api_key = Some(api_key);
179 Ok(client)
180 }
181
182 /// Build a request with common headers
183 pub fn request(&self, method: Method, path: &str) -> CircleResult<RequestBuilder> {
184 let url = self.base_url.join(path)?;
185 let mut request = self.client.request(method, url);
186
187 // Add common headers
188 request = request.header("Content-Type", "application/json");
189
190 // Add authorization header if API key is available
191 if let Some(ref api_key) = self.api_key {
192 request = request.header("Authorization", format!("Bearer {}", api_key));
193 }
194
195 Ok(request)
196 }
197
198 /// Execute a request and handle the response
199 pub async fn execute<T>(&self, request: RequestBuilder) -> CircleResult<T>
200 where
201 T: for<'de> Deserialize<'de>,
202 {
203 let response = request.send().await?;
204 self.handle_response(response).await
205 }
206
207 /// Handle HTTP response and convert to typed result
208 async fn handle_response<T>(&self, response: Response) -> CircleResult<T>
209 where
210 T: for<'de> Deserialize<'de>,
211 {
212 let status = response.status();
213 let response_text = response.text().await?;
214
215 if status.is_success() {
216 let circle_response: CircleResponse<T> = serde_json::from_str(&response_text)?;
217 Ok(circle_response.data)
218 } else {
219 // Try to parse error response
220 let error_message = match serde_json::from_str::<CircleErrorResponse>(&response_text) {
221 Ok(error_resp) => error_resp.message,
222 Err(_) => response_text,
223 };
224
225 Err(CircleError::Api {
226 status: status.as_u16(),
227 message: error_message,
228 })
229 }
230 }
231}
232
233/// Helper function to read environment variable
234///
235/// Reads an environment variable and returns its value, or an error if it's not set.
236///
237/// # Arguments
238///
239/// * `name` - The name of the environment variable to read
240///
241/// # Returns
242///
243/// Returns the environment variable value on success.
244///
245/// # Errors
246///
247/// Returns `CircleError::EnvVar` if the environment variable is not set.
248///
249/// # Example
250///
251/// ```rust,no_run
252/// use inf_circle_sdk::helper::get_env_var;
253///
254/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
255/// let api_key = get_env_var("CIRCLE_API_KEY")?;
256/// println!("API Key: {}", api_key);
257/// # Ok(())
258/// # }
259/// ```
260pub fn get_env_var(name: &str) -> CircleResult<String> {
261 std::env::var(name)
262 .map_err(|_| CircleError::EnvVar(format!("Missing environment variable: {}", name)))
263}
264
265/// Helper function to build query string from parameters
266///
267/// Serializes a struct into a URL-encoded query string, filtering out empty values.
268///
269/// # Arguments
270///
271/// * `params` - A serializable struct containing query parameters
272///
273/// # Returns
274///
275/// Returns a URL-encoded query string (e.g., "key1=value1&key2=value2").
276///
277/// # Example
278///
279/// ```rust,no_run
280/// use inf_circle_sdk::helper::build_query_params;
281/// use serde::Serialize;
282///
283/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
284/// #[derive(Serialize)]
285/// struct Params {
286/// page_size: u32,
287/// blockchain: String,
288/// }
289///
290/// let params = Params {
291/// page_size: 10,
292/// blockchain: "ETH-SEPOLIA".to_string(),
293/// };
294///
295/// let query = build_query_params(¶ms)?;
296/// println!("Query string: {}", query); // "page_size=10&blockchain=ETH-SEPOLIA"
297/// # Ok(())
298/// # }
299/// ```
300pub fn build_query_params<T: Serialize>(params: &T) -> CircleResult<String> {
301 let query_map: HashMap<String, String> = serde_json::from_value(serde_json::to_value(params)?)?;
302 let query_pairs: Vec<String> = query_map
303 .into_iter()
304 .filter(|(_, v)| !v.is_empty())
305 .map(|(k, v)| format!("{}={}", urlencoding::encode(&k), urlencoding::encode(&v)))
306 .collect();
307
308 Ok(query_pairs.join("&"))
309}
310
311/// Helper function to generate UUID v4
312///
313/// Generates a new UUID v4 (random UUID) and returns it as a string.
314///
315/// # Returns
316///
317/// Returns a UUID string in the format "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx".
318///
319/// # Example
320///
321/// ```rust,no_run
322/// use inf_circle_sdk::helper::generate_uuid;
323///
324/// # fn example() {
325/// let idempotency_key = generate_uuid();
326/// println!("Generated UUID: {}", idempotency_key);
327/// // Example output: "550e8400-e29b-41d4-a716-446655440000"
328/// # }
329/// ```
330pub fn generate_uuid() -> String {
331 uuid::Uuid::new_v4().to_string()
332}
333
334/// Encrypts entity secret using RSA-OAEP with SHA-256
335///
336/// This function takes a hex-encoded entity secret and encrypts it using the provided
337/// RSA public key in PEM format. The result is base64-encoded.
338///
339/// # Arguments
340/// * `entity_secret_hex` - The entity secret as a hex string
341/// * `public_key_pem` - The RSA public key in PEM format (PKCS#1 or PKCS#8)
342///
343/// # Returns
344/// * `Result<String>` - Base64-encoded encrypted data on success
345///
346/// # Example
347///
348/// ```rust,no_run
349/// use inf_circle_sdk::helper::encrypt_entity_secret;
350///
351/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
352/// let entity_secret = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
353/// let public_key = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A...\n-----END PUBLIC KEY-----";
354///
355/// let encrypted = encrypt_entity_secret(entity_secret, public_key)?;
356/// println!("Encrypted secret: {}", encrypted);
357/// # Ok(())
358/// # }
359/// ```
360pub fn encrypt_entity_secret(
361 entity_secret_hex: &str,
362 public_key_pem: &str,
363) -> AnyhowResult<String> {
364 // Convert hex string to bytes
365 let entity_secret_bytes = hex::decode(entity_secret_hex)
366 .map_err(|e| anyhow!("Failed to decode hex entity secret: {}", e))?;
367
368 // Try PKCS#1 format first, then fall back to PKCS#8 format
369 let public_key = match RsaPublicKey::from_pkcs1_pem(public_key_pem) {
370 Ok(key) => key,
371 Err(e1) => match RsaPublicKey::from_public_key_pem(public_key_pem) {
372 Ok(key) => key,
373 Err(e2) => {
374 return Err(anyhow!(
375 "Failed to parse public key from PEM (tried both PKCS#1 and PKCS#8): PKCS#1 error: {}, PKCS#8 error: {}",
376 e1, e2
377 ));
378 }
379 },
380 };
381
382 // Encrypt using RSA-OAEP with SHA-256
383 let mut rng = rand::thread_rng();
384 let padding = Oaep::new::<Sha256>();
385 let encrypted_data = public_key
386 .encrypt(&mut rng, padding, &entity_secret_bytes)
387 .map_err(|e| anyhow!("Failed to encrypt data: {}", e))?;
388
389 // Encode to base64
390 let base64_encoded = general_purpose::STANDARD.encode(&encrypted_data);
391
392 Ok(base64_encoded)
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398
399 #[test]
400 fn test_generate_uuid() {
401 let uuid = generate_uuid();
402 assert_eq!(uuid.len(), 36); // Standard UUID length
403 assert!(uuid.contains('-'));
404 }
405
406 #[test]
407 fn test_pagination_params_serialization() {
408 let params = PaginationParams {
409 page_size: Some(10),
410 ..Default::default()
411 };
412
413 let serialized = serde_json::to_string(¶ms).unwrap();
414 assert!(serialized.contains("pageSize"));
415 assert!(!serialized.contains("pageAfter"));
416 }
417
418 #[test]
419 fn test_encrypt_entity_secret_generates_different_values() {
420 // Test that multiple encryptions of the same data produce different results
421 // This is expected behavior due to RSA-OAEP padding with random values
422
423 // Use a simple test key pair (in practice, this would come from environment)
424 let entity_secret = "deadbeef"; // Simple hex string
425 let test_public_key = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3VoPN9PKUjKFLMwOge9+\nG852nMEQAiMpm8FZ8VpJx5yXHdVXyMqTDwGJstidHy5htGKsyEArvIHxBzgWhMfL\nKLFZjvnxWx+rm/d5fk/5UjpjGFI7KABlxEBOAArBuLoi8TJb9BF3MjEqtlHOHUj6\nKG2n4sRRqeWpyFxTJU2v8fhJgHR1HhYkdHw8JdJ6J1lNNJGE7JfGtKDHI4mEo8ZN\nKF8TlW4wIJLQ4CJtEZJH2vKJJFNyFwJNJG2vKJJFNyFwJNJG2vKJJFNyFwJNJG2v\nKJJFNyFwJNJG2vKJJFNyFwJNJG2vKJJFNyFwJNJG2vKJJFNyFwJNJG2vKJJFNyFw\nQIDAQAB\n-----END PUBLIC KEY-----";
426
427 // Note: This test would require a valid key pair to actually run
428 // For now, we'll just test the function signature and error handling
429 let result = encrypt_entity_secret(entity_secret, test_public_key);
430
431 // We expect this to fail with an invalid key, but that's ok for testing the interface
432 assert!(result.is_err());
433
434 // The important thing is that the function exists and has the right signature
435 // In real usage with valid keys, multiple calls would produce different encrypted values
436 }
437}