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}