Skip to main content

shopify_sdk/clients/
http_request.rs

1//! HTTP request types for the Shopify API SDK.
2//!
3//! This module provides the [`HttpRequest`] type and its builder for
4//! constructing requests to the Shopify API.
5
6use std::collections::HashMap;
7use std::fmt;
8
9use crate::clients::errors::InvalidHttpRequestError;
10
11/// HTTP methods supported by the Shopify API.
12///
13/// The SDK supports the four standard HTTP methods used by REST APIs.
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub enum HttpMethod {
16    /// HTTP GET method for retrieving resources.
17    Get,
18    /// HTTP POST method for creating resources.
19    Post,
20    /// HTTP PUT method for updating resources.
21    Put,
22    /// HTTP DELETE method for removing resources.
23    Delete,
24}
25
26impl fmt::Display for HttpMethod {
27    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28        match self {
29            Self::Get => write!(f, "get"),
30            Self::Post => write!(f, "post"),
31            Self::Put => write!(f, "put"),
32            Self::Delete => write!(f, "delete"),
33        }
34    }
35}
36
37/// Content type for HTTP request bodies.
38///
39/// Specifies the format of the request body and sets the appropriate
40/// `Content-Type` header.
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42pub enum DataType {
43    /// JSON content type (`application/json`).
44    Json,
45    /// GraphQL content type (`application/graphql`).
46    GraphQL,
47}
48
49impl DataType {
50    /// Returns the MIME type string for this data type.
51    #[must_use]
52    pub const fn as_content_type(&self) -> &'static str {
53        match self {
54            Self::Json => "application/json",
55            Self::GraphQL => "application/graphql",
56        }
57    }
58}
59
60/// An HTTP request to be sent to the Shopify API.
61///
62/// Use [`HttpRequest::builder`] to construct requests with the builder pattern.
63///
64/// # Example
65///
66/// ```rust
67/// use shopify_sdk::clients::{HttpRequest, HttpMethod, DataType};
68/// use serde_json::json;
69///
70/// // GET request
71/// let get_request = HttpRequest::builder(HttpMethod::Get, "products.json")
72///     .build()
73///     .unwrap();
74///
75/// // POST request with JSON body
76/// let post_request = HttpRequest::builder(HttpMethod::Post, "products.json")
77///     .body(json!({"product": {"title": "New Product"}}))
78///     .body_type(DataType::Json)
79///     .build()
80///     .unwrap();
81/// ```
82#[derive(Clone, Debug)]
83pub struct HttpRequest {
84    /// The HTTP method for this request.
85    pub http_method: HttpMethod,
86    /// The path (relative to base path) for this request.
87    pub path: String,
88    /// The request body, if any.
89    pub body: Option<serde_json::Value>,
90    /// The content type of the body.
91    pub body_type: Option<DataType>,
92    /// Query parameters to append to the URL.
93    pub query: Option<HashMap<String, String>>,
94    /// Additional headers to include in the request.
95    pub extra_headers: Option<HashMap<String, String>>,
96    /// Number of times to attempt the request (default: 1).
97    pub tries: u32,
98}
99
100impl HttpRequest {
101    /// Creates a new builder for constructing an `HttpRequest`.
102    ///
103    /// # Arguments
104    ///
105    /// * `method` - The HTTP method for the request
106    /// * `path` - The path (relative to base path) for the request
107    ///
108    /// # Example
109    ///
110    /// ```rust
111    /// use shopify_sdk::clients::{HttpRequest, HttpMethod};
112    ///
113    /// let request = HttpRequest::builder(HttpMethod::Get, "products.json")
114    ///     .tries(3)
115    ///     .build()
116    ///     .unwrap();
117    /// ```
118    #[must_use]
119    pub fn builder(method: HttpMethod, path: impl Into<String>) -> HttpRequestBuilder {
120        HttpRequestBuilder::new(method, path)
121    }
122
123    /// Validates the request, ensuring it meets all requirements.
124    ///
125    /// # Errors
126    ///
127    /// Returns [`InvalidHttpRequestError`] if:
128    /// - `body` is `Some` but `body_type` is `None`
129    /// - `http_method` is `Post` or `Put` but `body` is `None`
130    pub fn verify(&self) -> Result<(), InvalidHttpRequestError> {
131        // Validate body_type is set when body is present
132        if self.body.is_some() && self.body_type.is_none() {
133            return Err(InvalidHttpRequestError::MissingBodyType);
134        }
135
136        // Validate body is present for POST/PUT methods
137        if matches!(self.http_method, HttpMethod::Post | HttpMethod::Put) && self.body.is_none() {
138            return Err(InvalidHttpRequestError::MissingBody {
139                method: self.http_method.to_string(),
140            });
141        }
142
143        Ok(())
144    }
145}
146
147/// Builder for constructing [`HttpRequest`] instances.
148///
149/// Provides a fluent API for building requests with optional parameters.
150#[derive(Debug)]
151pub struct HttpRequestBuilder {
152    http_method: HttpMethod,
153    path: String,
154    body: Option<serde_json::Value>,
155    body_type: Option<DataType>,
156    query: Option<HashMap<String, String>>,
157    extra_headers: Option<HashMap<String, String>>,
158    tries: u32,
159}
160
161impl HttpRequestBuilder {
162    /// Creates a new builder with the required method and path.
163    fn new(method: HttpMethod, path: impl Into<String>) -> Self {
164        Self {
165            http_method: method,
166            path: path.into(),
167            body: None,
168            body_type: None,
169            query: None,
170            extra_headers: None,
171            tries: 1,
172        }
173    }
174
175    /// Sets the request body.
176    ///
177    /// When setting a body, you must also set the body type via [`body_type`](Self::body_type).
178    #[must_use]
179    pub fn body(mut self, body: impl Into<serde_json::Value>) -> Self {
180        self.body = Some(body.into());
181        self
182    }
183
184    /// Sets the content type of the request body.
185    #[must_use]
186    pub const fn body_type(mut self, body_type: DataType) -> Self {
187        self.body_type = Some(body_type);
188        self
189    }
190
191    /// Sets all query parameters at once.
192    #[must_use]
193    pub fn query(mut self, query: HashMap<String, String>) -> Self {
194        self.query = Some(query);
195        self
196    }
197
198    /// Adds a single query parameter.
199    #[must_use]
200    pub fn query_param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
201        self.query
202            .get_or_insert_with(HashMap::new)
203            .insert(key.into(), value.into());
204        self
205    }
206
207    /// Sets all extra headers at once.
208    #[must_use]
209    pub fn extra_headers(mut self, headers: HashMap<String, String>) -> Self {
210        self.extra_headers = Some(headers);
211        self
212    }
213
214    /// Adds a single extra header.
215    #[must_use]
216    pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
217        self.extra_headers
218            .get_or_insert_with(HashMap::new)
219            .insert(key.into(), value.into());
220        self
221    }
222
223    /// Sets the number of times to attempt the request.
224    ///
225    /// Default is 1 (no retries). Set to a higher value to enable
226    /// automatic retries for 429 and 500 responses.
227    #[must_use]
228    pub const fn tries(mut self, tries: u32) -> Self {
229        self.tries = tries;
230        self
231    }
232
233    /// Builds the [`HttpRequest`], validating it in the process.
234    ///
235    /// # Errors
236    ///
237    /// Returns [`InvalidHttpRequestError`] if the request fails validation.
238    pub fn build(self) -> Result<HttpRequest, InvalidHttpRequestError> {
239        let request = HttpRequest {
240            http_method: self.http_method,
241            path: self.path,
242            body: self.body,
243            body_type: self.body_type,
244            query: self.query,
245            extra_headers: self.extra_headers,
246            tries: self.tries,
247        };
248        request.verify()?;
249        Ok(request)
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use serde_json::json;
257
258    #[test]
259    fn test_http_method_display() {
260        assert_eq!(HttpMethod::Get.to_string(), "get");
261        assert_eq!(HttpMethod::Post.to_string(), "post");
262        assert_eq!(HttpMethod::Put.to_string(), "put");
263        assert_eq!(HttpMethod::Delete.to_string(), "delete");
264    }
265
266    #[test]
267    fn test_data_type_content_type() {
268        assert_eq!(DataType::Json.as_content_type(), "application/json");
269        assert_eq!(DataType::GraphQL.as_content_type(), "application/graphql");
270    }
271
272    #[test]
273    fn test_builder_creates_valid_get_request() {
274        let request = HttpRequest::builder(HttpMethod::Get, "products.json")
275            .build()
276            .unwrap();
277
278        assert_eq!(request.http_method, HttpMethod::Get);
279        assert_eq!(request.path, "products.json");
280        assert!(request.body.is_none());
281        assert!(request.body_type.is_none());
282        assert_eq!(request.tries, 1);
283    }
284
285    #[test]
286    fn test_builder_creates_valid_post_request() {
287        let request = HttpRequest::builder(HttpMethod::Post, "products.json")
288            .body(json!({"product": {"title": "Test"}}))
289            .body_type(DataType::Json)
290            .build()
291            .unwrap();
292
293        assert_eq!(request.http_method, HttpMethod::Post);
294        assert!(request.body.is_some());
295        assert_eq!(request.body_type, Some(DataType::Json));
296    }
297
298    #[test]
299    fn test_verify_requires_body_for_post() {
300        let result = HttpRequest::builder(HttpMethod::Post, "products.json").build();
301
302        assert!(matches!(
303            result,
304            Err(InvalidHttpRequestError::MissingBody { method }) if method == "post"
305        ));
306    }
307
308    #[test]
309    fn test_verify_requires_body_for_put() {
310        let result = HttpRequest::builder(HttpMethod::Put, "products/123.json").build();
311
312        assert!(matches!(
313            result,
314            Err(InvalidHttpRequestError::MissingBody { method }) if method == "put"
315        ));
316    }
317
318    #[test]
319    fn test_verify_requires_body_type_when_body_present() {
320        let request = HttpRequest {
321            http_method: HttpMethod::Get,
322            path: "test".to_string(),
323            body: Some(json!({"key": "value"})),
324            body_type: None,
325            query: None,
326            extra_headers: None,
327            tries: 1,
328        };
329
330        assert!(matches!(
331            request.verify(),
332            Err(InvalidHttpRequestError::MissingBodyType)
333        ));
334    }
335
336    #[test]
337    fn test_builder_with_query_params() {
338        let request = HttpRequest::builder(HttpMethod::Get, "products.json")
339            .query_param("limit", "50")
340            .query_param("page_info", "abc123")
341            .build()
342            .unwrap();
343
344        let query = request.query.unwrap();
345        assert_eq!(query.get("limit"), Some(&"50".to_string()));
346        assert_eq!(query.get("page_info"), Some(&"abc123".to_string()));
347    }
348
349    #[test]
350    fn test_builder_with_extra_headers() {
351        let request = HttpRequest::builder(HttpMethod::Get, "products.json")
352            .header("X-Custom-Header", "custom-value")
353            .build()
354            .unwrap();
355
356        let headers = request.extra_headers.unwrap();
357        assert_eq!(
358            headers.get("X-Custom-Header"),
359            Some(&"custom-value".to_string())
360        );
361    }
362
363    #[test]
364    fn test_default_tries_is_one() {
365        let request = HttpRequest::builder(HttpMethod::Get, "test")
366            .build()
367            .unwrap();
368        assert_eq!(request.tries, 1);
369    }
370}