Skip to main content

awsim_core/
error.rs

1use axum::http::StatusCode;
2use serde::Serialize;
3use serde_json::{Map, Value};
4
5/// Represents an AWS API error response.
6#[derive(Debug, Clone, Serialize)]
7pub struct AwsError {
8    /// HTTP status code (e.g., 404, 400, 500)
9    #[serde(skip)]
10    pub status: StatusCode,
11
12    /// AWS error code (e.g., "NoSuchBucket", "ResourceNotFoundException")
13    pub code: String,
14
15    /// Human-readable error message
16    pub message: String,
17
18    /// Error type: "Sender" (client error) or "Receiver" (server error)
19    pub error_type: ErrorType,
20
21    /// Extra JSON fields merged into the serialized error body.
22    ///
23    /// Some AWS exceptions carry structured data alongside the standard
24    /// `__type` / `message` envelope — for example, DynamoDB's
25    /// `TransactionCanceledException` includes a `CancellationReasons` array,
26    /// and `ConditionalCheckFailedException` may include the existing `Item`.
27    /// Use [`Self::with_extras`] or [`Self::with_extra`] to attach them.
28    ///
29    /// Boxed to keep `AwsError` small enough to fit comfortably in a
30    /// `Result<_, AwsError>` (clippy's `result_large_err` threshold).
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub extras: Option<Box<Map<String, Value>>>,
33}
34
35#[derive(Debug, Clone, Serialize)]
36pub enum ErrorType {
37    Sender,
38    Receiver,
39}
40
41impl AwsError {
42    pub fn not_found(code: impl Into<String>, message: impl Into<String>) -> Self {
43        Self {
44            status: StatusCode::NOT_FOUND,
45            code: code.into(),
46            message: message.into(),
47            error_type: ErrorType::Sender,
48            extras: None,
49        }
50    }
51
52    /// Service-level "resource not found" error returned with HTTP 400.
53    ///
54    /// Many JSON-protocol services (DynamoDB, KMS, SecretsManager, Cognito, ...)
55    /// model `ResourceNotFoundException` and friends as client-side validation
56    /// errors and respond with `400 Bad Request` rather than `404 Not Found`.
57    /// Use this constructor for those cases; reserve [`Self::not_found`] for
58    /// REST-style 404s such as S3's `NoSuchBucket` / `NoSuchKey`.
59    pub fn service_not_found(code: impl Into<String>, message: impl Into<String>) -> Self {
60        Self {
61            status: StatusCode::BAD_REQUEST,
62            code: code.into(),
63            message: message.into(),
64            error_type: ErrorType::Sender,
65            extras: None,
66        }
67    }
68
69    pub fn bad_request(code: impl Into<String>, message: impl Into<String>) -> Self {
70        Self {
71            status: StatusCode::BAD_REQUEST,
72            code: code.into(),
73            message: message.into(),
74            error_type: ErrorType::Sender,
75            extras: None,
76        }
77    }
78
79    /// HTTP 416 Range Not Satisfiable — used by S3 when a `Range` header
80    /// requests bytes outside the object's size.
81    pub fn range_not_satisfiable(code: impl Into<String>, message: impl Into<String>) -> Self {
82        Self {
83            status: StatusCode::RANGE_NOT_SATISFIABLE,
84            code: code.into(),
85            message: message.into(),
86            error_type: ErrorType::Sender,
87            extras: None,
88        }
89    }
90
91    /// HTTP 412 Precondition Failed — used by S3 when an `If-Match` /
92    /// `If-Unmodified-Since` conditional request fails.
93    pub fn precondition_failed(code: impl Into<String>, message: impl Into<String>) -> Self {
94        Self {
95            status: StatusCode::PRECONDITION_FAILED,
96            code: code.into(),
97            message: message.into(),
98            error_type: ErrorType::Sender,
99            extras: None,
100        }
101    }
102
103    pub fn conflict(code: impl Into<String>, message: impl Into<String>) -> Self {
104        Self {
105            status: StatusCode::CONFLICT,
106            code: code.into(),
107            message: message.into(),
108            error_type: ErrorType::Sender,
109            extras: None,
110        }
111    }
112
113    pub fn internal(message: impl Into<String>) -> Self {
114        Self {
115            status: StatusCode::INTERNAL_SERVER_ERROR,
116            code: "InternalServiceError".to_string(),
117            message: message.into(),
118            error_type: ErrorType::Receiver,
119            extras: None,
120        }
121    }
122
123    pub fn not_implemented(operation: &str) -> Self {
124        Self {
125            status: StatusCode::NOT_IMPLEMENTED,
126            code: "NotImplemented".to_string(),
127            message: format!("Operation '{operation}' is not yet implemented in AWSim"),
128            error_type: ErrorType::Receiver,
129            extras: None,
130        }
131    }
132
133    pub fn unknown_operation(operation: &str) -> Self {
134        Self {
135            status: StatusCode::BAD_REQUEST,
136            code: "UnknownOperationException".to_string(),
137            message: format!("Unknown operation: {operation}"),
138            error_type: ErrorType::Sender,
139            extras: None,
140        }
141    }
142
143    pub fn access_denied(message: impl Into<String>) -> Self {
144        Self {
145            status: StatusCode::FORBIDDEN,
146            code: "AccessDeniedException".to_string(),
147            message: message.into(),
148            error_type: ErrorType::Sender,
149            extras: None,
150        }
151    }
152
153    pub fn access_denied_for(action: &str, principal_arn: &str, resource: &str) -> Self {
154        Self {
155            status: StatusCode::FORBIDDEN,
156            code: "AccessDenied".to_string(),
157            message: format!(
158                "User: {principal_arn} is not authorized to perform: {action} on resource: {resource}"
159            ),
160            error_type: ErrorType::Sender,
161            extras: None,
162        }
163    }
164
165    pub fn validation(message: impl Into<String>) -> Self {
166        Self {
167            status: StatusCode::BAD_REQUEST,
168            code: "ValidationException".to_string(),
169            message: message.into(),
170            error_type: ErrorType::Sender,
171            extras: None,
172        }
173    }
174
175    /// Replace the extras map wholesale.
176    pub fn with_extras(mut self, extras: Map<String, Value>) -> Self {
177        self.extras = Some(Box::new(extras));
178        self
179    }
180
181    /// Insert a single extra field, allocating the map if needed.
182    pub fn with_extra(mut self, key: impl Into<String>, value: Value) -> Self {
183        self.extras
184            .get_or_insert_with(|| Box::new(Map::new()))
185            .insert(key.into(), value);
186        self
187    }
188}
189
190impl std::fmt::Display for AwsError {
191    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
192        write!(f, "{}: {}", self.code, self.message)
193    }
194}
195
196impl std::error::Error for AwsError {}