Skip to main content

steam_client/utils/
http.rs

1//! HTTP client abstraction for dependency injection.
2//!
3//! This module provides an HTTP client trait that can be mocked for testing,
4//! along with a default implementation using `reqwest`.
5//!
6//! # Example
7//!
8//! ```rust,ignore
9//! // Using the default client
10//! let client = ReqwestHttpClient::new();
11//!
12//! // Or in tests, use the mock
13//! let mock = MockHttpClient::new()
14//!     .with_response(HttpResponse::ok(b"response body".to_vec()));
15//! ```
16
17use std::{
18    collections::{HashMap, VecDeque},
19    sync::{Arc, Mutex},
20};
21
22use async_trait::async_trait;
23
24use crate::error::SteamError;
25
26/// HTTP response from a request.
27#[derive(Debug, Clone)]
28pub struct HttpResponse {
29    /// HTTP status code.
30    pub status: u16,
31    /// Response body.
32    pub body: Vec<u8>,
33    /// Response headers (lowercase keys).
34    pub headers: HashMap<String, String>,
35}
36
37impl HttpResponse {
38    /// Create a successful response (200 OK).
39    pub fn ok(body: Vec<u8>) -> Self {
40        Self { status: 200, body, headers: HashMap::new() }
41    }
42
43    /// Create an error response.
44    pub fn error(status: u16, body: Vec<u8>) -> Self {
45        Self { status, body, headers: HashMap::new() }
46    }
47
48    /// Check if the response indicates success (2xx status).
49    pub fn is_success(&self) -> bool {
50        (200..300).contains(&self.status)
51    }
52
53    /// Parse the body as JSON.
54    pub fn json<T: serde::de::DeserializeOwned>(&self) -> Result<T, SteamError> {
55        serde_json::from_slice(&self.body).map_err(|e| SteamError::ProtocolError(format!("Failed to parse JSON: {}", e)))
56    }
57
58    /// Get body as string.
59    pub fn text(&self) -> Result<String, SteamError> {
60        String::from_utf8(self.body.clone()).map_err(|e| SteamError::ProtocolError(format!("Invalid UTF-8: {}", e)))
61    }
62}
63
64/// HTTP client trait for making web requests.
65///
66/// This trait abstracts HTTP operations, enabling mock implementations
67/// for unit testing without actual network calls.
68#[async_trait]
69pub trait HttpClient: Send + Sync {
70    /// Perform a GET request.
71    ///
72    /// # Arguments
73    /// * `url` - The URL to request
74    async fn get(&self, url: &str) -> Result<HttpResponse, SteamError>;
75
76    /// Perform a GET request with query parameters.
77    ///
78    /// # Arguments
79    /// * `url` - The base URL
80    /// * `query` - Query parameters as key-value pairs
81    async fn get_with_query(&self, url: &str, query: &[(&str, &str)]) -> Result<HttpResponse, SteamError>;
82
83    /// Perform a POST request with a body.
84    ///
85    /// # Arguments
86    /// * `url` - The URL to post to
87    /// * `body` - The request body
88    /// * `content_type` - The content type header value
89    async fn post(&self, url: &str, body: Vec<u8>, content_type: &str) -> Result<HttpResponse, SteamError>;
90
91    /// Perform a POST request with form data.
92    ///
93    /// # Arguments
94    /// * `url` - The URL to post to
95    /// * `form` - Form fields as key-value pairs
96    async fn post_form(&self, url: &str, form: &[(&str, &str)]) -> Result<HttpResponse, SteamError>;
97}
98
99/// Default HTTP client implementation using `reqwest`.
100///
101/// Uses connection pooling for better performance.
102#[derive(Clone)]
103pub struct ReqwestHttpClient {
104    client: reqwest::Client,
105}
106
107impl ReqwestHttpClient {
108    /// Create a new HTTP client with optimized settings.
109    ///
110    /// Configures connection pooling with:
111    /// - 10 idle connections per host
112    /// - 60 second idle timeout
113    /// - 5 second request timeout (matching Node.js implementation)
114    /// - Custom User-Agent and default headers
115    pub fn new() -> Self {
116        use std::time::Duration;
117
118        use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, ACCEPT_CHARSET, USER_AGENT};
119
120        let mut headers = HeaderMap::new();
121        headers.insert(USER_AGENT, HeaderValue::from_static("Valve/Steam HTTP Client 1.0"));
122        headers.insert(ACCEPT, HeaderValue::from_static("text/html,*/*;q=0.9"));
123        headers.insert(ACCEPT_CHARSET, HeaderValue::from_static("ISO-8859-1,utf-8,*;q=0.7"));
124
125        let client = reqwest::Client::builder().default_headers(headers).pool_max_idle_per_host(10).pool_idle_timeout(Duration::from_secs(60)).timeout(Duration::from_secs(5)).gzip(true).build().unwrap_or_else(|_| reqwest::Client::new());
126
127        Self { client }
128    }
129
130    /// Create with a custom `reqwest::Client`.
131    pub fn with_client(client: reqwest::Client) -> Self {
132        Self { client }
133    }
134
135    /// Convert reqwest response to our HttpResponse.
136    async fn convert_response(resp: reqwest::Response) -> Result<HttpResponse, SteamError> {
137        let status = resp.status().as_u16();
138        let mut headers = HashMap::new();
139
140        for (key, value) in resp.headers() {
141            if let Ok(v) = value.to_str() {
142                headers.insert(key.as_str().to_lowercase(), v.to_string());
143            }
144        }
145
146        let body = resp.bytes().await.map_err(|e| SteamError::NetworkError(std::io::Error::other(e)))?.to_vec();
147
148        Ok(HttpResponse { status, body, headers })
149    }
150}
151
152impl Default for ReqwestHttpClient {
153    fn default() -> Self {
154        Self::new()
155    }
156}
157
158#[async_trait]
159impl HttpClient for ReqwestHttpClient {
160    async fn get(&self, url: &str) -> Result<HttpResponse, SteamError> {
161        let resp = self.client.get(url).send().await.map_err(|e| SteamError::NetworkError(std::io::Error::other(e)))?;
162
163        Self::convert_response(resp).await
164    }
165
166    async fn get_with_query(&self, url: &str, query: &[(&str, &str)]) -> Result<HttpResponse, SteamError> {
167        let resp = self.client.get(url).query(query).send().await.map_err(|e| SteamError::NetworkError(std::io::Error::other(e)))?;
168
169        Self::convert_response(resp).await
170    }
171
172    async fn post(&self, url: &str, body: Vec<u8>, content_type: &str) -> Result<HttpResponse, SteamError> {
173        let resp = self.client.post(url).header("Content-Type", content_type).body(body).send().await.map_err(|e| SteamError::NetworkError(std::io::Error::other(e)))?;
174
175        Self::convert_response(resp).await
176    }
177
178    async fn post_form(&self, url: &str, form: &[(&str, &str)]) -> Result<HttpResponse, SteamError> {
179        let resp = self.client.post(url).form(form).send().await.map_err(|e| SteamError::NetworkError(std::io::Error::other(e)))?;
180
181        Self::convert_response(resp).await
182    }
183}
184
185#[async_trait]
186impl steam_cm_provider::HttpClient for dyn HttpClient {
187    async fn get_with_query(&self, url: &str, query: &[(&str, &str)]) -> Result<steam_cm_provider::HttpResponse, steam_cm_provider::CmError> {
188        let resp = self.get_with_query(url, query).await.map_err(|e| steam_cm_provider::CmError::Network(e.to_string()))?;
189
190        Ok(steam_cm_provider::HttpResponse { status: resp.status, body: resp.body })
191    }
192}
193
194#[async_trait]
195impl steam_cm_provider::HttpClient for ReqwestHttpClient {
196    async fn get_with_query(&self, url: &str, query: &[(&str, &str)]) -> Result<steam_cm_provider::HttpResponse, steam_cm_provider::CmError> {
197        let resp = crate::utils::http::HttpClient::get_with_query(self, url, query).await.map_err(|e| steam_cm_provider::CmError::Network(e.to_string()))?;
198
199        Ok(steam_cm_provider::HttpResponse { status: resp.status, body: resp.body })
200    }
201}
202
203/// Mock HTTP client for testing.
204///
205/// Queues responses to be returned by subsequent requests.
206/// Tracks all requests made for assertions.
207///
208/// # Example
209///
210/// ```rust,ignore
211/// let mut mock = MockHttpClient::new();
212/// mock.queue_response(HttpResponse::ok(r#"{"key": "value"}"#.as_bytes().to_vec()));
213///
214/// let response = mock.get("https://example.com").await?;
215/// assert_eq!(response.status, 200);
216///
217/// // Verify the request was made
218/// assert_eq!(mock.requests()[0].url, "https://example.com");
219/// ```
220pub struct MockHttpClient {
221    /// Queued responses to return.
222    responses: Arc<Mutex<VecDeque<Result<HttpResponse, SteamError>>>>,
223    /// Recorded requests.
224    requests: Arc<Mutex<Vec<MockRequest>>>,
225}
226
227/// A recorded HTTP request made to the mock client.
228#[derive(Debug, Clone)]
229pub struct MockRequest {
230    /// HTTP method.
231    pub method: String,
232    /// Request URL.
233    pub url: String,
234    /// Query parameters (for get_with_query).
235    pub query: Vec<(String, String)>,
236    /// Request body (for POST).
237    pub body: Option<Vec<u8>>,
238    /// Content type (for POST).
239    pub content_type: Option<String>,
240}
241
242impl MockHttpClient {
243    /// Create a new mock HTTP client.
244    pub fn new() -> Self {
245        Self { responses: Arc::new(Mutex::new(VecDeque::new())), requests: Arc::new(Mutex::new(Vec::new())) }
246    }
247
248    /// Queue a response to be returned by the next request.
249    pub fn queue_response(&self, response: HttpResponse) {
250        self.responses.lock().expect("failed to get mock value").push_back(Ok(response));
251    }
252
253    /// Queue an error to be returned by the next request.
254    pub fn queue_error(&self, error: SteamError) {
255        self.responses.lock().expect("failed to get mock value").push_back(Err(error));
256    }
257
258    /// Queue multiple responses.
259    pub fn queue_responses(&self, responses: Vec<HttpResponse>) {
260        let mut queue = self.responses.lock().expect("failed to get mock value");
261        for resp in responses {
262            queue.push_back(Ok(resp));
263        }
264    }
265
266    /// Get all recorded requests.
267    pub fn requests(&self) -> Vec<MockRequest> {
268        self.requests.lock().expect("failed to get mock value").clone()
269    }
270
271    /// Get the last request made.
272    pub fn last_request(&self) -> Option<MockRequest> {
273        self.requests.lock().expect("failed to get mock value").last().cloned()
274    }
275
276    /// Clear all recorded requests.
277    pub fn clear_requests(&self) {
278        self.requests.lock().expect("failed to get mock value").clear();
279    }
280
281    /// Get the number of requests made.
282    pub fn request_count(&self) -> usize {
283        self.requests.lock().expect("failed to get mock value").len()
284    }
285
286    /// Pop the next response from the queue.
287    fn pop_response(&self) -> Result<HttpResponse, SteamError> {
288        self.responses.lock().expect("failed to get mock value").pop_front().unwrap_or_else(|| Err(SteamError::Other("MockHttpClient: No response queued".to_string())))
289    }
290
291    /// Record a request.
292    fn record_request(&self, request: MockRequest) {
293        self.requests.lock().expect("failed to get mock value").push(request);
294    }
295}
296
297impl Default for MockHttpClient {
298    fn default() -> Self {
299        Self::new()
300    }
301}
302
303impl Clone for MockHttpClient {
304    fn clone(&self) -> Self {
305        Self { responses: Arc::clone(&self.responses), requests: Arc::clone(&self.requests) }
306    }
307}
308
309#[async_trait]
310impl HttpClient for MockHttpClient {
311    async fn get(&self, url: &str) -> Result<HttpResponse, SteamError> {
312        self.record_request(MockRequest { method: "GET".to_string(), url: url.to_string(), query: Vec::new(), body: None, content_type: None });
313        self.pop_response()
314    }
315
316    async fn get_with_query(&self, url: &str, query: &[(&str, &str)]) -> Result<HttpResponse, SteamError> {
317        self.record_request(MockRequest {
318            method: "GET".to_string(),
319            url: url.to_string(),
320            query: query.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
321            body: None,
322            content_type: None,
323        });
324        self.pop_response()
325    }
326
327    async fn post(&self, url: &str, body: Vec<u8>, content_type: &str) -> Result<HttpResponse, SteamError> {
328        self.record_request(MockRequest {
329            method: "POST".to_string(),
330            url: url.to_string(),
331            query: Vec::new(),
332            body: Some(body),
333            content_type: Some(content_type.to_string()),
334        });
335        self.pop_response()
336    }
337
338    async fn post_form(&self, url: &str, form: &[(&str, &str)]) -> Result<HttpResponse, SteamError> {
339        // Encode form data as body for tracking
340        let form_body: String = form.iter().map(|(k, v)| format!("{}={}", k, v)).collect::<Vec<_>>().join("&");
341
342        self.record_request(MockRequest {
343            method: "POST".to_string(),
344            url: url.to_string(),
345            query: form.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
346            body: Some(form_body.into_bytes()),
347            content_type: Some("application/x-www-form-urlencoded".to_string()),
348        });
349        self.pop_response()
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    #[tokio::test]
358    async fn test_mock_http_client_get() {
359        let mock = MockHttpClient::new();
360        mock.queue_response(HttpResponse::ok(b"test response".to_vec()));
361
362        let response = mock.get("https://example.com/test").await.expect("failed to get mock value");
363
364        assert_eq!(response.status, 200);
365        assert_eq!(response.body, b"test response");
366        assert!(response.is_success());
367
368        let requests = mock.requests();
369        assert_eq!(requests.len(), 1);
370        assert_eq!(requests[0].method, "GET");
371        assert_eq!(requests[0].url, "https://example.com/test");
372    }
373
374    #[tokio::test]
375    async fn test_mock_http_client_get_with_query() {
376        let mock = MockHttpClient::new();
377        mock.queue_response(HttpResponse::ok(b"{}".to_vec()));
378
379        let query = [("key", "value"), ("foo", "bar")];
380        let response = mock.get_with_query("https://api.example.com", &query).await.expect("failed to get mock value");
381
382        assert!(response.is_success());
383
384        let request = mock.last_request().expect("failed to get mock value");
385        assert_eq!(request.query.len(), 2);
386        assert_eq!(request.query[0], ("key".to_string(), "value".to_string()));
387    }
388
389    #[tokio::test]
390    async fn test_mock_http_client_post() {
391        let mock = MockHttpClient::new();
392        mock.queue_response(HttpResponse::ok(b"created".to_vec()));
393
394        let body = b"request body".to_vec();
395        let response = mock.post("https://api.example.com/create", body.clone(), "text/plain").await.expect("failed to get mock value");
396
397        assert!(response.is_success());
398
399        let request = mock.last_request().expect("failed to get mock value");
400        assert_eq!(request.method, "POST");
401        assert_eq!(request.body, Some(body));
402        assert_eq!(request.content_type, Some("text/plain".to_string()));
403    }
404
405    #[tokio::test]
406    async fn test_mock_http_client_error() {
407        let mock = MockHttpClient::new();
408        mock.queue_error(SteamError::Timeout);
409
410        let result = mock.get("https://example.com").await;
411        assert!(result.is_err());
412    }
413
414    #[tokio::test]
415    async fn test_mock_http_client_no_response_queued() {
416        let mock = MockHttpClient::new();
417
418        let result = mock.get("https://example.com").await;
419        assert!(result.is_err());
420    }
421
422    #[tokio::test]
423    async fn test_http_response_json() {
424        let response = HttpResponse::ok(br#"{"key": "value"}"#.to_vec());
425        let json: serde_json::Value = response.json().expect("failed to get mock value");
426
427        assert_eq!(json["key"], "value");
428    }
429
430    #[tokio::test]
431    async fn test_http_response_is_success() {
432        assert!(HttpResponse::ok(vec![]).is_success());
433        assert!(HttpResponse { status: 201, body: vec![], headers: HashMap::new() }.is_success());
434        assert!(!HttpResponse::error(404, vec![]).is_success());
435        assert!(!HttpResponse::error(500, vec![]).is_success());
436    }
437}