dog_core/
errors.rs

1//! # Errors (Feathers-style)
2//!
3//! DogRS provides a Feathers-inspired set of structured errors.
4//! Core goals:
5//! - consistent status codes + class names
6//! - can be carried through anyhow::Error (for hook pipeline)
7//! - transport-agnostic (server crate decides how to serialize)
8//!
9//! If you enable feature `serde`, you also get:
10//! - `data` / `errors` as serde_json::Value
11//! - `to_json()` helper
12
13use std::fmt;
14
15use anyhow::Error as AnyError;
16
17/// A convenience result type for DogRS core APIs.
18pub type DogResult<T> = std::result::Result<T, AnyError>;
19
20/// Feathers-ish error class names + status codes.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22pub enum ErrorKind {
23    BadRequest,         // 400
24    NotAuthenticated,   // 401
25    Forbidden,          // 403
26    NotFound,           // 404
27    MethodNotAllowed,   // 405
28    NotAcceptable,      // 406
29    Timeout,            // 408
30    Conflict,           // 409
31    Gone,               // 410
32    LengthRequired,     // 411
33    Unprocessable,      // 422
34    TooManyRequests,    // 429
35    GeneralError,       // 500
36    NotImplemented,     // 501
37    BadGateway,         // 502
38    Unavailable,        // 503
39}
40
41impl ErrorKind {
42    pub fn status_code(&self) -> u16 {
43        match self {
44            ErrorKind::BadRequest => 400,
45            ErrorKind::NotAuthenticated => 401,
46            ErrorKind::Forbidden => 403,
47            ErrorKind::NotFound => 404,
48            ErrorKind::MethodNotAllowed => 405,
49            ErrorKind::NotAcceptable => 406,
50            ErrorKind::Timeout => 408,
51            ErrorKind::Conflict => 409,
52            ErrorKind::Gone => 410,
53            ErrorKind::LengthRequired => 411,
54            ErrorKind::Unprocessable => 422,
55            ErrorKind::TooManyRequests => 429,
56            ErrorKind::GeneralError => 500,
57            ErrorKind::NotImplemented => 501,
58            ErrorKind::BadGateway => 502,
59            ErrorKind::Unavailable => 503,
60        }
61    }
62
63    /// Feathers error `name` (e.g. "NotFound")
64    pub fn name(&self) -> &'static str {
65        match self {
66            ErrorKind::BadRequest => "BadRequest",
67            ErrorKind::NotAuthenticated => "NotAuthenticated",
68            ErrorKind::Forbidden => "Forbidden",
69            ErrorKind::NotFound => "NotFound",
70            ErrorKind::MethodNotAllowed => "MethodNotAllowed",
71            ErrorKind::NotAcceptable => "NotAcceptable",
72            ErrorKind::Timeout => "Timeout",
73            ErrorKind::Conflict => "Conflict",
74            ErrorKind::Gone => "Gone",
75            ErrorKind::LengthRequired => "LengthRequired",
76            ErrorKind::Unprocessable => "Unprocessable",
77            ErrorKind::TooManyRequests => "TooManyRequests",
78            ErrorKind::GeneralError => "GeneralError",
79            ErrorKind::NotImplemented => "NotImplemented",
80            ErrorKind::BadGateway => "BadGateway",
81            ErrorKind::Unavailable => "Unavailable",
82        }
83    }
84
85    /// Feathers error `className` (commonly kebab-cased)
86    pub fn class_name(&self) -> &'static str {
87        match self {
88            ErrorKind::BadRequest => "bad-request",
89            ErrorKind::NotAuthenticated => "not-authenticated",
90            ErrorKind::Forbidden => "forbidden",
91            ErrorKind::NotFound => "not-found",
92            ErrorKind::MethodNotAllowed => "method-not-allowed",
93            ErrorKind::NotAcceptable => "not-acceptable",
94            ErrorKind::Timeout => "timeout",
95            ErrorKind::Conflict => "conflict",
96            ErrorKind::Gone => "gone",
97            ErrorKind::LengthRequired => "length-required",
98            ErrorKind::Unprocessable => "unprocessable",
99            ErrorKind::TooManyRequests => "too-many-requests",
100            ErrorKind::GeneralError => "general-error",
101            ErrorKind::NotImplemented => "not-implemented",
102            ErrorKind::BadGateway => "bad-gateway",
103            ErrorKind::Unavailable => "unavailable",
104        }
105    }
106}
107
108#[cfg(feature = "serde")]
109pub type ErrorValue = serde_json::Value;
110
111#[cfg(not(feature = "serde"))]
112pub type ErrorValue = std::sync::Arc<dyn std::any::Any + Send + Sync>;
113
114/// A structured DogRS error that can live inside `anyhow::Error`.
115///
116/// Mirrors Feathers-style fields:
117/// - name
118/// - message
119/// - code (HTTP status)
120/// - class_name
121/// - data (optional)
122/// - errors (optional)
123#[derive(Debug)]
124pub struct DogError {
125    pub kind: ErrorKind,
126    pub message: String,
127    pub data: Option<ErrorValue>,
128    pub errors: Option<ErrorValue>,
129    pub source: Option<AnyError>,
130}
131
132impl DogError {
133    pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
134        Self {
135            kind,
136            message: message.into(),
137            data: None,
138            errors: None,
139            source: None,
140        }
141    }
142
143    pub fn with_data(mut self, data: ErrorValue) -> Self {
144        self.data = Some(data);
145        self
146    }
147
148    pub fn with_errors(mut self, errors: ErrorValue) -> Self {
149        self.errors = Some(errors);
150        self
151    }
152
153    pub fn with_source(mut self, source: AnyError) -> Self {
154        self.source = Some(source);
155        self
156    }
157
158    pub fn code(&self) -> u16 {
159        self.kind.status_code()
160    }
161
162    pub fn name(&self) -> &'static str {
163        self.kind.name()
164    }
165
166    pub fn class_name(&self) -> &'static str {
167        self.kind.class_name()
168    }
169
170    /// Convert into `anyhow::Error` so it flows through your hook pipeline.
171    pub fn into_anyhow(self) -> AnyError {
172        AnyError::new(self)
173    }
174
175    /// Downcast an `anyhow::Error` to a `DogError` if possible.
176    pub fn from_anyhow(err: &AnyError) -> Option<&DogError> {
177        err.downcast_ref::<DogError>()
178    }
179
180    /// Turn any error into a DogError:
181    /// - if it’s already a DogError, keep it (lossless)
182    /// - otherwise wrap as GeneralError
183    pub fn normalize(err: AnyError) -> DogError {
184        match err.downcast::<DogError>() {
185            Ok(dog) => dog,
186            Err(other) => DogError::new(ErrorKind::GeneralError, other.to_string()).with_source(other),
187        }
188    }
189
190    /// A “safe” version suitable for returning to clients:
191    /// - keep kind/message/code/class_name/data/errors
192    /// - drop the inner `source` (stack/secret details)
193    pub fn sanitize_for_client(&self) -> DogError {
194        DogError {
195            kind: self.kind,
196            message: self.message.clone(),
197            data: self.data.clone(),
198            errors: self.errors.clone(),
199            source: None,
200        }
201    }
202
203    // ---- Constructors (Feathers-style) ----
204
205    pub fn bad_request(msg: impl Into<String>) -> Self {
206        Self::new(ErrorKind::BadRequest, msg)
207    }
208    pub fn not_authenticated(msg: impl Into<String>) -> Self {
209        Self::new(ErrorKind::NotAuthenticated, msg)
210    }
211    pub fn forbidden(msg: impl Into<String>) -> Self {
212        Self::new(ErrorKind::Forbidden, msg)
213    }
214    pub fn not_found(msg: impl Into<String>) -> Self {
215        Self::new(ErrorKind::NotFound, msg)
216    }
217    pub fn method_not_allowed(msg: impl Into<String>) -> Self {
218        Self::new(ErrorKind::MethodNotAllowed, msg)
219    }
220    pub fn not_acceptable(msg: impl Into<String>) -> Self {
221        Self::new(ErrorKind::NotAcceptable, msg)
222    }
223    pub fn timeout(msg: impl Into<String>) -> Self {
224        Self::new(ErrorKind::Timeout, msg)
225    }
226    pub fn conflict(msg: impl Into<String>) -> Self {
227        Self::new(ErrorKind::Conflict, msg)
228    }
229    pub fn gone(msg: impl Into<String>) -> Self {
230        Self::new(ErrorKind::Gone, msg)
231    }
232    pub fn length_required(msg: impl Into<String>) -> Self {
233        Self::new(ErrorKind::LengthRequired, msg)
234    }
235    pub fn unprocessable(msg: impl Into<String>) -> Self {
236        Self::new(ErrorKind::Unprocessable, msg)
237    }
238    pub fn too_many_requests(msg: impl Into<String>) -> Self {
239        Self::new(ErrorKind::TooManyRequests, msg)
240    }
241    pub fn general_error(msg: impl Into<String>) -> Self {
242        Self::new(ErrorKind::GeneralError, msg)
243    }
244    pub fn not_implemented(msg: impl Into<String>) -> Self {
245        Self::new(ErrorKind::NotImplemented, msg)
246    }
247    pub fn bad_gateway(msg: impl Into<String>) -> Self {
248        Self::new(ErrorKind::BadGateway, msg)
249    }
250    pub fn unavailable(msg: impl Into<String>) -> Self {
251        Self::new(ErrorKind::Unavailable, msg)
252    }
253}
254
255impl fmt::Display for DogError {
256    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
257        write!(f, "{} ({}): {}", self.name(), self.code(), self.message)
258    }
259}
260
261impl std::error::Error for DogError {
262    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
263        self.source
264            .as_ref()
265            .map(|e| e.as_ref() as &(dyn std::error::Error + 'static))
266    }
267}
268
269#[cfg(feature = "serde")]
270impl DogError {
271    /// Feathers-ish JSON payload.
272    pub fn to_json(&self) -> serde_json::Value {
273        use serde_json::json;
274
275        let mut base = json!({
276            "name": self.name(),
277            "message": self.message,
278            "code": self.code(),
279            "className": self.class_name(),
280        });
281
282        if let Some(d) = &self.data {
283            base["data"] = d.clone();
284        }
285        if let Some(e) = &self.errors {
286            base["errors"] = e.clone();
287        }
288        base
289    }
290}
291
292/// Convenience trait: convert a `DogError` into `anyhow::Error`.
293pub trait IntoAnyhowDogError {
294    fn into_anyhow(self) -> AnyError;
295}
296
297impl IntoAnyhowDogError for DogError {
298    fn into_anyhow(self) -> AnyError {
299        self.into_anyhow()
300    }
301}
302
303/// Convenience helper for “bail with DogError”.
304#[macro_export]
305macro_rules! bail_dog {
306    ($ctor:ident, $msg:expr) => {
307        return Err($crate::errors::DogError::$ctor($msg).into_anyhow());
308    };
309    ($ctor:ident, $fmt:expr, $($arg:tt)*) => {
310        return Err($crate::errors::DogError::$ctor(format!($fmt, $($arg)*)).into_anyhow());
311    };
312}