Skip to main content

shopify_sdk/clients/rest/
client.rs

1//! REST client implementation for Shopify Admin API.
2//!
3//! This module provides the [`RestClient`] type for making REST API requests
4//! to the Shopify Admin API with automatic path normalization and retry handling.
5
6use std::collections::HashMap;
7
8use crate::auth::Session;
9use crate::clients::rest::RestError;
10use crate::clients::{DataType, HttpClient, HttpMethod, HttpRequest, HttpResponse};
11use crate::config::{ApiVersion, ShopifyConfig};
12
13/// REST API client for Shopify Admin API.
14///
15/// Provides convenient methods (`get`, `post`, `put`, `delete`) for making
16/// REST API requests with automatic path normalization and retry handling.
17///
18/// # Thread Safety
19///
20/// `RestClient` is `Send + Sync`, making it safe to share across async tasks.
21///
22/// # Deprecation Notice
23///
24/// The Shopify Admin REST API is deprecated. A warning is logged when this
25/// client is constructed. Consider migrating to the GraphQL Admin API.
26///
27/// # Example
28///
29/// ```rust,ignore
30/// use shopify_sdk::{RestClient, Session, ShopDomain};
31///
32/// let session = Session::new(
33///     "session-id".to_string(),
34///     ShopDomain::new("my-store").unwrap(),
35///     "access-token".to_string(),
36///     "read_products".parse().unwrap(),
37///     false,
38///     None,
39/// );
40///
41/// let client = RestClient::new(&session, None)?;
42///
43/// // GET request
44/// let response = client.get("products", None).await?;
45///
46/// // POST request with body
47/// let body = serde_json::json!({"product": {"title": "New Product"}});
48/// let response = client.post("products", body, None).await?;
49/// ```
50#[derive(Debug)]
51pub struct RestClient {
52    /// The internal HTTP client for making requests.
53    http_client: HttpClient,
54    /// The API version being used.
55    api_version: ApiVersion,
56}
57
58// Verify RestClient is Send + Sync at compile time
59const _: fn() = || {
60    const fn assert_send_sync<T: Send + Sync>() {}
61    assert_send_sync::<RestClient>();
62};
63
64impl RestClient {
65    /// Creates a new REST client for the given session.
66    ///
67    /// This constructor uses the API version from the configuration, or
68    /// falls back to the latest stable version if not specified.
69    ///
70    /// # Arguments
71    ///
72    /// * `session` - The session providing shop domain and access token
73    /// * `config` - Optional configuration for API version and other settings
74    ///
75    /// # Errors
76    ///
77    /// Returns [`RestError::RestApiDisabled`] if REST API is disabled in config
78    /// (future-proofing for when REST is fully deprecated).
79    ///
80    /// # Example
81    ///
82    /// ```rust,ignore
83    /// use shopify_sdk::{RestClient, Session, ShopDomain};
84    ///
85    /// let session = Session::new(
86    ///     "session-id".to_string(),
87    ///     ShopDomain::new("my-store").unwrap(),
88    ///     "access-token".to_string(),
89    ///     "read_products".parse().unwrap(),
90    ///     false,
91    ///     None,
92    /// );
93    ///
94    /// let client = RestClient::new(&session, None)?;
95    /// ```
96    pub fn new(session: &Session, config: Option<&ShopifyConfig>) -> Result<Self, RestError> {
97        let api_version = config.map_or_else(ApiVersion::latest, |c| c.api_version().clone());
98
99        Self::create_client(session, config, api_version)
100    }
101
102    /// Creates a new REST client with a specific API version override.
103    ///
104    /// This constructor allows overriding the API version from configuration.
105    ///
106    /// # Arguments
107    ///
108    /// * `session` - The session providing shop domain and access token
109    /// * `config` - Optional configuration for other settings
110    /// * `version` - The API version to use for requests
111    ///
112    /// # Errors
113    ///
114    /// Returns [`RestError::RestApiDisabled`] if REST API is disabled in config.
115    ///
116    /// # Example
117    ///
118    /// ```rust,ignore
119    /// use shopify_sdk::{RestClient, Session, ShopDomain, ApiVersion};
120    ///
121    /// let session = Session::new(
122    ///     "session-id".to_string(),
123    ///     ShopDomain::new("my-store").unwrap(),
124    ///     "access-token".to_string(),
125    ///     "read_products".parse().unwrap(),
126    ///     false,
127    ///     None,
128    /// );
129    ///
130    /// // Use a specific API version
131    /// let client = RestClient::with_version(&session, None, ApiVersion::V2024_10)?;
132    /// ```
133    pub fn with_version(
134        session: &Session,
135        config: Option<&ShopifyConfig>,
136        version: ApiVersion,
137    ) -> Result<Self, RestError> {
138        let config_version = config.map(|c| c.api_version().clone());
139
140        // Log debug message when overriding version (matching Ruby SDK pattern)
141        if let Some(ref cfg_version) = config_version {
142            if &version == cfg_version {
143                tracing::debug!(
144                    "Rest client has a redundant API version override to the default {}",
145                    cfg_version
146                );
147            } else {
148                tracing::debug!(
149                    "Rest client overriding default API version {} with {}",
150                    cfg_version,
151                    version
152                );
153            }
154        }
155
156        Self::create_client(session, config, version)
157    }
158
159    /// Internal helper to create the client with shared logic.
160    ///
161    /// Returns a `Result` to allow for future error handling when REST API
162    /// is disabled via configuration (future-proofing for REST deprecation).
163    #[allow(clippy::unnecessary_wraps)]
164    fn create_client(
165        session: &Session,
166        config: Option<&ShopifyConfig>,
167        api_version: ApiVersion,
168    ) -> Result<Self, RestError> {
169        // Log deprecation warning (matching Ruby SDK pattern)
170        tracing::warn!(
171            "The REST Admin API is deprecated. Consider migrating to GraphQL. See: https://www.shopify.com/ca/partners/blog/all-in-on-graphql"
172        );
173
174        // Construct base path: /admin/api/{version}
175        let base_path = format!("/admin/api/{api_version}");
176
177        // Create internal HTTP client
178        let http_client = HttpClient::new(base_path, session, config);
179
180        Ok(Self {
181            http_client,
182            api_version,
183        })
184    }
185
186    /// Returns the API version being used by this client.
187    #[must_use]
188    pub const fn api_version(&self) -> &ApiVersion {
189        &self.api_version
190    }
191
192    /// Sends a GET request to the specified path.
193    ///
194    /// # Arguments
195    ///
196    /// * `path` - The REST API path (e.g., "products", "orders/123")
197    /// * `query` - Optional query parameters
198    ///
199    /// # Errors
200    ///
201    /// Returns [`RestError::InvalidPath`] if the path is invalid (e.g., empty).
202    /// Returns [`RestError::Http`] for HTTP-level errors.
203    ///
204    /// # Example
205    ///
206    /// ```rust,ignore
207    /// // Simple GET
208    /// let response = client.get("products", None).await?;
209    ///
210    /// // GET with query parameters
211    /// let mut query = HashMap::new();
212    /// query.insert("limit".to_string(), "50".to_string());
213    /// let response = client.get("products", Some(query)).await?;
214    /// ```
215    pub async fn get(
216        &self,
217        path: &str,
218        query: Option<HashMap<String, String>>,
219    ) -> Result<HttpResponse, RestError> {
220        self.make_request(HttpMethod::Get, path, None, query, None)
221            .await
222    }
223
224    /// Sends a GET request with retry configuration.
225    ///
226    /// # Arguments
227    ///
228    /// * `path` - The REST API path
229    /// * `query` - Optional query parameters
230    /// * `tries` - Number of times to attempt the request (default: 1)
231    ///
232    /// # Errors
233    ///
234    /// Returns [`RestError::InvalidPath`] if the path is invalid.
235    /// Returns [`RestError::Http`] for HTTP-level errors, including retry exhaustion.
236    pub async fn get_with_tries(
237        &self,
238        path: &str,
239        query: Option<HashMap<String, String>>,
240        tries: u32,
241    ) -> Result<HttpResponse, RestError> {
242        self.make_request(HttpMethod::Get, path, None, query, Some(tries))
243            .await
244    }
245
246    /// Sends a POST request to the specified path.
247    ///
248    /// # Arguments
249    ///
250    /// * `path` - The REST API path (e.g., "products")
251    /// * `body` - The JSON body to send
252    /// * `query` - Optional query parameters
253    ///
254    /// # Errors
255    ///
256    /// Returns [`RestError::InvalidPath`] if the path is invalid.
257    /// Returns [`RestError::Http`] for HTTP-level errors.
258    ///
259    /// # Example
260    ///
261    /// ```rust,ignore
262    /// let body = serde_json::json!({
263    ///     "product": {
264    ///         "title": "New Product",
265    ///         "body_html": "<p>Description</p>"
266    ///     }
267    /// });
268    /// let response = client.post("products", body, None).await?;
269    /// ```
270    pub async fn post(
271        &self,
272        path: &str,
273        body: serde_json::Value,
274        query: Option<HashMap<String, String>>,
275    ) -> Result<HttpResponse, RestError> {
276        self.make_request(HttpMethod::Post, path, Some(body), query, None)
277            .await
278    }
279
280    /// Sends a POST request with retry configuration.
281    ///
282    /// # Errors
283    ///
284    /// Returns [`RestError::InvalidPath`] if the path is invalid.
285    /// Returns [`RestError::Http`] for HTTP-level errors, including retry exhaustion.
286    pub async fn post_with_tries(
287        &self,
288        path: &str,
289        body: serde_json::Value,
290        query: Option<HashMap<String, String>>,
291        tries: u32,
292    ) -> Result<HttpResponse, RestError> {
293        self.make_request(HttpMethod::Post, path, Some(body), query, Some(tries))
294            .await
295    }
296
297    /// Sends a PUT request to the specified path.
298    ///
299    /// # Arguments
300    ///
301    /// * `path` - The REST API path (e.g., "products/123")
302    /// * `body` - The JSON body to send
303    /// * `query` - Optional query parameters
304    ///
305    /// # Errors
306    ///
307    /// Returns [`RestError::InvalidPath`] if the path is invalid.
308    /// Returns [`RestError::Http`] for HTTP-level errors.
309    ///
310    /// # Example
311    ///
312    /// ```rust,ignore
313    /// let body = serde_json::json!({
314    ///     "product": {
315    ///         "title": "Updated Title"
316    ///     }
317    /// });
318    /// let response = client.put("products/123", body, None).await?;
319    /// ```
320    pub async fn put(
321        &self,
322        path: &str,
323        body: serde_json::Value,
324        query: Option<HashMap<String, String>>,
325    ) -> Result<HttpResponse, RestError> {
326        self.make_request(HttpMethod::Put, path, Some(body), query, None)
327            .await
328    }
329
330    /// Sends a PUT request with retry configuration.
331    ///
332    /// # Errors
333    ///
334    /// Returns [`RestError::InvalidPath`] if the path is invalid.
335    /// Returns [`RestError::Http`] for HTTP-level errors, including retry exhaustion.
336    pub async fn put_with_tries(
337        &self,
338        path: &str,
339        body: serde_json::Value,
340        query: Option<HashMap<String, String>>,
341        tries: u32,
342    ) -> Result<HttpResponse, RestError> {
343        self.make_request(HttpMethod::Put, path, Some(body), query, Some(tries))
344            .await
345    }
346
347    /// Sends a DELETE request to the specified path.
348    ///
349    /// # Arguments
350    ///
351    /// * `path` - The REST API path (e.g., "products/123")
352    /// * `query` - Optional query parameters
353    ///
354    /// # Errors
355    ///
356    /// Returns [`RestError::InvalidPath`] if the path is invalid.
357    /// Returns [`RestError::Http`] for HTTP-level errors.
358    ///
359    /// # Example
360    ///
361    /// ```rust,ignore
362    /// let response = client.delete("products/123", None).await?;
363    /// ```
364    pub async fn delete(
365        &self,
366        path: &str,
367        query: Option<HashMap<String, String>>,
368    ) -> Result<HttpResponse, RestError> {
369        self.make_request(HttpMethod::Delete, path, None, query, None)
370            .await
371    }
372
373    /// Sends a DELETE request with retry configuration.
374    ///
375    /// # Errors
376    ///
377    /// Returns [`RestError::InvalidPath`] if the path is invalid.
378    /// Returns [`RestError::Http`] for HTTP-level errors, including retry exhaustion.
379    pub async fn delete_with_tries(
380        &self,
381        path: &str,
382        query: Option<HashMap<String, String>>,
383        tries: u32,
384    ) -> Result<HttpResponse, RestError> {
385        self.make_request(HttpMethod::Delete, path, None, query, Some(tries))
386            .await
387    }
388
389    /// Internal helper to build and send requests.
390    async fn make_request(
391        &self,
392        method: HttpMethod,
393        path: &str,
394        body: Option<serde_json::Value>,
395        query: Option<HashMap<String, String>>,
396        tries: Option<u32>,
397    ) -> Result<HttpResponse, RestError> {
398        // Normalize the path
399        let normalized_path = normalize_path(path)?;
400
401        // Build the request
402        let mut builder = HttpRequest::builder(method, &normalized_path);
403
404        // Add body if present
405        if let Some(body_value) = body {
406            builder = builder.body(body_value).body_type(DataType::Json);
407        }
408
409        // Add query parameters if present
410        if let Some(query_params) = query {
411            builder = builder.query(query_params);
412        }
413
414        // Set tries (default 1)
415        if let Some(t) = tries {
416            builder = builder.tries(t);
417        }
418
419        // Build and send the request
420        let request = builder.build().map_err(|e| RestError::Http(e.into()))?;
421
422        self.http_client.request(request).await.map_err(Into::into)
423    }
424}
425
426/// Normalizes a REST API path following Ruby SDK conventions.
427///
428/// This function:
429/// 1. Strips leading `/` characters
430/// 2. Strips trailing `.json` suffix
431/// 3. Appends `.json` suffix
432/// 4. Returns an error for empty paths
433///
434/// # Examples
435///
436/// ```rust,ignore
437/// assert_eq!(normalize_path("products")?, "products.json");
438/// assert_eq!(normalize_path("/products")?, "products.json");
439/// assert_eq!(normalize_path("products.json")?, "products.json");
440/// assert_eq!(normalize_path("/products.json")?, "products.json");
441/// ```
442fn normalize_path(path: &str) -> Result<String, RestError> {
443    // Strip leading slashes
444    let path = path.trim_start_matches('/');
445
446    // Strip trailing .json
447    let path = path.strip_suffix(".json").unwrap_or(path);
448
449    // Check for empty path
450    if path.is_empty() {
451        return Err(RestError::InvalidPath {
452            path: String::new(),
453        });
454    }
455
456    // Append .json suffix
457    Ok(format!("{path}.json"))
458}
459
460/// Checks if a path has an admin/ prefix.
461///
462/// Paths starting with "admin/" bypass the base path construction
463/// and are used directly with the base URI.
464#[allow(dead_code)]
465fn has_admin_prefix(path: &str) -> bool {
466    path.starts_with("admin/")
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472    use crate::auth::AuthScopes;
473    use crate::config::ShopDomain;
474
475    fn create_test_session() -> Session {
476        Session::new(
477            "test-session".to_string(),
478            ShopDomain::new("test-shop").unwrap(),
479            "test-access-token".to_string(),
480            AuthScopes::new(),
481            false,
482            None,
483        )
484    }
485
486    // === Path Normalization Tests ===
487
488    #[test]
489    fn test_normalize_path_strips_leading_slash() {
490        let result = normalize_path("/products").unwrap();
491        assert_eq!(result, "products.json");
492    }
493
494    #[test]
495    fn test_normalize_path_strips_trailing_json() {
496        let result = normalize_path("products.json").unwrap();
497        assert_eq!(result, "products.json");
498    }
499
500    #[test]
501    fn test_normalize_path_strips_both_leading_slash_and_trailing_json() {
502        let result = normalize_path("/products.json").unwrap();
503        assert_eq!(result, "products.json");
504    }
505
506    #[test]
507    fn test_normalize_path_adds_json_suffix() {
508        let result = normalize_path("products").unwrap();
509        assert_eq!(result, "products.json");
510    }
511
512    #[test]
513    fn test_normalize_path_handles_nested_paths() {
514        let result = normalize_path("/admin/api/2024-10/products").unwrap();
515        assert_eq!(result, "admin/api/2024-10/products.json");
516    }
517
518    #[test]
519    fn test_normalize_path_handles_double_slashes() {
520        let result = normalize_path("//products").unwrap();
521        assert_eq!(result, "products.json");
522    }
523
524    #[test]
525    fn test_normalize_path_empty_path_returns_error() {
526        let result = normalize_path("");
527        assert!(matches!(result, Err(RestError::InvalidPath { path }) if path.is_empty()));
528    }
529
530    #[test]
531    fn test_normalize_path_only_slash_returns_error() {
532        let result = normalize_path("/");
533        assert!(matches!(result, Err(RestError::InvalidPath { path }) if path.is_empty()));
534    }
535
536    #[test]
537    fn test_normalize_path_only_json_returns_error() {
538        // "/.json" after stripping "/" becomes ".json", after stripping ".json" becomes ""
539        let result = normalize_path("/.json");
540        assert!(matches!(result, Err(RestError::InvalidPath { path }) if path.is_empty()));
541    }
542
543    // === Admin Prefix Tests ===
544
545    #[test]
546    fn test_has_admin_prefix_returns_true() {
547        assert!(has_admin_prefix("admin/products.json"));
548        assert!(has_admin_prefix("admin/api/2024-10/products.json"));
549    }
550
551    #[test]
552    fn test_has_admin_prefix_returns_false() {
553        assert!(!has_admin_prefix("products.json"));
554        assert!(!has_admin_prefix("/admin/products.json")); // Leading slash means it doesn't start with "admin/"
555    }
556
557    // === RestClient Construction Tests ===
558
559    #[test]
560    fn test_rest_client_new_creates_client_with_latest_version() {
561        let session = create_test_session();
562        let client = RestClient::new(&session, None).unwrap();
563
564        assert_eq!(client.api_version(), &ApiVersion::latest());
565    }
566
567    #[test]
568    fn test_rest_client_with_version_overrides_config() {
569        let session = create_test_session();
570        let client = RestClient::with_version(&session, None, ApiVersion::V2024_10).unwrap();
571
572        assert_eq!(client.api_version(), &ApiVersion::V2024_10);
573    }
574
575    #[test]
576    fn test_rest_client_is_send_sync() {
577        fn assert_send_sync<T: Send + Sync>() {}
578        assert_send_sync::<RestClient>();
579    }
580
581    #[test]
582    fn test_rest_client_constructs_correct_base_path() {
583        let session = create_test_session();
584        let client = RestClient::with_version(&session, None, ApiVersion::V2024_10).unwrap();
585
586        // The internal HttpClient should have the correct base path
587        // We can verify this indirectly through the api_version
588        assert_eq!(client.api_version(), &ApiVersion::V2024_10);
589    }
590}