1use std::{collections::BTreeMap, error::Error as StdError, fmt};
2
3use axum::{
4 Json,
5 extract::rejection::{JsonRejection, PathRejection, QueryRejection},
6 http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header::RETRY_AFTER},
7 response::{IntoResponse, Response},
8};
9use serde_json::Value;
10
11use crate::{
12 ErrorBody, ErrorEnvelope,
13 middleware::current_request_id,
14 options::{JsonRejectionCodes, JsonRejectionMode},
15};
16
17#[derive(Clone, Copy, Debug, Eq, PartialEq)]
18enum JsonRejectionClass {
19 BodyTooLarge,
20 ContentType,
21 Syntax,
22 DataShape,
23}
24
25impl JsonRejectionClass {
26 fn from_rejection(rejection: &JsonRejection) -> Self {
27 match rejection {
28 JsonRejection::JsonDataError(_) => Self::DataShape,
29 JsonRejection::JsonSyntaxError(_) => Self::Syntax,
30 JsonRejection::MissingJsonContentType(_) => Self::ContentType,
31 JsonRejection::BytesRejection(_) => {
32 if rejection.status() == StatusCode::PAYLOAD_TOO_LARGE {
33 Self::BodyTooLarge
34 } else {
35 Self::Syntax
36 }
37 }
38 _ if rejection.status() == StatusCode::PAYLOAD_TOO_LARGE => Self::BodyTooLarge,
39 _ if rejection.status() == StatusCode::UNSUPPORTED_MEDIA_TYPE => Self::ContentType,
40 _ if rejection.status() == StatusCode::UNPROCESSABLE_ENTITY => Self::DataShape,
41 _ => Self::Syntax,
42 }
43 }
44}
45
46pub struct ApiError {
52 status: StatusCode,
53 code: String,
54 message: String,
55 details: BTreeMap<String, Value>,
56 headers: Option<Box<HeaderMap>>,
57 cause: Option<Box<dyn StdError + Send + Sync>>,
58}
59
60impl ApiError {
61 pub fn new(status: StatusCode, code: impl Into<String>, message: impl Into<String>) -> Self {
66 let code = code.into();
67 assert!(
68 is_valid_error_code(&code),
69 "API error codes must be non-empty snake_case identifiers"
70 );
71 Self {
72 status,
73 code,
74 message: message.into(),
75 details: BTreeMap::new(),
76 headers: None,
77 cause: None,
78 }
79 }
80
81 #[must_use]
83 pub fn with_details(mut self, details: BTreeMap<String, Value>) -> Self {
84 self.details = details;
85 self
86 }
87
88 #[must_use]
93 pub fn with_header(mut self, name: HeaderName, value: HeaderValue) -> Self {
94 self.headers
95 .get_or_insert_with(Default::default)
96 .insert(name, value);
97 self
98 }
99
100 #[must_use]
102 pub fn with_retry_after(self, seconds: u64) -> Self {
103 self.with_header(
104 RETRY_AFTER,
105 HeaderValue::from_str(&seconds.to_string())
106 .expect("unsigned integers are valid header values"),
107 )
108 }
109
110 pub fn bad_request(message: impl Into<String>) -> Self {
112 Self::new(StatusCode::BAD_REQUEST, "bad_request", message)
113 }
114
115 pub fn validation(details: BTreeMap<String, Value>) -> Self {
117 Self::new(
118 StatusCode::BAD_REQUEST,
119 "validation_failed",
120 "The request is invalid",
121 )
122 .with_details(details)
123 }
124
125 pub fn validation_field(field: impl Into<String>, message: impl Into<String>) -> Self {
130 Self::validation(BTreeMap::from([(
131 field.into(),
132 Value::String(message.into()),
133 )]))
134 }
135
136 pub fn validation_fields<K, M, I>(fields: I) -> Self
141 where
142 K: Into<String>,
143 M: Into<String>,
144 I: IntoIterator<Item = (K, M)>,
145 {
146 Self::validation(
147 fields
148 .into_iter()
149 .map(|(field, message)| (field.into(), Value::String(message.into())))
150 .collect(),
151 )
152 }
153
154 pub fn unauthorized(message: impl Into<String>) -> Self {
156 Self::new(StatusCode::UNAUTHORIZED, "unauthorized", message)
157 }
158
159 pub fn unauthenticated() -> Self {
161 Self::new(
162 StatusCode::UNAUTHORIZED,
163 "unauthenticated",
164 "Authentication is required",
165 )
166 }
167
168 pub fn forbidden(message: impl Into<String>) -> Self {
170 Self::new(StatusCode::FORBIDDEN, "forbidden", message)
171 }
172
173 pub fn permission_denied() -> Self {
175 Self::new(
176 StatusCode::FORBIDDEN,
177 "permission_denied",
178 "Permission denied",
179 )
180 }
181
182 pub fn not_found(message: impl Into<String>) -> Self {
184 Self::new(StatusCode::NOT_FOUND, "not_found", message)
185 }
186
187 pub fn conflict(message: impl Into<String>) -> Self {
189 Self::new(StatusCode::CONFLICT, "conflict", message)
190 }
191
192 pub fn method_not_allowed() -> Self {
194 Self::new(
195 StatusCode::METHOD_NOT_ALLOWED,
196 "method_not_allowed",
197 "Method not allowed",
198 )
199 }
200
201 pub fn rate_limited() -> Self {
203 Self::new(
204 StatusCode::TOO_MANY_REQUESTS,
205 "rate_limited",
206 "Too many requests",
207 )
208 }
209
210 pub fn internal(error: impl StdError + Send + Sync + 'static) -> Self {
212 let mut api_error = Self::internal_without_cause();
213 api_error.cause = Some(Box::new(error));
214 api_error
215 }
216
217 pub(crate) fn timeout() -> Self {
218 Self::new(
219 StatusCode::GATEWAY_TIMEOUT,
220 "request_timeout",
221 "The request timed out",
222 )
223 }
224
225 pub(crate) fn payload_too_large() -> Self {
226 Self::body_too_large("payload_too_large")
227 }
228
229 pub(crate) fn configured_payload_too_large(mode: &JsonRejectionMode) -> Self {
230 match mode {
231 JsonRejectionMode::Legacy(_) => Self::payload_too_large(),
232 JsonRejectionMode::Classified(codes) => Self::body_too_large(codes.body_too_large()),
233 }
234 }
235
236 pub(crate) fn json_rejection(rejection: &JsonRejection, mode: &JsonRejectionMode) -> Self {
237 match mode {
238 JsonRejectionMode::Legacy(code) => Self::legacy_json_rejection(code),
239 JsonRejectionMode::Classified(codes) => {
240 Self::classified_json_rejection(rejection, codes)
241 }
242 }
243 }
244
245 fn body_too_large(code: impl Into<String>) -> Self {
246 Self::new(
247 StatusCode::PAYLOAD_TOO_LARGE,
248 code,
249 "The request body is too large",
250 )
251 }
252
253 fn legacy_json_rejection(code: impl Into<String>) -> Self {
254 Self::new(StatusCode::BAD_REQUEST, code, "The request is invalid").with_details(
255 BTreeMap::from([(
256 "body".to_owned(),
257 Value::String("must be valid JSON matching the request schema".to_owned()),
258 )]),
259 )
260 }
261
262 fn classified_json_rejection(rejection: &JsonRejection, codes: &JsonRejectionCodes) -> Self {
263 match JsonRejectionClass::from_rejection(rejection) {
264 JsonRejectionClass::BodyTooLarge => Self::body_too_large(codes.body_too_large()),
265 JsonRejectionClass::ContentType => Self::new(
266 rejection.status(),
267 codes.content_type(),
268 "The request content type is unsupported",
269 ),
270 JsonRejectionClass::Syntax => {
271 Self::new(rejection.status(), codes.syntax(), "The request is invalid")
272 .with_details(BTreeMap::from([(
273 "body".to_owned(),
274 Value::String("must contain valid JSON".to_owned()),
275 )]))
276 }
277 JsonRejectionClass::DataShape => Self::new(
278 rejection.status(),
279 codes.data_shape(),
280 "The request is invalid",
281 )
282 .with_details(BTreeMap::from([(
283 "body".to_owned(),
284 Value::String("must match the request schema".to_owned()),
285 )])),
286 }
287 }
288
289 pub(crate) fn query_rejection() -> Self {
290 Self::validation_field("query", "must contain valid query parameters")
291 }
292
293 pub(crate) fn internal_without_cause() -> Self {
294 Self::new(
295 StatusCode::INTERNAL_SERVER_ERROR,
296 "internal",
297 "An internal error occurred",
298 )
299 }
300
301 #[must_use]
303 pub const fn status(&self) -> StatusCode {
304 self.status
305 }
306
307 #[must_use]
309 pub fn code(&self) -> &str {
310 &self.code
311 }
312}
313
314impl From<JsonRejection> for ApiError {
315 fn from(_rejection: JsonRejection) -> Self {
316 Self::legacy_json_rejection("validation_failed")
317 }
318}
319
320impl From<PathRejection> for ApiError {
321 fn from(_rejection: PathRejection) -> Self {
322 Self::validation(BTreeMap::from([(
323 "path".to_owned(),
324 Value::String("must contain valid route parameters".to_owned()),
325 )]))
326 }
327}
328
329impl From<QueryRejection> for ApiError {
330 fn from(_rejection: QueryRejection) -> Self {
331 Self::query_rejection()
332 }
333}
334
335pub(crate) fn is_valid_error_code(code: &str) -> bool {
336 !code.is_empty()
337 && !code.starts_with('_')
338 && !code.ends_with('_')
339 && !code.contains("__")
340 && code
341 .bytes()
342 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
343}
344
345impl fmt::Debug for ApiError {
346 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
347 formatter
348 .debug_struct("ApiError")
349 .field("status", &self.status)
350 .field("code", &self.code)
351 .field("message", &self.message)
352 .field("details", &self.details)
353 .field("headers", &self.headers)
354 .field("has_cause", &self.cause.is_some())
355 .finish()
356 }
357}
358
359impl fmt::Display for ApiError {
360 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
361 write!(formatter, "{} ({})", self.message, self.code)
362 }
363}
364
365impl StdError for ApiError {
366 fn source(&self) -> Option<&(dyn StdError + 'static)> {
367 self.cause
368 .as_deref()
369 .map(|cause| cause as &(dyn StdError + 'static))
370 }
371}
372
373impl IntoResponse for ApiError {
374 fn into_response(self) -> Response {
375 let request_id = current_request_id();
376 if let Some(cause) = self.cause.as_deref() {
377 tracing::error!(error = %cause, "request failed with an internal error");
378 }
379
380 let envelope = ErrorEnvelope {
381 error: ErrorBody {
382 code: self.code,
383 message: self.message,
384 request_id: request_id.to_string(),
385 details: self.details,
386 },
387 };
388 let mut response = (self.status, Json(envelope)).into_response();
389 if let Some(headers) = self.headers {
390 response.headers_mut().extend(*headers);
391 }
392 response.headers_mut().insert(
393 crate::X_REQUEST_ID,
394 request_id
395 .header_value()
396 .expect("request IDs originate from a valid header or UUID"),
397 );
398 response
399 }
400}