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::StatusCode,
7    response::{IntoResponse, Response},
8};
9use serde_json::Value;
10
11use crate::{ErrorBody, ErrorEnvelope, middleware::current_request_id};
12
13/// A safe public API error rendered in Baukit's standard JSON envelope.
14///
15/// The public code and message must be stable and safe for clients. Attach
16/// internal diagnostic errors with [`ApiError::internal`]; causes are logged at
17/// the request span and are never serialized.
18pub 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    /// Creates a public API error.
28    ///
29    /// `code` should be a stable snake_case identifier and `message` must not
30    /// contain internal or sensitive information.
31    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    /// Replaces the structured details object.
47    #[must_use]
48    pub fn with_details(mut self, details: BTreeMap<String, Value>) -> Self {
49        self.details = details;
50        self
51    }
52
53    /// Returns a `400 bad_request` error with a safe message.
54    pub fn bad_request(message: impl Into<String>) -> Self {
55        Self::new(StatusCode::BAD_REQUEST, "bad_request", message)
56    }
57
58    /// Returns a `400 validation_failed` error with structured field details.
59    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    /// Returns a validation error for one DTO field.
69    ///
70    /// This is the concise form of [`ApiError::validation`] for the common case
71    /// where a product has one field-level semantic or bounds error.
72    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    /// Returns a validation error containing multiple DTO field messages.
80    ///
81    /// Field names are sorted in the serialized details object. If an iterator
82    /// contains the same field more than once, the last message wins.
83    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    /// Returns a `401 unauthorized` error with a safe message.
98    pub fn unauthorized(message: impl Into<String>) -> Self {
99        Self::new(StatusCode::UNAUTHORIZED, "unauthorized", message)
100    }
101
102    /// Returns the standard `401 unauthenticated` authentication error.
103    pub fn unauthenticated() -> Self {
104        Self::new(
105            StatusCode::UNAUTHORIZED,
106            "unauthenticated",
107            "Authentication is required",
108        )
109    }
110
111    /// Returns a `403 forbidden` error with a safe message.
112    pub fn forbidden(message: impl Into<String>) -> Self {
113        Self::new(StatusCode::FORBIDDEN, "forbidden", message)
114    }
115
116    /// Returns the standard `403 permission_denied` authorization error.
117    pub fn permission_denied() -> Self {
118        Self::new(
119            StatusCode::FORBIDDEN,
120            "permission_denied",
121            "Permission denied",
122        )
123    }
124
125    /// Returns a `404 not_found` error with a safe message.
126    pub fn not_found(message: impl Into<String>) -> Self {
127        Self::new(StatusCode::NOT_FOUND, "not_found", message)
128    }
129
130    /// Returns a `409 conflict` error with a safe message.
131    pub fn conflict(message: impl Into<String>) -> Self {
132        Self::new(StatusCode::CONFLICT, "conflict", message)
133    }
134
135    /// Returns a `405 method_not_allowed` error with a stable safe message.
136    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    /// Returns the standard `429 rate_limited` error.
145    pub fn rate_limited() -> Self {
146        Self::new(
147            StatusCode::TOO_MANY_REQUESTS,
148            "rate_limited",
149            "Too many requests",
150        )
151    }
152
153    /// Returns an opaque `500 internal` error and retains its cause for logging.
154    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    /// Returns this error's HTTP status.
198    #[must_use]
199    pub const fn status(&self) -> StatusCode {
200        self.status
201    }
202
203    /// Returns this error's stable machine-readable code.
204    #[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}