Skip to main content

rainier_support/
error.rs

1//! The framework error type.
2//!
3//! Every Rainier component fails with the same [`Error`], and every `Error`
4//! carries the two things the HTTP layer needs to render it without knowing
5//! where it came from: an [`ErrorKind`] that maps to a status code, and an
6//! optional structured `details` payload.
7//!
8//! That is what keeps the crate graph acyclic. `rainier-validation` cannot
9//! add a `Validation` variant to an enum in `rainier-support` (that would be a
10//! cycle), so instead it builds `Error::validation(details)` and the exception
11//! handler in `rainier-http` renders `details` as JSON. The same trick serves
12//! auth, authorization, and model-not-found — no component needs to know the
13//! others' error types.
14
15use std::fmt;
16
17/// The class of failure, which is also the HTTP status it renders as.
18///
19/// Mirrors the exceptions Laravel special-cases in its handler
20/// (`AuthenticationException` → 401, `ValidationException` → 422, …) so the
21/// same "throw a domain error, get the right response" flow works here.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum ErrorKind {
24    /// 400 — the request was malformed.
25    BadRequest,
26    /// 401 — no (or invalid) credentials. Raised by guards.
27    Unauthenticated,
28    /// 403 — authenticated but not permitted. Raised by gates/policies.
29    Unauthorized,
30    /// 404 — no such route or model.
31    NotFound,
32    /// 405 — the route exists but not for this method.
33    MethodNotAllowed,
34    /// 409 — the request conflicts with current state.
35    Conflict,
36    /// 413 — the body exceeded the configured limit.
37    PayloadTooLarge,
38    /// 422 — the request was well-formed but failed validation.
39    Validation,
40    /// 429 — throttled.
41    TooManyRequests,
42    /// 500 — an unhandled internal failure.
43    Internal,
44    /// 503 — the app is down for maintenance or a dependency is unavailable.
45    ServiceUnavailable,
46    /// Any other status, set explicitly.
47    Status(u16),
48}
49
50impl ErrorKind {
51    /// The HTTP status this kind renders as.
52    pub fn status(self) -> u16 {
53        match self {
54            ErrorKind::BadRequest => 400,
55            ErrorKind::Unauthenticated => 401,
56            ErrorKind::Unauthorized => 403,
57            ErrorKind::NotFound => 404,
58            ErrorKind::MethodNotAllowed => 405,
59            ErrorKind::Conflict => 409,
60            ErrorKind::PayloadTooLarge => 413,
61            ErrorKind::Validation => 422,
62            ErrorKind::TooManyRequests => 429,
63            ErrorKind::Internal => 500,
64            ErrorKind::ServiceUnavailable => 503,
65            ErrorKind::Status(code) => code,
66        }
67    }
68
69    /// `true` for 5xx — the errors worth logging as faults rather than as
70    /// ordinary client mistakes.
71    pub fn is_server_error(self) -> bool {
72        self.status() >= 500
73    }
74}
75
76/// A framework error: a [`kind`](Error::kind), a human message, an optional
77/// structured `details` payload, and an optional underlying cause.
78#[derive(Debug)]
79pub struct Error {
80    kind: ErrorKind,
81    message: String,
82    details: Option<serde_json::Value>,
83    source: Option<anyhow::Error>,
84}
85
86impl Error {
87    /// Build an error of an explicit kind.
88    pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
89        Self { kind, message: message.into(), details: None, source: None }
90    }
91
92    /// 500 — an internal failure.
93    pub fn internal(message: impl Into<String>) -> Self {
94        Self::new(ErrorKind::Internal, message)
95    }
96
97    /// 400 — a malformed request.
98    pub fn bad_request(message: impl Into<String>) -> Self {
99        Self::new(ErrorKind::BadRequest, message)
100    }
101
102    /// 401 — missing or invalid credentials.
103    pub fn unauthenticated(message: impl Into<String>) -> Self {
104        Self::new(ErrorKind::Unauthenticated, message)
105    }
106
107    /// 403 — authenticated but not permitted.
108    pub fn unauthorized(message: impl Into<String>) -> Self {
109        Self::new(ErrorKind::Unauthorized, message)
110    }
111
112    /// 404 — nothing matched.
113    pub fn not_found(message: impl Into<String>) -> Self {
114        Self::new(ErrorKind::NotFound, message)
115    }
116
117    /// 409 — conflicting state.
118    pub fn conflict(message: impl Into<String>) -> Self {
119        Self::new(ErrorKind::Conflict, message)
120    }
121
122    /// 422 with a structured field-error payload — what a failed
123    /// [`FormRequest`](https://laravel.com/docs/validation#form-request-validation)
124    /// produces.
125    pub fn validation(details: serde_json::Value) -> Self {
126        Self::new(ErrorKind::Validation, "The given data was invalid.").with_details(details)
127    }
128
129    /// Attach a structured payload rendered alongside the message.
130    pub fn with_details(mut self, details: serde_json::Value) -> Self {
131        self.details = Some(details);
132        self
133    }
134
135    /// Attach the underlying cause.
136    pub fn with_source(mut self, source: impl Into<anyhow::Error>) -> Self {
137        self.source = Some(source.into());
138        self
139    }
140
141    /// Re-label the kind (and therefore the status) without losing the rest.
142    pub fn with_kind(mut self, kind: ErrorKind) -> Self {
143        self.kind = kind;
144        self
145    }
146
147    /// This error's kind.
148    pub fn kind(&self) -> ErrorKind {
149        self.kind
150    }
151
152    /// The HTTP status this error renders as.
153    pub fn status(&self) -> u16 {
154        self.kind.status()
155    }
156
157    /// The human-readable message.
158    pub fn message(&self) -> &str {
159        &self.message
160    }
161
162    /// The structured payload, if any.
163    pub fn details(&self) -> Option<&serde_json::Value> {
164        self.details.as_ref()
165    }
166
167    /// The underlying cause, if one was attached.
168    pub fn source_error(&self) -> Option<&anyhow::Error> {
169        self.source.as_ref()
170    }
171}
172
173impl fmt::Display for Error {
174    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
175        write!(f, "{}", self.message)?;
176        if let Some(source) = &self.source {
177            write!(f, ": {source}")?;
178        }
179        Ok(())
180    }
181}
182
183impl std::error::Error for Error {
184    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
185        self.source.as_ref().map(|e| e.as_ref() as &(dyn std::error::Error + 'static))
186    }
187}
188
189// `anyhow::Error` is what rainier_orm fails with, so this conversion is the
190// seam between the ORM's errors and the framework's. A database failure is
191// internal until something upstream re-labels it (e.g. a repository turning a
192// missing row into `not_found`).
193impl From<anyhow::Error> for Error {
194    fn from(source: anyhow::Error) -> Self {
195        Self::internal(source.to_string()).with_source(source)
196    }
197}
198
199impl From<std::io::Error> for Error {
200    fn from(source: std::io::Error) -> Self {
201        let kind = match source.kind() {
202            std::io::ErrorKind::NotFound => ErrorKind::NotFound,
203            std::io::ErrorKind::PermissionDenied => ErrorKind::Unauthorized,
204            _ => ErrorKind::Internal,
205        };
206        Self::new(kind, source.to_string()).with_source(anyhow::Error::new(source))
207    }
208}
209
210impl From<serde_json::Error> for Error {
211    fn from(source: serde_json::Error) -> Self {
212        Self::bad_request(format!("malformed JSON: {source}"))
213            .with_source(anyhow::Error::new(source))
214    }
215}
216
217impl From<std::fmt::Error> for Error {
218    fn from(source: std::fmt::Error) -> Self {
219        Self::internal(source.to_string())
220    }
221}
222
223/// The framework result type.
224pub type Result<T, E = Error> = std::result::Result<T, E>;
225
226/// Adds `.context(..)` to any `Result` whose error converts into [`Error`],
227/// the way `anyhow::Context` does — but preserving the [`ErrorKind`] when the
228/// error already is a Rainier error, so adding context to a 404 does not
229/// silently turn it into a 500.
230pub trait Context<T> {
231    /// Prefix the error message with `context`.
232    fn context(self, context: impl fmt::Display) -> Result<T>;
233
234    /// Prefix the error message with a lazily built `context`.
235    fn with_context<C: fmt::Display>(self, context: impl FnOnce() -> C) -> Result<T>;
236}
237
238impl<T, E> Context<T> for Result<T, E>
239where
240    E: Into<Error>,
241{
242    fn context(self, context: impl fmt::Display) -> Result<T> {
243        self.map_err(|e| {
244            let e = e.into();
245            let kind = e.kind();
246            Error::new(kind, format!("{context}: {e}"))
247        })
248    }
249
250    fn with_context<C: fmt::Display>(self, context: impl FnOnce() -> C) -> Result<T> {
251        self.map_err(|e| {
252            let e = e.into();
253            let kind = e.kind();
254            Error::new(kind, format!("{}: {e}", context()))
255        })
256    }
257}
258
259impl<T> Context<T> for Option<T> {
260    fn context(self, context: impl fmt::Display) -> Result<T> {
261        self.ok_or_else(|| Error::internal(context.to_string()))
262    }
263
264    fn with_context<C: fmt::Display>(self, context: impl FnOnce() -> C) -> Result<T> {
265        self.ok_or_else(|| Error::internal(context().to_string()))
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    #[test]
274    fn kinds_map_to_statuses() {
275        assert_eq!(ErrorKind::Validation.status(), 422);
276        assert_eq!(ErrorKind::Unauthenticated.status(), 401);
277        assert_eq!(ErrorKind::Status(418).status(), 418);
278        assert!(ErrorKind::Internal.is_server_error());
279        assert!(!ErrorKind::NotFound.is_server_error());
280    }
281
282    #[test]
283    fn context_preserves_the_kind() {
284        let err: Result<()> = Err(Error::not_found("no such post"));
285        let err = err.context("loading the post").unwrap_err();
286        assert_eq!(err.kind(), ErrorKind::NotFound);
287        assert_eq!(err.message(), "loading the post: no such post");
288    }
289
290    #[test]
291    fn validation_errors_carry_their_details() {
292        let err = Error::validation(serde_json::json!({ "email": ["required"] }));
293        assert_eq!(err.status(), 422);
294        assert!(err.details().is_some());
295    }
296
297    #[test]
298    fn anyhow_errors_arrive_as_internal() {
299        let err: Error = anyhow::anyhow!("connection reset").into();
300        assert_eq!(err.status(), 500);
301        assert!(err.source_error().is_some());
302    }
303}