Skip to main content

churust_core/
error.rs

1//! The status-carrying [`Error`] type and the crate-wide [`Result`] alias.
2//!
3//! Churust errors render directly into HTTP responses: each [`Error`] carries
4//! the [`StatusCode`] to send, a human-readable message used as the response
5//! body, an optional source error for diagnostics, and any headers to attach
6//! (for example `WWW-Authenticate` on a `401`). Handlers and extractors return
7//! `Result<T>`; when an `Err` reaches the pipeline it is converted via
8//! [`IntoResponse`](crate::IntoResponse).
9
10use http::header::{HeaderName, HeaderValue};
11use http::StatusCode;
12use std::fmt;
13
14/// Crate-wide result type, defaulting the error to [`Error`].
15///
16/// Handlers and extractors typically return `Result<T>` (i.e.
17/// `Result<T, Error>`); the second type parameter exists only so the alias can
18/// be reused with a different error type when desired.
19///
20/// ```
21/// use churust_core::{Error, Result};
22/// use http::StatusCode;
23///
24/// fn parse_age(raw: &str) -> Result<u8> {
25///     raw.parse()
26///         .map_err(|_| Error::bad_request("age must be a number"))
27/// }
28///
29/// assert!(parse_age("30").is_ok());
30/// assert_eq!(parse_age("nope").unwrap_err().status(), StatusCode::BAD_REQUEST);
31/// ```
32pub type Result<T, E = Error> = std::result::Result<T, E>;
33
34/// A handler/framework error carrying the HTTP status to respond with.
35///
36/// An `Error` bundles everything needed to render a failed request: the
37/// [`StatusCode`], a message (sent as the response body), an optional source
38/// error, and headers to attach to the rendered response. Return one from any
39/// handler or extractor and the pipeline turns it into a [`Response`](crate::Response) for you
40/// via the [`IntoResponse`](crate::IntoResponse) impl.
41///
42/// Use the constructors ([`Error::bad_request`], [`Error::not_found`],
43/// [`Error::internal`], or [`Error::new`] for an arbitrary status) and chain
44/// [`with_source`](Error::with_source) /
45/// [`with_response_header`](Error::with_response_header) as needed.
46///
47/// ```
48/// use churust_core::Error;
49/// use http::StatusCode;
50///
51/// let err = Error::not_found("user 42 does not exist");
52/// assert_eq!(err.status(), StatusCode::NOT_FOUND);
53/// assert_eq!(err.message(), "user 42 does not exist");
54/// ```
55#[derive(Debug)]
56pub struct Error {
57    status: StatusCode,
58    message: String,
59    source: Option<Box<dyn std::error::Error + Send + Sync>>,
60    headers: Vec<(HeaderName, HeaderValue)>,
61}
62
63impl Error {
64    /// Create an error with an explicit `status` and `message`.
65    ///
66    /// Prefer the named constructors ([`bad_request`](Error::bad_request),
67    /// [`not_found`](Error::not_found), [`internal`](Error::internal)) for the
68    /// common cases; use `new` when you need any other status (e.g. `401` or
69    /// `409`).
70    ///
71    /// ```
72    /// use churust_core::Error;
73    /// use http::StatusCode;
74    ///
75    /// let err = Error::new(StatusCode::CONFLICT, "already exists");
76    /// assert_eq!(err.status(), StatusCode::CONFLICT);
77    /// ```
78    pub fn new(status: StatusCode, message: impl Into<String>) -> Self {
79        Self {
80            status,
81            message: message.into(),
82            source: None,
83            headers: Vec::new(),
84        }
85    }
86    /// Create a `400 Bad Request` error — use for malformed input such as an
87    /// unparseable path parameter or invalid query string.
88    ///
89    /// ```
90    /// use churust_core::Error;
91    /// use http::StatusCode;
92    ///
93    /// assert_eq!(Error::bad_request("nope").status(), StatusCode::BAD_REQUEST);
94    /// ```
95    pub fn bad_request(message: impl Into<String>) -> Self {
96        Self::new(StatusCode::BAD_REQUEST, message)
97    }
98    /// Create a `404 Not Found` error — use when a requested resource does not
99    /// exist.
100    ///
101    /// ```
102    /// use churust_core::Error;
103    /// use http::StatusCode;
104    ///
105    /// assert_eq!(Error::not_found("gone").status(), StatusCode::NOT_FOUND);
106    /// ```
107    pub fn not_found(message: impl Into<String>) -> Self {
108        Self::new(StatusCode::NOT_FOUND, message)
109    }
110    /// Create a `500 Internal Server Error` — use for unexpected server-side
111    /// failures (e.g. a missing application-state dependency).
112    ///
113    /// ```
114    /// use churust_core::Error;
115    /// use http::StatusCode;
116    ///
117    /// assert_eq!(Error::internal("boom").status(), StatusCode::INTERNAL_SERVER_ERROR);
118    /// ```
119    pub fn internal(message: impl Into<String>) -> Self {
120        Self::new(StatusCode::INTERNAL_SERVER_ERROR, message)
121    }
122    /// Attach an underlying source error for diagnostics (exposed via
123    /// [`std::error::Error::source`]). The source does not change the rendered
124    /// response; it is for logging and error chaining. Returns `self` so it can
125    /// be chained.
126    ///
127    /// ```
128    /// use churust_core::Error;
129    /// use std::error::Error as _;
130    ///
131    /// let io = std::io::Error::new(std::io::ErrorKind::Other, "disk gone");
132    /// let err = Error::internal("write failed").with_source(io);
133    /// assert!(err.source().is_some());
134    /// ```
135    pub fn with_source(mut self, source: impl std::error::Error + Send + Sync + 'static) -> Self {
136        self.source = Some(Box::new(source));
137        self
138    }
139    /// The HTTP status this error renders to.
140    ///
141    /// ```
142    /// use churust_core::Error;
143    /// use http::StatusCode;
144    ///
145    /// assert_eq!(Error::bad_request("x").status(), StatusCode::BAD_REQUEST);
146    /// ```
147    pub fn status(&self) -> StatusCode {
148        self.status
149    }
150    /// The human-readable message, used as the response body when rendered.
151    ///
152    /// ```
153    /// use churust_core::Error;
154    ///
155    /// assert_eq!(Error::not_found("missing").message(), "missing");
156    /// ```
157    pub fn message(&self) -> &str {
158        &self.message
159    }
160
161    /// Attach a header to the response this error renders into (e.g.
162    /// `WWW-Authenticate` on a `401`). May be called repeatedly to add several
163    /// headers; returns `self` for chaining.
164    ///
165    /// ```
166    /// use churust_core::Error;
167    /// use http::{StatusCode, header::WWW_AUTHENTICATE, HeaderValue};
168    ///
169    /// let err = Error::new(StatusCode::UNAUTHORIZED, "login required")
170    ///     .with_response_header(WWW_AUTHENTICATE, HeaderValue::from_static("Bearer"));
171    /// assert_eq!(err.response_headers().len(), 1);
172    /// ```
173    pub fn with_response_header(mut self, name: HeaderName, value: HeaderValue) -> Self {
174        self.headers.push((name, value));
175        self
176    }
177
178    /// The headers to apply when rendering this error to a
179    /// [`Response`](crate::Response).
180    /// Returns an empty slice unless
181    /// [`with_response_header`](Error::with_response_header) was called.
182    ///
183    /// ```
184    /// use churust_core::Error;
185    ///
186    /// assert!(Error::bad_request("x").response_headers().is_empty());
187    /// ```
188    pub fn response_headers(&self) -> &[(HeaderName, HeaderValue)] {
189        &self.headers
190    }
191}
192
193impl fmt::Display for Error {
194    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
195        write!(f, "{}: {}", self.status, self.message)
196    }
197}
198
199impl std::error::Error for Error {
200    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
201        self.source
202            .as_ref()
203            .map(|s| s.as_ref() as &(dyn std::error::Error + 'static))
204    }
205}
206
207/// Let a user error type be returned from a handler with `?`.
208///
209/// [`Result<T>`](Result) already flows through `?` for Churust's own [`Error`],
210/// but a handler holding a `Result<T, sqlx::Error>` previously needed a
211/// `map_err` at every call site. Implement this and the conversion is
212/// automatic.
213///
214/// ```
215/// use churust_core::IntoError;
216/// use http::StatusCode;
217///
218/// #[derive(Debug)]
219/// struct NotFound;
220/// impl std::fmt::Display for NotFound {
221///     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
222///         write!(f, "no such row")
223///     }
224/// }
225/// impl IntoError for NotFound {
226///     fn status(&self) -> StatusCode { StatusCode::NOT_FOUND }
227/// }
228///
229/// fn find() -> Result<&'static str, NotFound> { Err(NotFound) }
230///
231/// fn handler() -> churust_core::Result<&'static str> {
232///     Ok(find()?)   // converts through IntoError
233/// }
234/// assert_eq!(handler().unwrap_err().status(), StatusCode::NOT_FOUND);
235/// ```
236///
237/// # Why `message` does not default to `Display`
238///
239/// It would be convenient and it would leak. Error types routinely render
240/// connection strings, file paths and query fragments in their `Display`, and a
241/// framework that forwarded those to clients by default would turn every
242/// adopter's first `?` into an information disclosure. The default is the
243/// status' canonical reason; opting *into* detail is safe, opting out of a leak
244/// is not.
245pub trait IntoError: std::fmt::Display {
246    /// The status this error should produce. Defaults to `500`.
247    fn status(&self) -> StatusCode {
248        StatusCode::INTERNAL_SERVER_ERROR
249    }
250
251    /// The client-facing message. Defaults to the status' canonical reason,
252    /// deliberately not `Display` — see the note on the trait.
253    fn message(&self) -> String {
254        self.status()
255            .canonical_reason()
256            .unwrap_or("error")
257            .to_string()
258    }
259}
260
261impl<E: IntoError> From<E> for Error {
262    fn from(e: E) -> Self {
263        Error::new(e.status(), e.message())
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn carries_status_and_message() {
273        let e = Error::not_found("nope");
274        assert_eq!(e.status(), StatusCode::NOT_FOUND);
275        assert_eq!(e.message(), "nope");
276    }
277
278    #[test]
279    fn display_includes_status() {
280        let e = Error::bad_request("bad");
281        assert!(format!("{e}").contains("400"));
282    }
283
284    #[test]
285    fn carries_response_headers() {
286        let e = Error::new(StatusCode::UNAUTHORIZED, "no").with_response_header(
287            http::header::WWW_AUTHENTICATE,
288            http::HeaderValue::from_static("Bearer"),
289        );
290        assert_eq!(e.response_headers().len(), 1);
291    }
292}