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