1use std::collections::HashMap;
35
36use crate::clients::{HttpError, RestError};
37use thiserror::Error;
38
39#[derive(Debug, Error)]
69pub enum ResourceError {
70 #[error("{resource} with id {id} not found")]
75 NotFound {
76 resource: &'static str,
78 id: String,
80 },
81
82 #[error("Validation failed: {errors:?}")]
87 ValidationFailed {
88 errors: HashMap<String, Vec<String>>,
90 request_id: Option<String>,
92 },
93
94 #[error("Cannot resolve path for {resource}::{operation} with provided IDs")]
99 PathResolutionFailed {
100 resource: &'static str,
102 operation: &'static str,
104 },
105
106 #[error(transparent)]
111 Http(#[from] HttpError),
112
113 #[error(transparent)]
117 Rest(#[from] RestError),
118}
119
120impl ResourceError {
121 #[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 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 #[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
197fn 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 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 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 serde_json::Value::String(s) => {
246 result.insert("base".to_string(), vec![s.clone()]);
247 }
248 _ => {}
249 }
250 }
251
252 result
253}
254
255const _: 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 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 let validation_error: &dyn std::error::Error = &ResourceError::ValidationFailed {
373 errors: HashMap::new(),
374 request_id: None,
375 };
376 let _ = validation_error;
377
378 let path_error: &dyn std::error::Error = &ResourceError::PathResolutionFailed {
380 resource: "Variant",
381 operation: "all",
382 };
383 let _ = path_error;
384
385 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 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}