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    /// Perform a GET request carrying a raw `Cookie` header value.
99    ///
100    /// Default implementation delegates to `get(url)` and ignores cookies, so
101    /// existing mock/test implementations keep compiling. Real HTTP clients
102    /// should override this to attach the cookie header.
103    ///
104    /// # Arguments
105    /// * `url` - The URL to request
106    /// * `cookies` - Raw `Cookie` header value (e.g. `"sessionid=...; steamLoginSecure=..."`)
107    async fn get_with_cookies(&self, url: &str, cookies: &str) -> Result<HttpResponse, SteamError> {
108        let _ = cookies;
109        self.get(url).await
110    }
111
112    /// Perform a POST form request carrying a raw `Cookie` header value.
113    ///
114    /// Default implementation delegates to `post_form(url, form)` and ignores
115    /// cookies, so existing mock/test implementations keep compiling. Real HTTP
116    /// clients should override this to attach the cookie header.
117    async fn post_form_with_cookies(&self, url: &str, form: &[(&str, &str)], cookies: &str) -> Result<HttpResponse, SteamError> {
118        let _ = cookies;
119        self.post_form(url, form).await
120    }
121}
122
123/// Default HTTP client implementation using `reqwest`.
124///
125/// Uses connection pooling for better performance.
126#[derive(Clone)]
127pub struct ReqwestHttpClient {
128    client: reqwest::Client,
129}
130
131impl ReqwestHttpClient {
132    /// Create a new HTTP client with optimized settings.
133    ///
134    /// Configures connection pooling with:
135    /// - 10 idle connections per host
136    /// - 60 second idle timeout
137    /// - 5 second request timeout (matching Node.js implementation)
138    /// - Custom User-Agent and default headers
139    pub fn new() -> Self {
140        use std::time::Duration;
141
142        use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, ACCEPT_CHARSET, USER_AGENT};
143
144        let mut headers = HeaderMap::new();
145        headers.insert(USER_AGENT, HeaderValue::from_static("Valve/Steam HTTP Client 1.0"));
146        headers.insert(ACCEPT, HeaderValue::from_static("text/html,*/*;q=0.9"));
147        headers.insert(ACCEPT_CHARSET, HeaderValue::from_static("ISO-8859-1,utf-8,*;q=0.7"));
148
149        // Bumped overall request timeout from 5s to 30s for slower Steam web endpoints; connect_timeout caps DNS+TCP+TLS setup.
150        let client = reqwest::Client::builder().default_headers(headers).pool_max_idle_per_host(10).pool_idle_timeout(Duration::from_secs(60)).connect_timeout(Duration::from_secs(30)).timeout(Duration::from_secs(30)).gzip(true).build().unwrap_or_else(|_| reqwest::Client::new());
151
152        Self { client }
153    }
154
155    /// Create with a custom `reqwest::Client`.
156    pub fn with_client(client: reqwest::Client) -> Self {
157        Self { client }
158    }
159
160    /// Convert reqwest response to our HttpResponse.
161    async fn convert_response(resp: reqwest::Response) -> Result<HttpResponse, SteamError> {
162        let status = resp.status().as_u16();
163        let mut headers = HashMap::new();
164
165        for (key, value) in resp.headers() {
166            if let Ok(v) = value.to_str() {
167                headers.insert(key.as_str().to_lowercase(), v.to_string());
168            }
169        }
170
171        let body = resp.bytes().await.map_err(|e| SteamError::NetworkError(std::io::Error::other(e)))?.to_vec();
172
173        Ok(HttpResponse { status, body, headers })
174    }
175}
176
177impl Default for ReqwestHttpClient {
178    fn default() -> Self {
179        Self::new()
180    }
181}
182
183#[async_trait]
184impl HttpClient for ReqwestHttpClient {
185    async fn get(&self, url: &str) -> Result<HttpResponse, SteamError> {
186        let resp = self.client.get(url).send().await.map_err(|e| SteamError::NetworkError(std::io::Error::other(e)))?;
187
188        Self::convert_response(resp).await
189    }
190
191    async fn get_with_query(&self, url: &str, query: &[(&str, &str)]) -> Result<HttpResponse, SteamError> {
192        let resp = self.client.get(url).query(query).send().await.map_err(|e| SteamError::NetworkError(std::io::Error::other(e)))?;
193
194        Self::convert_response(resp).await
195    }
196
197    async fn post(&self, url: &str, body: Vec<u8>, content_type: &str) -> Result<HttpResponse, SteamError> {
198        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)))?;
199
200        Self::convert_response(resp).await
201    }
202
203    async fn post_form(&self, url: &str, form: &[(&str, &str)]) -> Result<HttpResponse, SteamError> {
204        let resp = self.client.post(url).form(form).send().await.map_err(|e| SteamError::NetworkError(std::io::Error::other(e)))?;
205
206        Self::convert_response(resp).await
207    }
208
209    async fn get_with_cookies(&self, url: &str, cookies: &str) -> Result<HttpResponse, SteamError> {
210        let client = build_cookie_client(cookies)?;
211        let resp = client.get(url).send().await.map_err(|e| SteamError::NetworkError(std::io::Error::other(e)))?;
212        Self::convert_response(resp).await
213    }
214
215    async fn post_form_with_cookies(&self, url: &str, form: &[(&str, &str)], cookies: &str) -> Result<HttpResponse, SteamError> {
216        let client = build_cookie_client(cookies)?;
217        let resp = client.post(url).form(form).send().await.map_err(|e| SteamError::NetworkError(std::io::Error::other(e)))?;
218        Self::convert_response(resp).await
219    }
220}
221
222/// Build a transient `reqwest::Client` with a `Cookie` header (marked sensitive)
223/// and a 10-second timeout. Used for one-off cookie-authenticated web requests.
224fn build_cookie_client(cookies: &str) -> Result<reqwest::Client, SteamError> {
225    use std::time::Duration;
226
227    use reqwest::header::{HeaderMap, HeaderValue, COOKIE};
228
229    let mut cookie_value: HeaderValue = cookies.parse().map_err(|_| SteamError::Other("Invalid cookie header".to_string()))?;
230    cookie_value.set_sensitive(true);
231
232    let mut headers = HeaderMap::new();
233    headers.insert(COOKIE, cookie_value);
234
235    reqwest::Client::builder()
236        .user_agent("Valve/Steam HTTP Client 1.0")
237        .default_headers(headers)
238        .connect_timeout(Duration::from_secs(30))
239        .timeout(Duration::from_secs(10))
240        .build()
241        .map_err(|e| SteamError::Other(format!("Failed to build HTTP client: {}", e)))
242}
243
244#[async_trait]
245impl steam_cm_provider::HttpClient for dyn HttpClient {
246    async fn get_with_query(&self, url: &str, query: &[(&str, &str)]) -> Result<steam_cm_provider::HttpResponse, steam_cm_provider::CmError> {
247        let resp = self.get_with_query(url, query).await.map_err(|e| steam_cm_provider::CmError::Network(e.to_string()))?;
248
249        Ok(steam_cm_provider::HttpResponse { status: resp.status, body: resp.body })
250    }
251}
252
253#[async_trait]
254impl steam_cm_provider::HttpClient for ReqwestHttpClient {
255    async fn get_with_query(&self, url: &str, query: &[(&str, &str)]) -> Result<steam_cm_provider::HttpResponse, steam_cm_provider::CmError> {
256        let resp = crate::utils::http::HttpClient::get_with_query(self, url, query).await.map_err(|e| steam_cm_provider::CmError::Network(e.to_string()))?;
257
258        Ok(steam_cm_provider::HttpResponse { status: resp.status, body: resp.body })
259    }
260}
261
262/// Mock HTTP client for testing.
263///
264/// Queues responses to be returned by subsequent requests.
265/// Tracks all requests made for assertions.
266///
267/// # Example
268///
269/// ```rust,ignore
270/// let mut mock = MockHttpClient::new();
271/// mock.queue_response(HttpResponse::ok(r#"{"key": "value"}"#.as_bytes().to_vec()));
272///
273/// let response = mock.get("https://example.com").await?;
274/// assert_eq!(response.status, 200);
275///
276/// // Verify the request was made
277/// assert_eq!(mock.requests()[0].url, "https://example.com");
278/// ```
279pub struct MockHttpClient {
280    /// Queued responses to return.
281    responses: Arc<Mutex<VecDeque<Result<HttpResponse, SteamError>>>>,
282    /// Recorded requests.
283    requests: Arc<Mutex<Vec<MockRequest>>>,
284}
285
286/// A recorded HTTP request made to the mock client.
287#[derive(Debug, Clone)]
288pub struct MockRequest {
289    /// HTTP method.
290    pub method: String,
291    /// Request URL.
292    pub url: String,
293    /// Query parameters (for get_with_query).
294    pub query: Vec<(String, String)>,
295    /// Request body (for POST).
296    pub body: Option<Vec<u8>>,
297    /// Content type (for POST).
298    pub content_type: Option<String>,
299}
300
301impl MockHttpClient {
302    /// Create a new mock HTTP client.
303    pub fn new() -> Self {
304        Self { responses: Arc::new(Mutex::new(VecDeque::new())), requests: Arc::new(Mutex::new(Vec::new())) }
305    }
306
307    /// Queue a response to be returned by the next request.
308    pub fn queue_response(&self, response: HttpResponse) {
309        self.responses.lock().expect("failed to get mock value").push_back(Ok(response));
310    }
311
312    /// Queue an error to be returned by the next request.
313    pub fn queue_error(&self, error: SteamError) {
314        self.responses.lock().expect("failed to get mock value").push_back(Err(error));
315    }
316
317    /// Queue multiple responses.
318    pub fn queue_responses(&self, responses: Vec<HttpResponse>) {
319        let mut queue = self.responses.lock().expect("failed to get mock value");
320        for resp in responses {
321            queue.push_back(Ok(resp));
322        }
323    }
324
325    /// Get all recorded requests.
326    pub fn requests(&self) -> Vec<MockRequest> {
327        self.requests.lock().expect("failed to get mock value").clone()
328    }
329
330    /// Get the last request made.
331    pub fn last_request(&self) -> Option<MockRequest> {
332        self.requests.lock().expect("failed to get mock value").last().cloned()
333    }
334
335    /// Clear all recorded requests.
336    pub fn clear_requests(&self) {
337        self.requests.lock().expect("failed to get mock value").clear();
338    }
339
340    /// Get the number of requests made.
341    pub fn request_count(&self) -> usize {
342        self.requests.lock().expect("failed to get mock value").len()
343    }
344
345    /// Pop the next response from the queue.
346    fn pop_response(&self) -> Result<HttpResponse, SteamError> {
347        self.responses.lock().expect("failed to get mock value").pop_front().unwrap_or_else(|| Err(SteamError::Other("MockHttpClient: No response queued".to_string())))
348    }
349
350    /// Record a request.
351    fn record_request(&self, request: MockRequest) {
352        self.requests.lock().expect("failed to get mock value").push(request);
353    }
354}
355
356impl Default for MockHttpClient {
357    fn default() -> Self {
358        Self::new()
359    }
360}
361
362impl Clone for MockHttpClient {
363    fn clone(&self) -> Self {
364        Self { responses: Arc::clone(&self.responses), requests: Arc::clone(&self.requests) }
365    }
366}
367
368#[async_trait]
369impl HttpClient for MockHttpClient {
370    async fn get(&self, url: &str) -> Result<HttpResponse, SteamError> {
371        self.record_request(MockRequest { method: "GET".to_string(), url: url.to_string(), query: Vec::new(), body: None, content_type: None });
372        self.pop_response()
373    }
374
375    async fn get_with_query(&self, url: &str, query: &[(&str, &str)]) -> Result<HttpResponse, SteamError> {
376        self.record_request(MockRequest {
377            method: "GET".to_string(),
378            url: url.to_string(),
379            query: query.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
380            body: None,
381            content_type: None,
382        });
383        self.pop_response()
384    }
385
386    async fn post(&self, url: &str, body: Vec<u8>, content_type: &str) -> Result<HttpResponse, SteamError> {
387        self.record_request(MockRequest {
388            method: "POST".to_string(),
389            url: url.to_string(),
390            query: Vec::new(),
391            body: Some(body),
392            content_type: Some(content_type.to_string()),
393        });
394        self.pop_response()
395    }
396
397    async fn post_form(&self, url: &str, form: &[(&str, &str)]) -> Result<HttpResponse, SteamError> {
398        // Encode form data as body for tracking
399        let form_body: String = form.iter().map(|(k, v)| format!("{}={}", k, v)).collect::<Vec<_>>().join("&");
400
401        self.record_request(MockRequest {
402            method: "POST".to_string(),
403            url: url.to_string(),
404            query: form.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
405            body: Some(form_body.into_bytes()),
406            content_type: Some("application/x-www-form-urlencoded".to_string()),
407        });
408        self.pop_response()
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    #[tokio::test]
417    async fn test_mock_http_client_get() {
418        let mock = MockHttpClient::new();
419        mock.queue_response(HttpResponse::ok(b"test response".to_vec()));
420
421        let response = mock.get("https://example.com/test").await.expect("failed to get mock value");
422
423        assert_eq!(response.status, 200);
424        assert_eq!(response.body, b"test response");
425        assert!(response.is_success());
426
427        let requests = mock.requests();
428        assert_eq!(requests.len(), 1);
429        assert_eq!(requests[0].method, "GET");
430        assert_eq!(requests[0].url, "https://example.com/test");
431    }
432
433    #[tokio::test]
434    async fn test_mock_http_client_get_with_query() {
435        let mock = MockHttpClient::new();
436        mock.queue_response(HttpResponse::ok(b"{}".to_vec()));
437
438        let query = [("key", "value"), ("foo", "bar")];
439        let response = mock.get_with_query("https://api.example.com", &query).await.expect("failed to get mock value");
440
441        assert!(response.is_success());
442
443        let request = mock.last_request().expect("failed to get mock value");
444        assert_eq!(request.query.len(), 2);
445        assert_eq!(request.query[0], ("key".to_string(), "value".to_string()));
446    }
447
448    #[tokio::test]
449    async fn test_mock_http_client_post() {
450        let mock = MockHttpClient::new();
451        mock.queue_response(HttpResponse::ok(b"created".to_vec()));
452
453        let body = b"request body".to_vec();
454        let response = mock.post("https://api.example.com/create", body.clone(), "text/plain").await.expect("failed to get mock value");
455
456        assert!(response.is_success());
457
458        let request = mock.last_request().expect("failed to get mock value");
459        assert_eq!(request.method, "POST");
460        assert_eq!(request.body, Some(body));
461        assert_eq!(request.content_type, Some("text/plain".to_string()));
462    }
463
464    #[tokio::test]
465    async fn test_mock_http_client_error() {
466        let mock = MockHttpClient::new();
467        mock.queue_error(SteamError::Timeout);
468
469        let result = mock.get("https://example.com").await;
470        assert!(result.is_err());
471    }
472
473    #[tokio::test]
474    async fn test_mock_http_client_no_response_queued() {
475        let mock = MockHttpClient::new();
476
477        let result = mock.get("https://example.com").await;
478        assert!(result.is_err());
479    }
480
481    #[tokio::test]
482    async fn test_http_response_json() {
483        let response = HttpResponse::ok(br#"{"key": "value"}"#.to_vec());
484        let json: serde_json::Value = response.json().expect("failed to get mock value");
485
486        assert_eq!(json["key"], "value");
487    }
488
489    #[tokio::test]
490    async fn test_http_response_is_success() {
491        assert!(HttpResponse::ok(vec![]).is_success());
492        assert!(HttpResponse { status: 201, body: vec![], headers: HashMap::new() }.is_success());
493        assert!(!HttpResponse::error(404, vec![]).is_success());
494        assert!(!HttpResponse::error(500, vec![]).is_success());
495    }
496}