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    pub fn conflict(code: impl Into<String>, message: impl Into<String>) -> Self {
80        Self {
81            status: StatusCode::CONFLICT,
82            code: code.into(),
83            message: message.into(),
84            error_type: ErrorType::Sender,
85            extras: None,
86        }
87    }
88
89    pub fn internal(message: impl Into<String>) -> Self {
90        Self {
91            status: StatusCode::INTERNAL_SERVER_ERROR,
92            code: "InternalServiceError".to_string(),
93            message: message.into(),
94            error_type: ErrorType::Receiver,
95            extras: None,
96        }
97    }
98
99    pub fn not_implemented(operation: &str) -> Self {
100        Self {
101            status: StatusCode::NOT_IMPLEMENTED,
102            code: "NotImplemented".to_string(),
103            message: format!("Operation '{operation}' is not yet implemented in AWSim"),
104            error_type: ErrorType::Receiver,
105            extras: None,
106        }
107    }
108
109    pub fn unknown_operation(operation: &str) -> Self {
110        Self {
111            status: StatusCode::BAD_REQUEST,
112            code: "UnknownOperationException".to_string(),
113            message: format!("Unknown operation: {operation}"),
114            error_type: ErrorType::Sender,
115            extras: None,
116        }
117    }
118
119    pub fn access_denied(message: impl Into<String>) -> Self {
120        Self {
121            status: StatusCode::FORBIDDEN,
122            code: "AccessDeniedException".to_string(),
123            message: message.into(),
124            error_type: ErrorType::Sender,
125            extras: None,
126        }
127    }
128
129    pub fn access_denied_for(action: &str, principal_arn: &str, resource: &str) -> Self {
130        Self {
131            status: StatusCode::FORBIDDEN,
132            code: "AccessDenied".to_string(),
133            message: format!(
134                "User: {principal_arn} is not authorized to perform: {action} on resource: {resource}"
135            ),
136            error_type: ErrorType::Sender,
137            extras: None,
138        }
139    }
140
141    pub fn validation(message: impl Into<String>) -> Self {
142        Self {
143            status: StatusCode::BAD_REQUEST,
144            code: "ValidationException".to_string(),
145            message: message.into(),
146            error_type: ErrorType::Sender,
147            extras: None,
148        }
149    }
150
151    /// Replace the extras map wholesale.
152    pub fn with_extras(mut self, extras: Map<String, Value>) -> Self {
153        self.extras = Some(Box::new(extras));
154        self
155    }
156
157    /// Insert a single extra field, allocating the map if needed.
158    pub fn with_extra(mut self, key: impl Into<String>, value: Value) -> Self {
159        self.extras
160            .get_or_insert_with(|| Box::new(Map::new()))
161            .insert(key.into(), value);
162        self
163    }
164}
165
166impl std::fmt::Display for AwsError {
167    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168        write!(f, "{}: {}", self.code, self.message)
169    }
170}
171
172impl std::error::Error for AwsError {}