1use std::{collections::BTreeMap, error::Error as StdError, fmt};
2
3use axum::{
4 Json,
5 extract::rejection::{JsonRejection, PathRejection, QueryRejection},
6 http::StatusCode,
7 response::{IntoResponse, Response},
8};
9use serde_json::Value;
10
11use crate::{ErrorBody, ErrorEnvelope, middleware::current_request_id};
12
13pub struct ApiError {
19 status: StatusCode,
20 code: String,
21 message: String,
22 details: BTreeMap<String, Value>,
23 cause: Option<Box<dyn StdError + Send + Sync>>,
24}
25
26impl ApiError {
27 pub fn new(status: StatusCode, code: impl Into<String>, message: impl Into<String>) -> Self {
32 let code = code.into();
33 assert!(
34 is_valid_error_code(&code),
35 "API error codes must be non-empty snake_case identifiers"
36 );
37 Self {
38 status,
39 code,
40 message: message.into(),
41 details: BTreeMap::new(),
42 cause: None,
43 }
44 }
45
46 #[must_use]
48 pub fn with_details(mut self, details: BTreeMap<String, Value>) -> Self {
49 self.details = details;
50 self
51 }
52
53 pub fn bad_request(message: impl Into<String>) -> Self {
55 Self::new(StatusCode::BAD_REQUEST, "bad_request", message)
56 }
57
58 pub fn validation(details: BTreeMap<String, Value>) -> Self {
60 Self::new(
61 StatusCode::BAD_REQUEST,
62 "validation_failed",
63 "The request is invalid",
64 )
65 .with_details(details)
66 }
67
68 pub fn validation_field(field: impl Into<String>, message: impl Into<String>) -> Self {
73 Self::validation(BTreeMap::from([(
74 field.into(),
75 Value::String(message.into()),
76 )]))
77 }
78
79 pub fn validation_fields<K, M, I>(fields: I) -> Self
84 where
85 K: Into<String>,
86 M: Into<String>,
87 I: IntoIterator<Item = (K, M)>,
88 {
89 Self::validation(
90 fields
91 .into_iter()
92 .map(|(field, message)| (field.into(), Value::String(message.into())))
93 .collect(),
94 )
95 }
96
97 pub fn unauthorized(message: impl Into<String>) -> Self {
99 Self::new(StatusCode::UNAUTHORIZED, "unauthorized", message)
100 }
101
102 pub fn unauthenticated() -> Self {
104 Self::new(
105 StatusCode::UNAUTHORIZED,
106 "unauthenticated",
107 "Authentication is required",
108 )
109 }
110
111 pub fn forbidden(message: impl Into<String>) -> Self {
113 Self::new(StatusCode::FORBIDDEN, "forbidden", message)
114 }
115
116 pub fn permission_denied() -> Self {
118 Self::new(
119 StatusCode::FORBIDDEN,
120 "permission_denied",
121 "Permission denied",
122 )
123 }
124
125 pub fn not_found(message: impl Into<String>) -> Self {
127 Self::new(StatusCode::NOT_FOUND, "not_found", message)
128 }
129
130 pub fn conflict(message: impl Into<String>) -> Self {
132 Self::new(StatusCode::CONFLICT, "conflict", message)
133 }
134
135 pub fn method_not_allowed() -> Self {
137 Self::new(
138 StatusCode::METHOD_NOT_ALLOWED,
139 "method_not_allowed",
140 "Method not allowed",
141 )
142 }
143
144 pub fn rate_limited() -> Self {
146 Self::new(
147 StatusCode::TOO_MANY_REQUESTS,
148 "rate_limited",
149 "Too many requests",
150 )
151 }
152
153 pub fn internal(error: impl StdError + Send + Sync + 'static) -> Self {
155 let mut api_error = Self::internal_without_cause();
156 api_error.cause = Some(Box::new(error));
157 api_error
158 }
159
160 pub(crate) fn timeout() -> Self {
161 Self::new(
162 StatusCode::GATEWAY_TIMEOUT,
163 "request_timeout",
164 "The request timed out",
165 )
166 }
167
168 pub(crate) fn payload_too_large() -> Self {
169 Self::new(
170 StatusCode::PAYLOAD_TOO_LARGE,
171 "payload_too_large",
172 "The request body is too large",
173 )
174 }
175
176 pub(crate) fn json_rejection(code: impl Into<String>) -> Self {
177 Self::new(StatusCode::BAD_REQUEST, code, "The request is invalid").with_details(
178 BTreeMap::from([(
179 "body".to_owned(),
180 Value::String("must be valid JSON matching the request schema".to_owned()),
181 )]),
182 )
183 }
184
185 pub(crate) fn query_rejection() -> Self {
186 Self::validation_field("query", "must contain valid query parameters")
187 }
188
189 pub(crate) fn internal_without_cause() -> Self {
190 Self::new(
191 StatusCode::INTERNAL_SERVER_ERROR,
192 "internal",
193 "An internal error occurred",
194 )
195 }
196
197 #[must_use]
199 pub const fn status(&self) -> StatusCode {
200 self.status
201 }
202
203 #[must_use]
205 pub fn code(&self) -> &str {
206 &self.code
207 }
208}
209
210impl From<JsonRejection> for ApiError {
211 fn from(_rejection: JsonRejection) -> Self {
212 Self::json_rejection("validation_failed")
213 }
214}
215
216impl From<PathRejection> for ApiError {
217 fn from(_rejection: PathRejection) -> Self {
218 Self::validation(BTreeMap::from([(
219 "path".to_owned(),
220 Value::String("must contain valid route parameters".to_owned()),
221 )]))
222 }
223}
224
225impl From<QueryRejection> for ApiError {
226 fn from(_rejection: QueryRejection) -> Self {
227 Self::query_rejection()
228 }
229}
230
231pub(crate) fn is_valid_error_code(code: &str) -> bool {
232 !code.is_empty()
233 && !code.starts_with('_')
234 && !code.ends_with('_')
235 && !code.contains("__")
236 && code
237 .bytes()
238 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
239}
240
241impl fmt::Debug for ApiError {
242 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
243 formatter
244 .debug_struct("ApiError")
245 .field("status", &self.status)
246 .field("code", &self.code)
247 .field("message", &self.message)
248 .field("details", &self.details)
249 .field("has_cause", &self.cause.is_some())
250 .finish()
251 }
252}
253
254impl fmt::Display for ApiError {
255 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
256 write!(formatter, "{} ({})", self.message, self.code)
257 }
258}
259
260impl StdError for ApiError {
261 fn source(&self) -> Option<&(dyn StdError + 'static)> {
262 self.cause
263 .as_deref()
264 .map(|cause| cause as &(dyn StdError + 'static))
265 }
266}
267
268impl IntoResponse for ApiError {
269 fn into_response(self) -> Response {
270 let request_id = current_request_id();
271 if let Some(cause) = self.cause.as_deref() {
272 tracing::error!(error = %cause, "request failed with an internal error");
273 }
274
275 let envelope = ErrorEnvelope {
276 error: ErrorBody {
277 code: self.code,
278 message: self.message,
279 request_id: request_id.to_string(),
280 details: self.details,
281 },
282 };
283 let mut response = (self.status, Json(envelope)).into_response();
284 response.headers_mut().insert(
285 crate::X_REQUEST_ID,
286 request_id
287 .header_value()
288 .expect("request IDs originate from a valid header or UUID"),
289 );
290 response
291 }
292}