Skip to main content

shopify_sdk/clients/
http_client.rs

1//! HTTP client for Shopify API communication.
2//!
3//! This module provides the [`HttpClient`] type for making authenticated
4//! requests to the Shopify API with automatic retry handling.
5
6use std::collections::HashMap;
7
8use crate::auth::Session;
9use crate::clients::errors::{HttpError, HttpResponseError, MaxHttpRetriesExceededError};
10use crate::clients::http_request::HttpRequest;
11use crate::clients::http_response::{ApiDeprecationInfo, HttpResponse};
12use crate::config::{DeprecationCallback, ShopifyConfig};
13
14/// Fixed retry wait time in seconds (matching Ruby SDK).
15pub const RETRY_WAIT_TIME: u64 = 1;
16
17/// SDK version from Cargo.toml.
18pub const SDK_VERSION: &str = env!("CARGO_PKG_VERSION");
19
20/// HTTP client for making requests to the Shopify API.
21///
22/// The client handles:
23/// - Base URI construction from session shop domain or `api_host`
24/// - Default headers including User-Agent and access token
25/// - Automatic retry logic for 429 and 500 responses
26/// - Shopify-specific header parsing
27///
28/// # Thread Safety
29///
30/// `HttpClient` is `Send + Sync`, making it safe to share across async tasks.
31///
32/// # Example
33///
34/// ```rust,ignore
35/// use shopify_sdk::{HttpClient, HttpRequest, HttpMethod, Session, ShopDomain};
36///
37/// let session = Session::new(
38///     "session-id".to_string(),
39///     ShopDomain::new("my-store").unwrap(),
40///     "access-token".to_string(),
41///     "read_products".parse().unwrap(),
42///     false,
43///     None,
44/// );
45///
46/// let client = HttpClient::new("/admin/api/2024-10", &session, None);
47///
48/// let request = HttpRequest::builder(HttpMethod::Get, "products.json")
49///     .build()
50///     .unwrap();
51///
52/// let response = client.request(request).await?;
53/// ```
54pub struct HttpClient {
55    /// The internal reqwest HTTP client.
56    client: reqwest::Client,
57    /// Base URI (e.g., `https://my-store.myshopify.com`).
58    base_uri: String,
59    /// Base path (e.g., "/admin/api/2024-10").
60    base_path: String,
61    /// Default headers to include in all requests.
62    default_headers: HashMap<String, String>,
63    /// Optional callback for deprecation notices.
64    deprecation_callback: Option<DeprecationCallback>,
65}
66
67impl std::fmt::Debug for HttpClient {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        f.debug_struct("HttpClient")
70            .field("client", &self.client)
71            .field("base_uri", &self.base_uri)
72            .field("base_path", &self.base_path)
73            .field("default_headers", &self.default_headers)
74            .field(
75                "deprecation_callback",
76                &self.deprecation_callback.as_ref().map(|_| "<callback>"),
77            )
78            .finish()
79    }
80}
81
82// Verify HttpClient is Send + Sync at compile time
83const _: fn() = || {
84    const fn assert_send_sync<T: Send + Sync>() {}
85    assert_send_sync::<HttpClient>();
86};
87
88impl HttpClient {
89    /// Creates a new HTTP client for the given session.
90    ///
91    /// # Arguments
92    ///
93    /// * `base_path` - The base path for API requests (e.g., "/admin/api/2024-10")
94    /// * `session` - The session providing shop domain and access token
95    /// * `config` - Optional configuration for `api_host` and `user_agent_prefix`
96    ///
97    /// # Panics
98    ///
99    /// Panics if the underlying reqwest client cannot be created. This should
100    /// only happen in extremely unusual circumstances (e.g., TLS initialization failure).
101    ///
102    /// # Example
103    ///
104    /// ```rust
105    /// use shopify_sdk::{Session, ShopDomain, AuthScopes};
106    /// use shopify_sdk::clients::HttpClient;
107    ///
108    /// let session = Session::new(
109    ///     "session-id".to_string(),
110    ///     ShopDomain::new("my-store").unwrap(),
111    ///     "access-token".to_string(),
112    ///     AuthScopes::new(),
113    ///     false,
114    ///     None,
115    /// );
116    ///
117    /// let client = HttpClient::new("/admin/api/2024-10", &session, None);
118    /// ```
119    #[must_use]
120    pub fn new(
121        base_path: impl Into<String>,
122        session: &Session,
123        config: Option<&ShopifyConfig>,
124    ) -> Self {
125        let base_path = base_path.into();
126
127        // Determine base URI - use api_host if configured, otherwise session.shop
128        let api_host = config.and_then(|c| c.host());
129        let default_shop_uri = || format!("https://{}", session.shop.as_ref());
130        let base_uri = api_host.map_or_else(default_shop_uri, |host| {
131            host.host_name()
132                .map_or_else(default_shop_uri, |host_name| format!("https://{host_name}"))
133        });
134
135        // Build User-Agent header
136        let user_agent_prefix = config
137            .and_then(ShopifyConfig::user_agent_prefix)
138            .map_or(String::new(), |prefix| format!("{prefix} | "));
139        let rust_version = env!("CARGO_PKG_RUST_VERSION");
140        let user_agent =
141            format!("{user_agent_prefix}Shopify API Library v{SDK_VERSION} | Rust {rust_version}");
142
143        // Build default headers
144        let mut default_headers = HashMap::new();
145        default_headers.insert("User-Agent".to_string(), user_agent);
146        default_headers.insert("Accept".to_string(), "application/json".to_string());
147
148        // Add Host header when using api_host (proxy scenario)
149        if api_host.is_some() {
150            default_headers.insert("Host".to_string(), session.shop.as_ref().to_string());
151        }
152
153        // Add access token header if present
154        if !session.access_token.is_empty() {
155            default_headers.insert(
156                "X-Shopify-Access-Token".to_string(),
157                session.access_token.clone(),
158            );
159        }
160
161        // Create reqwest client
162        let client = reqwest::Client::builder()
163            .use_rustls_tls()
164            .build()
165            .expect("Failed to create HTTP client");
166
167        // Get deprecation callback if configured
168        let deprecation_callback = config.and_then(|c| c.deprecation_callback().cloned());
169
170        Self {
171            client,
172            base_uri,
173            base_path,
174            default_headers,
175            deprecation_callback,
176        }
177    }
178
179    /// Returns the base URI for this client.
180    #[must_use]
181    pub fn base_uri(&self) -> &str {
182        &self.base_uri
183    }
184
185    /// Returns the base path for this client.
186    #[must_use]
187    pub fn base_path(&self) -> &str {
188        &self.base_path
189    }
190
191    /// Returns the default headers for this client.
192    #[must_use]
193    pub const fn default_headers(&self) -> &HashMap<String, String> {
194        &self.default_headers
195    }
196
197    /// Sends an HTTP request to the Shopify API.
198    ///
199    /// This method handles:
200    /// - Request validation
201    /// - URL construction
202    /// - Header merging
203    /// - Response parsing
204    /// - Retry logic for 429 and 500 responses
205    /// - Deprecation warning logging
206    ///
207    /// # Errors
208    ///
209    /// Returns [`HttpError`] if:
210    /// - Request validation fails (`InvalidRequest`)
211    /// - Network error occurs (`Network`)
212    /// - Non-2xx response received (`Response`)
213    /// - Max retries exceeded (`MaxRetries`)
214    ///
215    /// # Example
216    ///
217    /// ```rust,ignore
218    /// let request = HttpRequest::builder(HttpMethod::Get, "products.json")
219    ///     .tries(3) // Enable retries
220    ///     .build()
221    ///     .unwrap();
222    ///
223    /// let response = client.request(request).await?;
224    /// if response.is_ok() {
225    ///     println!("Products: {}", response.body);
226    /// }
227    /// ```
228    pub async fn request(&self, request: HttpRequest) -> Result<HttpResponse, HttpError> {
229        // Validate request first
230        request.verify()?;
231
232        // Build full URL
233        let url = format!("{}{}/{}", self.base_uri, self.base_path, request.path);
234
235        // Merge headers
236        let mut headers = self.default_headers.clone();
237        if let Some(body_type) = &request.body_type {
238            headers.insert(
239                "Content-Type".to_string(),
240                body_type.as_content_type().to_string(),
241            );
242        }
243        if let Some(extra) = &request.extra_headers {
244            for (key, value) in extra {
245                headers.insert(key.clone(), value.clone());
246            }
247        }
248
249        // Retry loop
250        let mut tries: u32 = 0;
251        loop {
252            tries += 1;
253
254            // Build the reqwest request
255            let mut req_builder = match request.http_method {
256                crate::clients::http_request::HttpMethod::Get => self.client.get(&url),
257                crate::clients::http_request::HttpMethod::Post => self.client.post(&url),
258                crate::clients::http_request::HttpMethod::Put => self.client.put(&url),
259                crate::clients::http_request::HttpMethod::Delete => self.client.delete(&url),
260            };
261
262            // Add headers
263            for (key, value) in &headers {
264                req_builder = req_builder.header(key, value);
265            }
266
267            // Add query params
268            if let Some(query) = &request.query {
269                req_builder = req_builder.query(query);
270            }
271
272            // Add body
273            if let Some(body) = &request.body {
274                req_builder = req_builder.body(body.to_string());
275            }
276
277            // Send request
278            let res = req_builder.send().await?;
279
280            // Parse response
281            let code = res.status().as_u16();
282            let res_headers = Self::parse_response_headers(res.headers());
283            let body_text = res.text().await.unwrap_or_default();
284
285            // Parse body as JSON
286            let body = if body_text.is_empty() {
287                serde_json::json!({})
288            } else {
289                serde_json::from_str(&body_text).unwrap_or_else(|_| {
290                    // For 5xx errors, return raw body as string value
291                    if code >= 500 {
292                        serde_json::json!({ "raw_body": body_text })
293                    } else {
294                        serde_json::json!({})
295                    }
296                })
297            };
298
299            let response = HttpResponse::new(code, res_headers, body);
300
301            // Handle deprecation warning if present
302            if let Some(reason) = response.deprecation_reason() {
303                tracing::warn!(
304                    "Deprecated request to Shopify API at {}, received reason: {}",
305                    request.path,
306                    reason
307                );
308
309                // Invoke deprecation callback if configured
310                if let Some(callback) = &self.deprecation_callback {
311                    let info = ApiDeprecationInfo {
312                        reason: reason.to_string(),
313                        path: Some(request.path.clone()),
314                    };
315                    callback(&info);
316                }
317            }
318
319            // Check if response is OK
320            if response.is_ok() {
321                return Ok(response);
322            }
323
324            // Build error message (matching Ruby SDK format)
325            let error_message = Self::serialize_error(&response);
326
327            // Check if we should retry
328            let should_retry = code == 429 || code == 500;
329            if !should_retry {
330                return Err(HttpError::Response(HttpResponseError {
331                    code,
332                    message: error_message,
333                    error_reference: response.request_id().map(String::from),
334                }));
335            }
336
337            // Check if we've exhausted retries
338            if tries >= request.tries {
339                if request.tries == 1 {
340                    return Err(HttpError::Response(HttpResponseError {
341                        code,
342                        message: error_message,
343                        error_reference: response.request_id().map(String::from),
344                    }));
345                }
346                return Err(HttpError::MaxRetries(MaxHttpRetriesExceededError {
347                    code,
348                    tries: request.tries,
349                    message: error_message,
350                    error_reference: response.request_id().map(String::from),
351                }));
352            }
353
354            // Calculate retry delay
355            let delay = Self::calculate_retry_delay(&response, code);
356            tokio::time::sleep(delay).await;
357        }
358    }
359
360    /// Parses response headers into a `HashMap`.
361    fn parse_response_headers(
362        headers: &reqwest::header::HeaderMap,
363    ) -> HashMap<String, Vec<String>> {
364        let mut result: HashMap<String, Vec<String>> = HashMap::new();
365        for (name, value) in headers {
366            let key = name.as_str().to_lowercase();
367            let value = value.to_str().unwrap_or_default().to_string();
368            result.entry(key).or_default().push(value);
369        }
370        result
371    }
372
373    /// Calculates the retry delay based on response and status code.
374    fn calculate_retry_delay(response: &HttpResponse, status: u16) -> std::time::Duration {
375        // For 429: use Retry-After if present, otherwise fixed delay
376        // For 500: always use fixed delay (ignore Retry-After)
377        if status == 429 {
378            if let Some(retry_after) = response.retry_request_after {
379                return std::time::Duration::from_secs_f64(retry_after);
380            }
381        }
382        std::time::Duration::from_secs(RETRY_WAIT_TIME)
383    }
384
385    /// Serializes error response to JSON format (matching Ruby SDK).
386    fn serialize_error(response: &HttpResponse) -> String {
387        let mut error_body = serde_json::Map::new();
388
389        if let Some(errors) = response.body.get("errors") {
390            error_body.insert("errors".to_string(), errors.clone());
391        }
392        if let Some(error) = response.body.get("error") {
393            error_body.insert("error".to_string(), error.clone());
394        }
395        if response.body.get("error").is_some() {
396            if let Some(desc) = response.body.get("error_description") {
397                error_body.insert("error_description".to_string(), desc.clone());
398            }
399        }
400
401        if let Some(request_id) = response.request_id() {
402            error_body.insert(
403                "error_reference".to_string(),
404                serde_json::json!(format!(
405                    "If you report this error, please include this id: {request_id}."
406                )),
407            );
408        }
409
410        serde_json::to_string(&error_body).unwrap_or_else(|_| "{}".to_string())
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417    use crate::auth::AuthScopes;
418    use crate::config::{ApiKey, ApiSecretKey, ShopDomain};
419
420    fn create_test_session() -> Session {
421        Session::new(
422            "test-session".to_string(),
423            ShopDomain::new("test-shop").unwrap(),
424            "test-access-token".to_string(),
425            AuthScopes::new(),
426            false,
427            None,
428        )
429    }
430
431    #[test]
432    fn test_client_construction_with_session() {
433        let session = create_test_session();
434        let client = HttpClient::new("/admin/api/2024-10", &session, None);
435
436        assert_eq!(client.base_uri(), "https://test-shop.myshopify.com");
437        assert_eq!(client.base_path(), "/admin/api/2024-10");
438    }
439
440    #[test]
441    fn test_user_agent_header_format() {
442        let session = create_test_session();
443        let client = HttpClient::new("/admin/api/2024-10", &session, None);
444
445        let user_agent = client.default_headers().get("User-Agent").unwrap();
446        assert!(user_agent.contains("Shopify API Library v"));
447        assert!(user_agent.contains("Rust"));
448    }
449
450    #[test]
451    fn test_access_token_header_injection() {
452        let session = create_test_session();
453        let client = HttpClient::new("/admin/api/2024-10", &session, None);
454
455        assert_eq!(
456            client.default_headers().get("X-Shopify-Access-Token"),
457            Some(&"test-access-token".to_string())
458        );
459    }
460
461    #[test]
462    fn test_no_access_token_header_when_empty() {
463        let session = Session::new(
464            "test-session".to_string(),
465            ShopDomain::new("test-shop").unwrap(),
466            String::new(), // Empty access token
467            AuthScopes::new(),
468            false,
469            None,
470        );
471        let client = HttpClient::new("/admin/api/2024-10", &session, None);
472
473        assert!(client
474            .default_headers()
475            .get("X-Shopify-Access-Token")
476            .is_none());
477    }
478
479    #[test]
480    fn test_accept_header_is_json() {
481        let session = create_test_session();
482        let client = HttpClient::new("/admin/api/2024-10", &session, None);
483
484        assert_eq!(
485            client.default_headers().get("Accept"),
486            Some(&"application/json".to_string())
487        );
488    }
489
490    #[test]
491    fn test_client_is_send_sync() {
492        fn assert_send_sync<T: Send + Sync>() {}
493        assert_send_sync::<HttpClient>();
494    }
495
496    #[test]
497    fn test_base_uri_with_shop_domain() {
498        let session = create_test_session();
499        let client = HttpClient::new("/admin/api/2024-10", &session, None);
500
501        assert_eq!(client.base_uri(), "https://test-shop.myshopify.com");
502    }
503
504    #[test]
505    fn test_user_agent_with_prefix() {
506        let session = create_test_session();
507        let config = ShopifyConfig::builder()
508            .api_key(ApiKey::new("test-key").unwrap())
509            .api_secret_key(ApiSecretKey::new("test-secret").unwrap())
510            .user_agent_prefix("MyApp/1.0")
511            .build()
512            .unwrap();
513
514        let client = HttpClient::new("/admin/api/2024-10", &session, Some(&config));
515
516        let user_agent = client.default_headers().get("User-Agent").unwrap();
517        assert!(user_agent.starts_with("MyApp/1.0 | "));
518        assert!(user_agent.contains("Shopify API Library"));
519    }
520}