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