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