Skip to main content

baukit_http/
error.rs

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
47/// A safe public API error rendered in Baukit's standard JSON envelope.
48///
49/// The public code and message must be stable and safe for clients. Attach
50/// internal diagnostic errors with [`ApiError::internal`]; causes are logged at
51/// the request span and are never serialized.
52pub 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    /// Creates a public API error.
63    ///
64    /// `code` should be a stable snake_case identifier and `message` must not
65    /// contain internal or sensitive information.
66    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    /// Replaces the structured details object.
83    #[must_use]
84    pub fn with_details(mut self, details: BTreeMap<String, Value>) -> Self {
85        self.details = details;
86        self
87    }
88
89    /// Adds a header to the error response.
90    ///
91    /// A later call with the same name replaces the previous value. The
92    /// request middleware always supplies the final `X-Request-Id` value.
93    #[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    /// Adds a `Retry-After` response header using delta seconds.
102    #[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    /// Returns a `400 bad_request` error with a safe message.
112    pub fn bad_request(message: impl Into<String>) -> Self {
113        Self::new(StatusCode::BAD_REQUEST, "bad_request", message)
114    }
115
116    /// Returns a `400 validation_failed` error with structured field details.
117    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    /// Returns a validation error for one DTO field.
127    ///
128    /// This is the concise form of [`ApiError::validation`] for the common case
129    /// where a product has one field-level semantic or bounds error.
130    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    /// Returns a validation error containing multiple DTO field messages.
138    ///
139    /// Field names are sorted in the serialized details object. If an iterator
140    /// contains the same field more than once, the last message wins.
141    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    /// Returns a `401 unauthorized` error with a safe message.
156    pub fn unauthorized(message: impl Into<String>) -> Self {
157        Self::new(StatusCode::UNAUTHORIZED, "unauthorized", message)
158    }
159
160    /// Returns the standard `401 unauthenticated` authentication error.
161    pub fn unauthenticated() -> Self {
162        Self::new(
163            StatusCode::UNAUTHORIZED,
164            "unauthenticated",
165            "Authentication is required",
166        )
167    }
168
169    /// Returns a `403 forbidden` error with a safe message.
170    pub fn forbidden(message: impl Into<String>) -> Self {
171        Self::new(StatusCode::FORBIDDEN, "forbidden", message)
172    }
173
174    /// Returns the standard `403 permission_denied` authorization error.
175    pub fn permission_denied() -> Self {
176        Self::new(
177            StatusCode::FORBIDDEN,
178            "permission_denied",
179            "Permission denied",
180        )
181    }
182
183    /// Returns a `404 not_found` error with a safe message.
184    pub fn not_found(message: impl Into<String>) -> Self {
185        Self::new(StatusCode::NOT_FOUND, "not_found", message)
186    }
187
188    /// Returns a `409 conflict` error with a safe message.
189    pub fn conflict(message: impl Into<String>) -> Self {
190        Self::new(StatusCode::CONFLICT, "conflict", message)
191    }
192
193    /// Returns a `405 method_not_allowed` error with a stable safe message.
194    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    /// Returns the standard `429 rate_limited` error.
203    pub fn rate_limited() -> Self {
204        Self::new(
205            StatusCode::TOO_MANY_REQUESTS,
206            "rate_limited",
207            "Too many requests",
208        )
209    }
210
211    /// Returns an opaque `500 internal` error and retains its cause for logging.
212    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    /// Returns this error's HTTP status.
303    #[must_use]
304    pub const fn status(&self) -> StatusCode {
305        self.status
306    }
307
308    /// Returns this error's stable machine-readable code.
309    #[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}