Skip to main content

shopify_sdk/rest/
errors.rs

1//! Resource-specific error types for REST API operations.
2//!
3//! This module contains error types for REST resource operations, extending
4//! the base [`RestError`](crate::clients::RestError) with resource-specific
5//! semantics like `NotFound` and `ValidationFailed`.
6//!
7//! # Error Handling
8//!
9//! The SDK maps HTTP status codes to semantic error variants:
10//!
11//! - **404**: [`ResourceError::NotFound`] - Resource doesn't exist
12//! - **422**: [`ResourceError::ValidationFailed`] - Validation errors from the API
13//! - **Other 4xx/5xx**: [`ResourceError::Http`] - Wrapped HTTP error
14//!
15//! # Example
16//!
17//! ```rust,ignore
18//! use shopify_sdk::rest::{RestResource, ResourceError};
19//!
20//! match Product::find(&client, 123, None).await {
21//!     Ok(product) => println!("Found: {}", product.title),
22//!     Err(ResourceError::NotFound { resource, id }) => {
23//!         println!("{} with id {} not found", resource, id);
24//!     }
25//!     Err(ResourceError::ValidationFailed { errors, .. }) => {
26//!         for (field, messages) in errors {
27//!             println!("{}: {:?}", field, messages);
28//!         }
29//!     }
30//!     Err(e) => println!("Other error: {}", e),
31//! }
32//! ```
33
34use std::collections::HashMap;
35
36use crate::clients::{HttpError, RestError};
37use thiserror::Error;
38
39/// Error type for REST resource operations.
40///
41/// This enum provides semantic error types for resource operations,
42/// mapping HTTP error codes to meaningful variants while preserving
43/// the request ID for debugging.
44///
45/// # Example
46///
47/// ```rust
48/// use shopify_sdk::rest::ResourceError;
49/// use std::collections::HashMap;
50///
51/// // Not found error
52/// let error = ResourceError::NotFound {
53///     resource: "Product",
54///     id: "123".to_string(),
55/// };
56/// assert!(error.to_string().contains("Product"));
57/// assert!(error.to_string().contains("123"));
58///
59/// // Validation failed error
60/// let mut errors = HashMap::new();
61/// errors.insert("title".to_string(), vec!["can't be blank".to_string()]);
62/// let error = ResourceError::ValidationFailed {
63///     errors,
64///     request_id: Some("abc-123".to_string()),
65/// };
66/// assert!(error.to_string().contains("Validation failed"));
67/// ```
68#[derive(Debug, Error)]
69pub enum ResourceError {
70    /// The resource was not found (HTTP 404).
71    ///
72    /// This error is returned when attempting to find, update, or delete
73    /// a resource that doesn't exist.
74    #[error("{resource} with id {id} not found")]
75    NotFound {
76        /// The type name of the resource (e.g., "Product", "Order").
77        resource: &'static str,
78        /// The ID that was requested.
79        id: String,
80    },
81
82    /// Validation failed for the resource (HTTP 422).
83    ///
84    /// This error is returned when the API rejects a create or update
85    /// request due to validation errors.
86    #[error("Validation failed: {errors:?}")]
87    ValidationFailed {
88        /// A map of field names to error messages.
89        errors: HashMap<String, Vec<String>>,
90        /// The request ID for debugging (from X-Request-Id header).
91        request_id: Option<String>,
92    },
93
94    /// No valid path matches the provided IDs and operation.
95    ///
96    /// This error is returned when attempting an operation without
97    /// providing the required parent resource IDs.
98    #[error("Cannot resolve path for {resource}::{operation} with provided IDs")]
99    PathResolutionFailed {
100        /// The type name of the resource.
101        resource: &'static str,
102        /// The operation being attempted (e.g., "find", "all", "delete").
103        operation: &'static str,
104    },
105
106    /// An HTTP-level error occurred.
107    ///
108    /// This variant wraps [`HttpError`] for errors that don't map to
109    /// a specific resource error type.
110    #[error(transparent)]
111    Http(#[from] HttpError),
112
113    /// A REST-level error occurred.
114    ///
115    /// This variant wraps [`RestError`] for REST client errors.
116    #[error(transparent)]
117    Rest(#[from] RestError),
118}
119
120impl ResourceError {
121    /// Creates a `ResourceError` from an HTTP response status code.
122    ///
123    /// Maps HTTP status codes to semantic error variants:
124    /// - 404 -> `NotFound`
125    /// - 422 -> `ValidationFailed` (parsing errors from body)
126    /// - Other -> `Http`
127    ///
128    /// # Arguments
129    ///
130    /// * `code` - The HTTP status code
131    /// * `body` - The response body as JSON
132    /// * `resource` - The resource type name (e.g., "Product")
133    /// * `id` - The resource ID (if applicable)
134    /// * `request_id` - The X-Request-Id header value
135    ///
136    /// # Example
137    ///
138    /// ```rust
139    /// use shopify_sdk::rest::ResourceError;
140    /// use serde_json::json;
141    ///
142    /// let error = ResourceError::from_http_response(
143    ///     404,
144    ///     &json!({"error": "Not found"}),
145    ///     "Product",
146    ///     Some("123"),
147    ///     Some("req-123"),
148    /// );
149    /// assert!(matches!(error, ResourceError::NotFound { .. }));
150    /// ```
151    #[must_use]
152    pub fn from_http_response(
153        code: u16,
154        body: &serde_json::Value,
155        resource: &'static str,
156        id: Option<&str>,
157        request_id: Option<&str>,
158    ) -> Self {
159        match code {
160            404 => Self::NotFound {
161                resource,
162                id: id.unwrap_or("unknown").to_string(),
163            },
164            422 => {
165                let errors = parse_validation_errors(body);
166                Self::ValidationFailed {
167                    errors,
168                    request_id: request_id.map(ToString::to_string),
169                }
170            }
171            _ => {
172                // For other errors, create an HttpResponseError
173                let message = body.to_string();
174                Self::Http(HttpError::Response(crate::clients::HttpResponseError {
175                    code,
176                    message,
177                    error_reference: request_id.map(ToString::to_string),
178                }))
179            }
180        }
181    }
182
183    /// Returns the request ID if available.
184    ///
185    /// Useful for debugging and error reporting.
186    #[must_use]
187    pub fn request_id(&self) -> Option<&str> {
188        match self {
189            Self::ValidationFailed { request_id, .. } => request_id.as_deref(),
190            Self::Http(HttpError::Response(e)) => e.error_reference.as_deref(),
191            Self::Http(HttpError::MaxRetries(e)) => e.error_reference.as_deref(),
192            _ => None,
193        }
194    }
195}
196
197/// Parses validation errors from an API response body.
198///
199/// Shopify returns validation errors in the format:
200/// ```json
201/// {
202///   "errors": {
203///     "title": ["can't be blank", "is too short"],
204///     "price": ["must be greater than 0"]
205///   }
206/// }
207/// ```
208///
209/// Or as an array:
210/// ```json
211/// {
212///   "errors": ["Title can't be blank", "Price must be greater than 0"]
213/// }
214/// ```
215fn parse_validation_errors(body: &serde_json::Value) -> HashMap<String, Vec<String>> {
216    let mut result = HashMap::new();
217
218    if let Some(errors) = body.get("errors") {
219        match errors {
220            // Object format: {"field": ["error1", "error2"]}
221            serde_json::Value::Object(map) => {
222                for (field, messages) in map {
223                    let msgs: Vec<String> = match messages {
224                        serde_json::Value::Array(arr) => arr
225                            .iter()
226                            .filter_map(|v| v.as_str().map(ToString::to_string))
227                            .collect(),
228                        serde_json::Value::String(s) => vec![s.clone()],
229                        _ => vec![messages.to_string()],
230                    };
231                    result.insert(field.clone(), msgs);
232                }
233            }
234            // Array format: ["error1", "error2"]
235            serde_json::Value::Array(arr) => {
236                let msgs: Vec<String> = arr
237                    .iter()
238                    .filter_map(|v| v.as_str().map(ToString::to_string))
239                    .collect();
240                if !msgs.is_empty() {
241                    result.insert("base".to_string(), msgs);
242                }
243            }
244            // String format: "single error"
245            serde_json::Value::String(s) => {
246                result.insert("base".to_string(), vec![s.clone()]);
247            }
248            _ => {}
249        }
250    }
251
252    result
253}
254
255// Verify ResourceError is Send + Sync at compile time
256const _: fn() = || {
257    const fn assert_send_sync<T: Send + Sync>() {}
258    assert_send_sync::<ResourceError>();
259};
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use serde_json::json;
265
266    #[test]
267    fn test_not_found_error_formats_message_with_resource_and_id() {
268        let error = ResourceError::NotFound {
269            resource: "Product",
270            id: "123456".to_string(),
271        };
272        let message = error.to_string();
273
274        assert!(message.contains("Product"));
275        assert!(message.contains("123456"));
276        assert!(message.contains("not found"));
277    }
278
279    #[test]
280    fn test_validation_failed_stores_and_retrieves_field_errors() {
281        let mut errors = HashMap::new();
282        errors.insert("title".to_string(), vec!["can't be blank".to_string()]);
283        errors.insert(
284            "price".to_string(),
285            vec![
286                "must be greater than 0".to_string(),
287                "is invalid".to_string(),
288            ],
289        );
290
291        let error = ResourceError::ValidationFailed {
292            errors: errors.clone(),
293            request_id: Some("abc-123".to_string()),
294        };
295
296        if let ResourceError::ValidationFailed {
297            errors: returned_errors,
298            request_id,
299        } = error
300        {
301            assert_eq!(returned_errors.len(), 2);
302            assert_eq!(
303                returned_errors.get("title"),
304                Some(&vec!["can't be blank".to_string()])
305            );
306            assert_eq!(returned_errors.get("price").map(|v| v.len()), Some(2));
307            assert_eq!(request_id, Some("abc-123".to_string()));
308        } else {
309            panic!("Expected ValidationFailed variant");
310        }
311    }
312
313    #[test]
314    fn test_path_resolution_failed_includes_operation_context() {
315        let error = ResourceError::PathResolutionFailed {
316            resource: "Variant",
317            operation: "find",
318        };
319        let message = error.to_string();
320
321        assert!(message.contains("Variant"));
322        assert!(message.contains("find"));
323        assert!(message.contains("path"));
324    }
325
326    #[test]
327    fn test_http_error_wraps_correctly() {
328        let http_error = HttpError::Response(crate::clients::HttpResponseError {
329            code: 500,
330            message: r#"{"error":"Internal Server Error"}"#.to_string(),
331            error_reference: Some("req-xyz".to_string()),
332        });
333
334        let resource_error = ResourceError::Http(http_error);
335        let message = resource_error.to_string();
336
337        assert!(message.contains("Internal Server Error"));
338    }
339
340    #[test]
341    fn test_from_http_error_conversion() {
342        let http_error = HttpError::Response(crate::clients::HttpResponseError {
343            code: 503,
344            message: "Service unavailable".to_string(),
345            error_reference: None,
346        });
347
348        let resource_error: ResourceError = http_error.into();
349        assert!(matches!(resource_error, ResourceError::Http(_)));
350    }
351
352    #[test]
353    fn test_from_rest_error_conversion() {
354        let rest_error = RestError::InvalidPath {
355            path: "/bad/path".to_string(),
356        };
357
358        let resource_error: ResourceError = rest_error.into();
359        assert!(matches!(resource_error, ResourceError::Rest(_)));
360    }
361
362    #[test]
363    fn test_all_error_variants_implement_std_error() {
364        // NotFound
365        let not_found_error: &dyn std::error::Error = &ResourceError::NotFound {
366            resource: "Product",
367            id: "123".to_string(),
368        };
369        let _ = not_found_error;
370
371        // ValidationFailed
372        let validation_error: &dyn std::error::Error = &ResourceError::ValidationFailed {
373            errors: HashMap::new(),
374            request_id: None,
375        };
376        let _ = validation_error;
377
378        // PathResolutionFailed
379        let path_error: &dyn std::error::Error = &ResourceError::PathResolutionFailed {
380            resource: "Variant",
381            operation: "all",
382        };
383        let _ = path_error;
384
385        // Http
386        let http_error: &dyn std::error::Error =
387            &ResourceError::Http(HttpError::Response(crate::clients::HttpResponseError {
388                code: 400,
389                message: "test".to_string(),
390                error_reference: None,
391            }));
392        let _ = http_error;
393
394        // Rest
395        let rest_error: &dyn std::error::Error = &ResourceError::Rest(RestError::InvalidPath {
396            path: "test".to_string(),
397        });
398        let _ = rest_error;
399    }
400
401    #[test]
402    fn test_from_http_response_maps_404_to_not_found() {
403        let error = ResourceError::from_http_response(
404            404,
405            &json!({"error": "Not found"}),
406            "Product",
407            Some("123"),
408            Some("req-123"),
409        );
410
411        assert!(matches!(
412            error,
413            ResourceError::NotFound { resource: "Product", id } if id == "123"
414        ));
415    }
416
417    #[test]
418    fn test_from_http_response_maps_422_to_validation_failed() {
419        let body = json!({
420            "errors": {
421                "title": ["can't be blank"],
422                "price": ["must be a number", "must be positive"]
423            }
424        });
425
426        let error =
427            ResourceError::from_http_response(422, &body, "Product", Some("123"), Some("req-456"));
428
429        if let ResourceError::ValidationFailed { errors, request_id } = error {
430            assert_eq!(
431                errors.get("title"),
432                Some(&vec!["can't be blank".to_string()])
433            );
434            assert_eq!(errors.get("price").map(|v| v.len()), Some(2));
435            assert_eq!(request_id, Some("req-456".to_string()));
436        } else {
437            panic!("Expected ValidationFailed variant");
438        }
439    }
440
441    #[test]
442    fn test_from_http_response_maps_other_codes_to_http() {
443        let error = ResourceError::from_http_response(
444            500,
445            &json!({"error": "Internal error"}),
446            "Product",
447            None,
448            Some("req-789"),
449        );
450
451        assert!(matches!(error, ResourceError::Http(_)));
452    }
453
454    #[test]
455    fn test_parse_validation_errors_object_format() {
456        let body = json!({
457            "errors": {
458                "title": ["can't be blank"],
459                "tags": ["is invalid", "has too many items"]
460            }
461        });
462
463        let errors = parse_validation_errors(&body);
464        assert_eq!(errors.len(), 2);
465        assert_eq!(
466            errors.get("title"),
467            Some(&vec!["can't be blank".to_string()])
468        );
469        assert_eq!(errors.get("tags").map(|v| v.len()), Some(2));
470    }
471
472    #[test]
473    fn test_parse_validation_errors_array_format() {
474        let body = json!({
475            "errors": ["Error 1", "Error 2"]
476        });
477
478        let errors = parse_validation_errors(&body);
479        assert_eq!(errors.len(), 1);
480        assert_eq!(errors.get("base").map(|v| v.len()), Some(2));
481    }
482
483    #[test]
484    fn test_request_id_extraction() {
485        let error = ResourceError::ValidationFailed {
486            errors: HashMap::new(),
487            request_id: Some("req-abc".to_string()),
488        };
489        assert_eq!(error.request_id(), Some("req-abc"));
490
491        let error = ResourceError::NotFound {
492            resource: "Product",
493            id: "123".to_string(),
494        };
495        assert_eq!(error.request_id(), None);
496    }
497}