Skip to main content

siderite_core/
error.rs

1//! Error types: RFC 7807 problem details, server and body errors.
2
3use crate::response::{IntoResponse, Response, with_content_type};
4use http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
5use serde::Serialize;
6use serde_json::Value;
7use siderite_openapi::{Operation, Schema, SchemaObject, SchemaRegistry};
8use siderite_orm::{BackendError, OrmError, QueryError};
9use std::fmt::Display;
10use thiserror::Error;
11
12/// Media type of RFC 7807 problem documents.
13pub const PROBLEM_JSON: &str = "application/problem+json";
14
15const GENERIC_INTERNAL_DETAIL: &str = "An internal error occurred.";
16const FALLBACK_BODY: &[u8] =
17    br#"{"type":"about:blank","title":"Internal Server Error","status":500}"#;
18
19/// Convenience alias for handler results.
20pub type ApiResult<T> = Result<T, ApiError>;
21
22/// An HTTP error rendered as an RFC 7807 `application/problem+json` document.
23#[derive(Debug, Clone, Error)]
24#[error("{status}: {title}")]
25pub struct ApiError {
26    status: StatusCode,
27    title: &'static str,
28    type_uri: String,
29    detail: Option<String>,
30    /// Boxed so `ApiError` stays small when `serde_json` is built with
31    /// `preserve_order` (pulled in by the MongoDB driver under `--all-features`).
32    errors: Option<Box<Value>>,
33    /// Extra response headers (for example `WWW-Authenticate`); boxed for the
34    /// same reason as `errors`.
35    headers: Option<Box<HeaderMap>>,
36    /// Set when the request lacks the input entirely (see
37    /// [`absent`](Self::absent)).
38    absent: bool,
39    /// Stable machine-readable error code, if any.
40    code: Option<&'static str>,
41}
42
43#[derive(Serialize)]
44struct Problem<'a> {
45    #[serde(rename = "type")]
46    type_uri: &'a str,
47    title: &'a str,
48    status: u16,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    detail: Option<&'a str>,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    errors: Option<&'a Value>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    code: Option<&'a str>,
55}
56
57impl ApiError {
58    /// Create an error with an arbitrary status and human-readable detail.
59    pub fn new(status: StatusCode, detail: impl Into<String>) -> Self {
60        Self {
61            status,
62            title: status.canonical_reason().unwrap_or("Unknown"),
63            type_uri: "about:blank".to_owned(),
64            detail: Some(detail.into()),
65            errors: None,
66            headers: None,
67            absent: false,
68            code: None,
69        }
70    }
71
72    /// 404 Not Found.
73    pub fn not_found(detail: impl Into<String>) -> Self {
74        Self::new(StatusCode::NOT_FOUND, detail)
75    }
76
77    /// 400 Bad Request.
78    pub fn bad_request(detail: impl Into<String>) -> Self {
79        Self::new(StatusCode::BAD_REQUEST, detail)
80    }
81
82    /// 422 Unprocessable Entity carrying structured validation `errors`.
83    pub fn unprocessable(errors: Value) -> Self {
84        let mut err = Self::new(
85            StatusCode::UNPROCESSABLE_ENTITY,
86            "The request could not be processed; see `errors` for details.",
87        );
88        err.errors = Some(Box::new(errors));
89        err
90    }
91
92    /// 500 Internal Server Error. `source` is logged but never sent to the client.
93    pub fn internal(source: impl Display) -> Self {
94        tracing::error!(error = %source, "internal server error");
95        Self::new(StatusCode::INTERNAL_SERVER_ERROR, GENERIC_INTERNAL_DETAIL)
96    }
97
98    /// Override the problem `type` URI (defaults to `about:blank`).
99    #[must_use]
100    pub fn with_type(mut self, type_uri: impl Into<String>) -> Self {
101        self.type_uri = type_uri.into();
102        self
103    }
104
105    /// Set the stable machine-readable `code` extension member of the
106    /// problem document, for clients that branch on the kind of failure.
107    #[must_use]
108    pub fn with_code(mut self, code: &'static str) -> Self {
109        self.code = Some(code);
110        self
111    }
112
113    /// Attach a response header (appended if the name is already set).
114    #[must_use]
115    pub fn with_header(mut self, name: HeaderName, value: HeaderValue) -> Self {
116        self.headers
117            .get_or_insert_with(Default::default)
118            .append(name, value);
119        self
120    }
121
122    /// Mark this error as "the input is not there at all" rather than
123    /// "the input is there but wrong".
124    ///
125    /// `Option<T>` extractors turn absent errors into `None` and let every
126    /// other error through, so a malformed value still fails the request.
127    #[must_use]
128    pub fn absent(mut self) -> Self {
129        self.absent = true;
130        self
131    }
132
133    /// Whether this error was marked with [`absent`](Self::absent).
134    pub fn is_absent(&self) -> bool {
135        self.absent
136    }
137
138    /// Extra response headers attached with [`with_header`](Self::with_header).
139    pub fn headers(&self) -> Option<&HeaderMap> {
140        self.headers.as_deref()
141    }
142
143    /// The HTTP status code.
144    pub fn status(&self) -> StatusCode {
145        self.status
146    }
147
148    /// The short, status-derived title.
149    pub fn title(&self) -> &str {
150        self.title
151    }
152
153    /// The human-readable detail, if any.
154    pub fn detail(&self) -> Option<&str> {
155        self.detail.as_deref()
156    }
157
158    /// Structured error extension, if any.
159    pub fn errors(&self) -> Option<&Value> {
160        self.errors.as_deref()
161    }
162
163    /// The machine-readable error code, if any.
164    pub fn code(&self) -> Option<&str> {
165        self.code
166    }
167}
168
169impl IntoResponse for ApiError {
170    fn into_response(self) -> Response {
171        let problem = Problem {
172            type_uri: &self.type_uri,
173            title: self.title,
174            status: self.status.as_u16(),
175            detail: self.detail.as_deref(),
176            errors: self.errors.as_deref(),
177            code: self.code,
178        };
179        let body = serde_json::to_vec(&problem).unwrap_or_else(|_| FALLBACK_BODY.to_vec());
180        let mut response = with_content_type(PROBLEM_JSON, body);
181        *response.status_mut() = self.status;
182        if let Some(extra) = self.headers {
183            response.headers_mut().extend(*extra);
184        }
185        response
186    }
187
188    fn describe(op: &mut Operation, registry: &mut SchemaRegistry) {
189        let schema = registry.subschema::<ProblemSchema>();
190        op.add_response(
191            "default",
192            "Error (RFC 7807 problem details)",
193            Some((PROBLEM_JSON, schema)),
194        );
195    }
196}
197
198/// Schema of the RFC 7807 document produced by [`ApiError`].
199struct ProblemSchema;
200
201impl Schema for ProblemSchema {
202    fn schema_name() -> Option<&'static str> {
203        Some("Problem")
204    }
205
206    fn schema(_: &mut SchemaRegistry) -> SchemaObject {
207        SchemaObject::of_type("object")
208            .with(
209                "properties",
210                serde_json::json!({
211                    "type": {"type": "string"},
212                    "title": {"type": "string"},
213                    "status": {"type": "integer"},
214                    "detail": {"type": "string"},
215                    "errors": {},
216                    "code": {"type": "string"}
217                }),
218            )
219            .with("required", serde_json::json!(["type", "title", "status"]))
220    }
221}
222
223impl From<OrmError> for ApiError {
224    fn from(err: OrmError) -> Self {
225        match err {
226            OrmError::Query(QueryError::DoesNotExist) => Self::not_found("Resource not found."),
227            OrmError::Cursor(cursor) => {
228                // The token came from the client: say it is bad, not why.
229                let detail = "The pagination cursor is not valid.";
230                Self::bad_request(detail).with_code(cursor.code())
231            }
232            OrmError::Capability(cap) => {
233                tracing::error!(error = %cap, "unsupported backend capability");
234                Self::new(
235                    StatusCode::NOT_IMPLEMENTED,
236                    "The requested operation is not supported by the configured database backend.",
237                )
238            }
239            OrmError::Backend(BackendError::Constraint(detail)) => {
240                // The driver message may name tables/columns: log it, don't return it.
241                tracing::info!(%detail, "database constraint violation");
242                Self::new(
243                    StatusCode::CONFLICT,
244                    "The request conflicts with existing data (a uniqueness, reference or check constraint failed).",
245                )
246            }
247            other => Self::internal(other),
248        }
249    }
250}
251
252/// Failure to bind or run the HTTP server.
253#[derive(Debug, Error)]
254#[non_exhaustive]
255pub enum ServerError {
256    /// The listen address could not be bound.
257    #[error("failed to bind {addr}")]
258    Bind {
259        /// Address that was requested.
260        addr: String,
261        /// Underlying IO error.
262        #[source]
263        source: std::io::Error,
264    },
265    /// The application is misconfigured (detected before serving).
266    #[error("invalid application configuration: {0}")]
267    Configuration(String),
268    /// A startup or shutdown hook failed.
269    #[error("lifespan hook failed: {0}")]
270    Lifespan(#[source] ApiError),
271    /// The server failed while serving connections.
272    #[error("server error: {0}")]
273    Serve(#[source] std::io::Error),
274    /// Request draining or resource teardown exceeded its shutdown budget.
275    #[error("application shutdown exceeded its deadline")]
276    ShutdownTimeout,
277}
278
279/// Failure to read a request or response body.
280#[derive(Debug, Clone, Error)]
281#[error("{message}")]
282pub struct BodyError {
283    message: String,
284    too_large: bool,
285}
286
287impl BodyError {
288    pub(crate) fn failed(reason: String) -> Self {
289        Self {
290            message: format!("failed to read body: {reason}"),
291            too_large: false,
292        }
293    }
294
295    pub(crate) fn too_large(max_bytes: usize) -> Self {
296        Self {
297            message: format!("body exceeds the limit of {max_bytes} bytes"),
298            too_large: true,
299        }
300    }
301
302    /// Whether the body was rejected for exceeding its size limit.
303    pub fn is_too_large(&self) -> bool {
304        self.too_large
305    }
306}
307
308impl From<BodyError> for ApiError {
309    fn from(err: BodyError) -> Self {
310        if err.too_large {
311            ApiError::new(
312                StatusCode::PAYLOAD_TOO_LARGE,
313                format!("Request {}.", err.message),
314            )
315        } else {
316            ApiError::bad_request(err.message)
317        }
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    #[test]
326    fn orm_errors_map_to_http_statuses() {
327        let conflict = ApiError::from(OrmError::Backend(BackendError::Constraint(
328            "UNIQUE constraint failed: users.email".into(),
329        )));
330        assert_eq!(conflict.status(), StatusCode::CONFLICT);
331        let missing = ApiError::from(OrmError::Query(QueryError::DoesNotExist));
332        assert_eq!(missing.status(), StatusCode::NOT_FOUND);
333        let broken = ApiError::from(OrmError::Backend(BackendError::Database("x".into())));
334        assert_eq!(broken.status(), StatusCode::INTERNAL_SERVER_ERROR);
335        let foreign = siderite_orm::CursorError::ForeignQuery;
336        let stale = ApiError::from(OrmError::Cursor(foreign));
337        assert_eq!(stale.status(), StatusCode::BAD_REQUEST);
338        assert_eq!(stale.code(), Some("cursor.E003"));
339        let detail = Some("The pagination cursor is not valid.");
340        assert_eq!(stale.detail(), detail);
341    }
342}