Skip to main content

shopify_sdk/clients/
http_response.rs

1//! HTTP response types for the Shopify API SDK.
2//!
3//! This module provides the [`HttpResponse`] type and related types for
4//! parsing and accessing API response data.
5
6use std::collections::HashMap;
7
8/// Information about a deprecated API endpoint or feature.
9///
10/// When Shopify deprecates an API endpoint, they include the
11/// `X-Shopify-API-Deprecated-Reason` header in responses. This struct
12/// provides structured access to that deprecation information.
13///
14/// # Example
15///
16/// ```rust
17/// use shopify_api::ApiDeprecationInfo;
18///
19/// let info = ApiDeprecationInfo {
20///     reason: "This endpoint will be removed in 2025-07".to_string(),
21///     path: Some("/admin/api/2024-01/products.json".to_string()),
22/// };
23///
24/// println!("Deprecation: {} at {:?}", info.reason, info.path);
25/// ```
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct ApiDeprecationInfo {
28    /// The reason for deprecation from the `X-Shopify-API-Deprecated-Reason` header.
29    pub reason: String,
30    /// The request path that triggered the deprecation notice, if available.
31    pub path: Option<String>,
32}
33
34/// Rate limit information parsed from the `X-Shopify-Shop-Api-Call-Limit` header.
35///
36/// The header format is "X/Y" where X is the current request count and Y is
37/// the bucket size.
38///
39/// # Example
40///
41/// ```rust
42/// use shopify_sdk::clients::ApiCallLimit;
43///
44/// let limit = ApiCallLimit::parse("40/80").unwrap();
45/// assert_eq!(limit.request_count, 40);
46/// assert_eq!(limit.bucket_size, 80);
47/// ```
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49pub struct ApiCallLimit {
50    /// The current number of requests made in this bucket.
51    pub request_count: u32,
52    /// The maximum number of requests allowed in this bucket.
53    pub bucket_size: u32,
54}
55
56impl ApiCallLimit {
57    /// Parses the rate limit header value.
58    ///
59    /// # Arguments
60    ///
61    /// * `header_value` - The header value in "X/Y" format
62    ///
63    /// # Returns
64    ///
65    /// `Some(ApiCallLimit)` if parsing succeeds, `None` otherwise.
66    #[must_use]
67    pub fn parse(header_value: &str) -> Option<Self> {
68        let parts: Vec<&str> = header_value.split('/').collect();
69        if parts.len() != 2 {
70            return None;
71        }
72
73        let request_count = parts[0].parse().ok()?;
74        let bucket_size = parts[1].parse().ok()?;
75
76        Some(Self {
77            request_count,
78            bucket_size,
79        })
80    }
81}
82
83/// Pagination information parsed from the `Link` header.
84///
85/// Shopify uses cursor-based pagination with `page_info` parameters in
86/// the Link header URLs.
87#[derive(Clone, Debug, Default, PartialEq, Eq)]
88pub struct PaginationInfo {
89    /// The `page_info` value for the previous page, if available.
90    pub prev_page_info: Option<String>,
91    /// The `page_info` value for the next page, if available.
92    pub next_page_info: Option<String>,
93}
94
95impl PaginationInfo {
96    /// Parses pagination info from a Link header value.
97    ///
98    /// The Link header format is:
99    /// `<url>; rel="next", <url>; rel="previous"`
100    ///
101    /// # Arguments
102    ///
103    /// * `header_value` - The Link header value
104    #[must_use]
105    pub fn parse_link_header(header_value: &str) -> Self {
106        let mut result = Self::default();
107
108        for link in header_value.split(',') {
109            let link = link.trim();
110
111            // Extract rel type
112            let rel = link.split(';').find_map(|part| {
113                let part = part.trim();
114                if part.starts_with("rel=") {
115                    // Remove rel=" and trailing "
116                    Some(part.trim_start_matches("rel=").trim_matches('"'))
117                } else {
118                    None
119                }
120            });
121
122            // Extract URL
123            let url = link
124                .split(';')
125                .next()
126                .map(|s| s.trim().trim_start_matches('<').trim_end_matches('>'));
127
128            if let (Some(rel), Some(url)) = (rel, url) {
129                // Extract page_info from URL query params
130                if let Some(page_info) = Self::extract_page_info(url) {
131                    match rel {
132                        "previous" => result.prev_page_info = Some(page_info),
133                        "next" => result.next_page_info = Some(page_info),
134                        _ => {}
135                    }
136                }
137            }
138        }
139
140        result
141    }
142
143    /// Extracts the `page_info` parameter from a URL.
144    fn extract_page_info(url: &str) -> Option<String> {
145        // Find the query string
146        let query_start = url.find('?')?;
147        let query = &url[query_start + 1..];
148
149        // Parse query parameters
150        for param in query.split('&') {
151            let mut parts = param.splitn(2, '=');
152            if let (Some(key), Some(value)) = (parts.next(), parts.next()) {
153                if key == "page_info" {
154                    return Some(value.to_string());
155                }
156            }
157        }
158
159        None
160    }
161}
162
163/// An HTTP response from the Shopify API.
164///
165/// Contains the response status code, headers, body, and parsed
166/// Shopify-specific header values like rate limits and pagination.
167#[derive(Clone, Debug)]
168pub struct HttpResponse {
169    /// The HTTP status code.
170    pub code: u16,
171    /// Response headers (headers may have multiple values).
172    pub headers: HashMap<String, Vec<String>>,
173    /// The parsed response body.
174    pub body: serde_json::Value,
175    /// Page info for the previous page (from Link header).
176    pub prev_page_info: Option<String>,
177    /// Page info for the next page (from Link header).
178    pub next_page_info: Option<String>,
179    /// Rate limit information (from `X-Shopify-Shop-Api-Call-Limit` header).
180    pub api_call_limit: Option<ApiCallLimit>,
181    /// Seconds to wait before retrying (from `Retry-After` header).
182    pub retry_request_after: Option<f64>,
183}
184
185impl HttpResponse {
186    /// Creates a new `HttpResponse` with automatic header parsing.
187    ///
188    /// This constructor parses Shopify-specific headers automatically:
189    /// - `X-Shopify-Shop-Api-Call-Limit` -> `api_call_limit`
190    /// - `Link` -> `prev_page_info`, `next_page_info`
191    /// - `Retry-After` -> `retry_request_after`
192    #[must_use]
193    pub fn new(code: u16, headers: HashMap<String, Vec<String>>, body: serde_json::Value) -> Self {
194        // Parse Link header for pagination
195        let (prev_page_info, next_page_info) = headers
196            .get("link")
197            .and_then(|values| values.first())
198            .map_or((None, None), |link| {
199                let info = PaginationInfo::parse_link_header(link);
200                (info.prev_page_info, info.next_page_info)
201            });
202
203        // Parse API call limit
204        let api_call_limit = headers
205            .get("x-shopify-shop-api-call-limit")
206            .and_then(|values| values.first())
207            .and_then(|value| ApiCallLimit::parse(value));
208
209        // Parse Retry-After
210        let retry_request_after = headers
211            .get("retry-after")
212            .and_then(|values| values.first())
213            .and_then(|value| value.parse::<f64>().ok());
214
215        Self {
216            code,
217            headers,
218            body,
219            prev_page_info,
220            next_page_info,
221            api_call_limit,
222            retry_request_after,
223        }
224    }
225
226    /// Returns `true` if the response status code is in the 2xx range.
227    #[must_use]
228    pub const fn is_ok(&self) -> bool {
229        self.code >= 200 && self.code <= 299
230    }
231
232    /// Returns the `X-Request-Id` header value, if present.
233    ///
234    /// This ID is useful for debugging and should be included in error reports.
235    #[must_use]
236    pub fn request_id(&self) -> Option<&str> {
237        self.headers
238            .get("x-request-id")
239            .and_then(|values| values.first())
240            .map(String::as_str)
241    }
242
243    /// Returns the `X-Shopify-API-Deprecated-Reason` header value, if present.
244    ///
245    /// When present, this indicates the API endpoint is deprecated and
246    /// should be updated.
247    #[must_use]
248    pub fn deprecation_reason(&self) -> Option<&str> {
249        self.headers
250            .get("x-shopify-api-deprecated-reason")
251            .and_then(|values| values.first())
252            .map(String::as_str)
253    }
254
255    /// Returns structured deprecation information if the response indicates deprecation.
256    ///
257    /// This method parses the `X-Shopify-API-Deprecated-Reason` header and returns
258    /// an [`ApiDeprecationInfo`] struct with the deprecation details.
259    ///
260    /// # Example
261    ///
262    /// ```rust
263    /// use shopify_api::HttpResponse;
264    /// use std::collections::HashMap;
265    /// use serde_json::json;
266    ///
267    /// let mut headers = HashMap::new();
268    /// headers.insert(
269    ///     "x-shopify-api-deprecated-reason".to_string(),
270    ///     vec!["This endpoint is deprecated".to_string()],
271    /// );
272    ///
273    /// let response = HttpResponse::new(200, headers, json!({}));
274    ///
275    /// if let Some(info) = response.deprecation_info() {
276    ///     println!("Warning: {}", info.reason);
277    /// }
278    /// ```
279    #[must_use]
280    pub fn deprecation_info(&self) -> Option<ApiDeprecationInfo> {
281        self.deprecation_reason().map(|reason| ApiDeprecationInfo {
282            reason: reason.to_string(),
283            path: None, // Path is set by the caller who knows the request path
284        })
285    }
286
287    /// Returns `true` if the response indicates a deprecated API endpoint.
288    ///
289    /// This checks for the presence of the `X-Shopify-API-Deprecated-Reason` header.
290    ///
291    /// # Example
292    ///
293    /// ```rust
294    /// use shopify_api::HttpResponse;
295    /// use std::collections::HashMap;
296    /// use serde_json::json;
297    ///
298    /// let mut headers = HashMap::new();
299    /// headers.insert(
300    ///     "x-shopify-api-deprecated-reason".to_string(),
301    ///     vec!["This endpoint is deprecated".to_string()],
302    /// );
303    ///
304    /// let response = HttpResponse::new(200, headers, json!({}));
305    /// assert!(response.is_deprecated());
306    /// ```
307    #[must_use]
308    pub fn is_deprecated(&self) -> bool {
309        self.deprecation_reason().is_some()
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316    use serde_json::json;
317
318    #[test]
319    fn test_is_ok_returns_true_for_2xx() {
320        for code in 200..=299 {
321            let response = HttpResponse::new(code, HashMap::new(), json!({}));
322            assert!(
323                response.is_ok(),
324                "Expected is_ok() to be true for code {code}"
325            );
326        }
327    }
328
329    #[test]
330    fn test_is_ok_returns_false_for_4xx_and_5xx() {
331        let response_400 = HttpResponse::new(400, HashMap::new(), json!({}));
332        assert!(!response_400.is_ok());
333
334        let response_404 = HttpResponse::new(404, HashMap::new(), json!({}));
335        assert!(!response_404.is_ok());
336
337        let response_429 = HttpResponse::new(429, HashMap::new(), json!({}));
338        assert!(!response_429.is_ok());
339
340        let response_500 = HttpResponse::new(500, HashMap::new(), json!({}));
341        assert!(!response_500.is_ok());
342    }
343
344    #[test]
345    fn test_api_call_limit_parsing() {
346        let limit = ApiCallLimit::parse("40/80").unwrap();
347        assert_eq!(limit.request_count, 40);
348        assert_eq!(limit.bucket_size, 80);
349
350        let limit = ApiCallLimit::parse("1/40").unwrap();
351        assert_eq!(limit.request_count, 1);
352        assert_eq!(limit.bucket_size, 40);
353
354        // Invalid formats
355        assert!(ApiCallLimit::parse("invalid").is_none());
356        assert!(ApiCallLimit::parse("40").is_none());
357        assert!(ApiCallLimit::parse("40/").is_none());
358        assert!(ApiCallLimit::parse("/80").is_none());
359        assert!(ApiCallLimit::parse("abc/def").is_none());
360    }
361
362    #[test]
363    fn test_link_header_parsing() {
364        // Both prev and next
365        let link = r#"<https://shop.myshopify.com/admin/api/2024-10/products.json?page_info=abc123>; rel="next", <https://shop.myshopify.com/admin/api/2024-10/products.json?page_info=xyz789>; rel="previous""#;
366        let info = PaginationInfo::parse_link_header(link);
367        assert_eq!(info.next_page_info, Some("abc123".to_string()));
368        assert_eq!(info.prev_page_info, Some("xyz789".to_string()));
369
370        // Only next
371        let link = r#"<https://shop.myshopify.com/admin/api/2024-10/products.json?page_info=abc123>; rel="next""#;
372        let info = PaginationInfo::parse_link_header(link);
373        assert_eq!(info.next_page_info, Some("abc123".to_string()));
374        assert!(info.prev_page_info.is_none());
375
376        // Only prev
377        let link = r#"<https://shop.myshopify.com/admin/api/2024-10/products.json?page_info=xyz789>; rel="previous""#;
378        let info = PaginationInfo::parse_link_header(link);
379        assert!(info.next_page_info.is_none());
380        assert_eq!(info.prev_page_info, Some("xyz789".to_string()));
381    }
382
383    #[test]
384    fn test_retry_after_parsing() {
385        let mut headers = HashMap::new();
386        headers.insert("retry-after".to_string(), vec!["2.5".to_string()]);
387
388        let response = HttpResponse::new(429, headers, json!({}));
389        assert!((response.retry_request_after.unwrap() - 2.5).abs() < f64::EPSILON);
390    }
391
392    #[test]
393    fn test_empty_body_returns_empty_json() {
394        let response = HttpResponse::new(200, HashMap::new(), json!({}));
395        assert_eq!(response.body, json!({}));
396    }
397
398    #[test]
399    fn test_request_id_extraction() {
400        let mut headers = HashMap::new();
401        headers.insert("x-request-id".to_string(), vec!["abc-123-xyz".to_string()]);
402
403        let response = HttpResponse::new(200, headers, json!({}));
404        assert_eq!(response.request_id(), Some("abc-123-xyz"));
405    }
406
407    #[test]
408    fn test_deprecation_reason_extraction() {
409        let mut headers = HashMap::new();
410        headers.insert(
411            "x-shopify-api-deprecated-reason".to_string(),
412            vec!["This endpoint is deprecated".to_string()],
413        );
414
415        let response = HttpResponse::new(200, headers, json!({}));
416        assert_eq!(
417            response.deprecation_reason(),
418            Some("This endpoint is deprecated")
419        );
420    }
421
422    #[test]
423    fn test_deprecation_info_parses_header() {
424        let mut headers = HashMap::new();
425        headers.insert(
426            "x-shopify-api-deprecated-reason".to_string(),
427            vec!["This endpoint will be removed in 2025-07".to_string()],
428        );
429
430        let response = HttpResponse::new(200, headers, json!({}));
431        let info = response.deprecation_info().unwrap();
432
433        assert_eq!(info.reason, "This endpoint will be removed in 2025-07");
434        assert!(info.path.is_none()); // Path is set by caller
435    }
436
437    #[test]
438    fn test_deprecation_info_returns_none_when_not_deprecated() {
439        let response = HttpResponse::new(200, HashMap::new(), json!({}));
440        assert!(response.deprecation_info().is_none());
441    }
442
443    #[test]
444    fn test_is_deprecated_true_when_header_present() {
445        let mut headers = HashMap::new();
446        headers.insert(
447            "x-shopify-api-deprecated-reason".to_string(),
448            vec!["Deprecated".to_string()],
449        );
450
451        let response = HttpResponse::new(200, headers, json!({}));
452        assert!(response.is_deprecated());
453    }
454
455    #[test]
456    fn test_is_deprecated_false_when_no_header() {
457        let response = HttpResponse::new(200, HashMap::new(), json!({}));
458        assert!(!response.is_deprecated());
459    }
460}