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::{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    headers: Option<Box<HeaderMap>>,
24    cause: Option<Box<dyn StdError + Send + Sync>>,
25}
26
27impl ApiError {
28    /// Creates a public API error.
29    ///
30    /// `code` should be a stable snake_case identifier and `message` must not
31    /// contain internal or sensitive information.
32    pub fn new(status: StatusCode, code: impl Into<String>, message: impl Into<String>) -> Self {
33        let code = code.into();
34        assert!(
35            is_valid_error_code(&code),
36            "API error codes must be non-empty snake_case identifiers"
37        );
38        Self {
39            status,
40            code,
41            message: message.into(),
42            details: BTreeMap::new(),
43            headers: None,
44            cause: None,
45        }
46    }
47
48    /// Replaces the structured details object.
49    #[must_use]
50    pub fn with_details(mut self, details: BTreeMap<String, Value>) -> Self {
51        self.details = details;
52        self
53    }
54
55    /// Adds a header to the error response.
56    ///
57    /// A later call with the same name replaces the previous value. The
58    /// request middleware always supplies the final `X-Request-Id` value.
59    #[must_use]
60    pub fn with_header(mut self, name: HeaderName, value: HeaderValue) -> Self {
61        self.headers
62            .get_or_insert_with(Default::default)
63            .insert(name, value);
64        self
65    }
66
67    /// Adds a `Retry-After` response header using delta seconds.
68    #[must_use]
69    pub fn with_retry_after(self, seconds: u64) -> Self {
70        self.with_header(
71            RETRY_AFTER,
72            HeaderValue::from_str(&seconds.to_string())
73                .expect("unsigned integers are valid header values"),
74        )
75    }
76
77    /// Returns a `400 bad_request` error with a safe message.
78    pub fn bad_request(message: impl Into<String>) -> Self {
79        Self::new(StatusCode::BAD_REQUEST, "bad_request", message)
80    }
81
82    /// Returns a `400 validation_failed` error with structured field details.
83    pub fn validation(details: BTreeMap<String, Value>) -> Self {
84        Self::new(
85            StatusCode::BAD_REQUEST,
86            "validation_failed",
87            "The request is invalid",
88        )
89        .with_details(details)
90    }
91
92    /// Returns a validation error for one DTO field.
93    ///
94    /// This is the concise form of [`ApiError::validation`] for the common case
95    /// where a product has one field-level semantic or bounds error.
96    pub fn validation_field(field: impl Into<String>, message: impl Into<String>) -> Self {
97        Self::validation(BTreeMap::from([(
98            field.into(),
99            Value::String(message.into()),
100        )]))
101    }
102
103    /// Returns a validation error containing multiple DTO field messages.
104    ///
105    /// Field names are sorted in the serialized details object. If an iterator
106    /// contains the same field more than once, the last message wins.
107    pub fn validation_fields<K, M, I>(fields: I) -> Self
108    where
109        K: Into<String>,
110        M: Into<String>,
111        I: IntoIterator<Item = (K, M)>,
112    {
113        Self::validation(
114            fields
115                .into_iter()
116                .map(|(field, message)| (field.into(), Value::String(message.into())))
117                .collect(),
118        )
119    }
120
121    /// Returns a `401 unauthorized` error with a safe message.
122    pub fn unauthorized(message: impl Into<String>) -> Self {
123        Self::new(StatusCode::UNAUTHORIZED, "unauthorized", message)
124    }
125
126    /// Returns the standard `401 unauthenticated` authentication error.
127    pub fn unauthenticated() -> Self {
128        Self::new(
129            StatusCode::UNAUTHORIZED,
130            "unauthenticated",
131            "Authentication is required",
132        )
133    }
134
135    /// Returns a `403 forbidden` error with a safe message.
136    pub fn forbidden(message: impl Into<String>) -> Self {
137        Self::new(StatusCode::FORBIDDEN, "forbidden", message)
138    }
139
140    /// Returns the standard `403 permission_denied` authorization error.
141    pub fn permission_denied() -> Self {
142        Self::new(
143            StatusCode::FORBIDDEN,
144            "permission_denied",
145            "Permission denied",
146        )
147    }
148
149    /// Returns a `404 not_found` error with a safe message.
150    pub fn not_found(message: impl Into<String>) -> Self {
151        Self::new(StatusCode::NOT_FOUND, "not_found", message)
152    }
153
154    /// Returns a `409 conflict` error with a safe message.
155    pub fn conflict(message: impl Into<String>) -> Self {
156        Self::new(StatusCode::CONFLICT, "conflict", message)
157    }
158
159    /// Returns a `405 method_not_allowed` error with a stable safe message.
160    pub fn method_not_allowed() -> Self {
161        Self::new(
162            StatusCode::METHOD_NOT_ALLOWED,
163            "method_not_allowed",
164            "Method not allowed",
165        )
166    }
167
168    /// Returns the standard `429 rate_limited` error.
169    pub fn rate_limited() -> Self {
170        Self::new(
171            StatusCode::TOO_MANY_REQUESTS,
172            "rate_limited",
173            "Too many requests",
174        )
175    }
176
177    /// Returns an opaque `500 internal` error and retains its cause for logging.
178    pub fn internal(error: impl StdError + Send + Sync + 'static) -> Self {
179        let mut api_error = Self::internal_without_cause();
180        api_error.cause = Some(Box::new(error));
181        api_error
182    }
183
184    pub(crate) fn timeout() -> Self {
185        Self::new(
186            StatusCode::GATEWAY_TIMEOUT,
187            "request_timeout",
188            "The request timed out",
189        )
190    }
191
192    pub(crate) fn payload_too_large() -> Self {
193        Self::new(
194            StatusCode::PAYLOAD_TOO_LARGE,
195            "payload_too_large",
196            "The request body is too large",
197        )
198    }
199
200    pub(crate) fn json_rejection(code: impl Into<String>) -> Self {
201        Self::new(StatusCode::BAD_REQUEST, code, "The request is invalid").with_details(
202            BTreeMap::from([(
203                "body".to_owned(),
204                Value::String("must be valid JSON matching the request schema".to_owned()),
205            )]),
206        )
207    }
208
209    pub(crate) fn query_rejection() -> Self {
210        Self::validation_field("query", "must contain valid query parameters")
211    }
212
213    pub(crate) fn internal_without_cause() -> Self {
214        Self::new(
215            StatusCode::INTERNAL_SERVER_ERROR,
216            "internal",
217            "An internal error occurred",
218        )
219    }
220
221    /// Returns this error's HTTP status.
222    #[must_use]
223    pub const fn status(&self) -> StatusCode {
224        self.status
225    }
226
227    /// Returns this error's stable machine-readable code.
228    #[must_use]
229    pub fn code(&self) -> &str {
230        &self.code
231    }
232}
233
234impl From<JsonRejection> for ApiError {
235    fn from(_rejection: JsonRejection) -> Self {
236        Self::json_rejection("validation_failed")
237    }
238}
239
240impl From<PathRejection> for ApiError {
241    fn from(_rejection: PathRejection) -> Self {
242        Self::validation(BTreeMap::from([(
243            "path".to_owned(),
244            Value::String("must contain valid route parameters".to_owned()),
245        )]))
246    }
247}
248
249impl From<QueryRejection> for ApiError {
250    fn from(_rejection: QueryRejection) -> Self {
251        Self::query_rejection()
252    }
253}
254
255pub(crate) fn is_valid_error_code(code: &str) -> bool {
256    !code.is_empty()
257        && !code.starts_with('_')
258        && !code.ends_with('_')
259        && !code.contains("__")
260        && code
261            .bytes()
262            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
263}
264
265impl fmt::Debug for ApiError {
266    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
267        formatter
268            .debug_struct("ApiError")
269            .field("status", &self.status)
270            .field("code", &self.code)
271            .field("message", &self.message)
272            .field("details", &self.details)
273            .field("headers", &self.headers)
274            .field("has_cause", &self.cause.is_some())
275            .finish()
276    }
277}
278
279impl fmt::Display for ApiError {
280    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
281        write!(formatter, "{} ({})", self.message, self.code)
282    }
283}
284
285impl StdError for ApiError {
286    fn source(&self) -> Option<&(dyn StdError + 'static)> {
287        self.cause
288            .as_deref()
289            .map(|cause| cause as &(dyn StdError + 'static))
290    }
291}
292
293impl IntoResponse for ApiError {
294    fn into_response(self) -> Response {
295        let request_id = current_request_id();
296        if let Some(cause) = self.cause.as_deref() {
297            tracing::error!(error = %cause, "request failed with an internal error");
298        }
299
300        let envelope = ErrorEnvelope {
301            error: ErrorBody {
302                code: self.code,
303                message: self.message,
304                request_id: request_id.to_string(),
305                details: self.details,
306            },
307        };
308        let mut response = (self.status, Json(envelope)).into_response();
309        if let Some(headers) = self.headers {
310            response.headers_mut().extend(*headers);
311        }
312        response.headers_mut().insert(
313            crate::X_REQUEST_ID,
314            request_id
315                .header_value()
316                .expect("request IDs originate from a valid header or UUID"),
317        );
318        response
319    }
320}