Skip to main content

rok_core/
error.rs

1use crate::rok_exception;
2use thiserror::Error;
3
4// ── RokError (legacy enum) ────────────────────────────────────────────────────
5
6#[derive(Debug, Error)]
7#[non_exhaustive]
8pub enum RokError {
9    #[error("not found")]
10    NotFound,
11
12    #[error("forbidden")]
13    Forbidden,
14
15    #[cfg(feature = "orm")]
16    #[error("database error: {0}")]
17    Orm(sqlx::Error),
18
19    #[error("internal server error: {0}")]
20    Internal(String),
21}
22
23#[cfg(feature = "orm")]
24impl From<sqlx::Error> for RokError {
25    fn from(e: sqlx::Error) -> Self {
26        match e {
27            sqlx::Error::RowNotFound => Self::NotFound,
28            other => Self::Orm(other),
29        }
30    }
31}
32
33impl From<String> for RokError {
34    fn from(s: String) -> Self {
35        Self::Internal(s)
36    }
37}
38
39impl From<&str> for RokError {
40    fn from(s: &str) -> Self {
41        Self::Internal(s.to_string())
42    }
43}
44
45#[cfg(feature = "axum")]
46mod axum_impl {
47    use super::RokError;
48    use axum::{
49        http::StatusCode,
50        response::{IntoResponse, Response},
51        Json,
52    };
53
54    impl IntoResponse for RokError {
55        fn into_response(self) -> Response {
56            match self {
57                RokError::NotFound => {
58                    (StatusCode::NOT_FOUND, Json(serde_json::json!({"message": "not found"}))).into_response()
59                }
60                RokError::Forbidden => {
61                    (StatusCode::FORBIDDEN, Json(serde_json::json!({"message": "forbidden"}))).into_response()
62                }
63                #[cfg(feature = "orm")]
64                RokError::Orm(e) => {
65                    #[cfg(feature = "app")]
66                    tracing::error!(error = %e, "Internal server error (ORM)");
67                    (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"message": "internal server error"}))).into_response()
68                }
69                #[cfg(feature = "orm")]
70                RokError::Internal(ref msg) => {
71                    #[cfg(feature = "app")]
72                    tracing::error!(error = %msg, "Internal server error");
73                    (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"message": "internal server error"}))).into_response()
74                }
75                #[cfg(not(feature = "orm"))]
76                RokError::Internal(ref msg) => {
77                    #[cfg(feature = "app")]
78                    tracing::error!(error = %msg, "Internal server error");
79                    (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"message": "internal server error"}))).into_response()
80                }
81            }
82        }
83    }
84}
85
86// ── RokException trait ────────────────────────────────────────────────────────
87
88/// Base trait for all typed exceptions in the rok ecosystem.
89///
90/// Analogous to AdonisJS `Exception` — every crate exposes `E_*` structs
91/// implementing this trait.  Self-handled exceptions can convert themselves
92/// to an HTTP response; non-self-handled ones must be caught by a global
93/// error handler.
94pub trait RokException: std::error::Error + Send + Sync + 'static {
95    /// Exception identifier (e.g. `"E_ROW_NOT_FOUND"`).
96    fn name(&self) -> &'static str;
97    /// HTTP status code.
98    fn status_code(&self) -> u16;
99    /// Whether this exception can produce its own HTTP response.
100    fn self_handled(&self) -> bool {
101        true
102    }
103    /// Optional i18n translation key.
104    fn translation_id(&self) -> Option<&'static str> {
105        None
106    }
107    /// Optional help text for debugging.
108    fn help(&self) -> Option<&'static str> {
109        None
110    }
111}
112
113// ── Standard exceptions ──────────────────────────────────────────────────────
114
115rok_exception! {
116    /// Generic HTTP error. Can be instantiated with custom status and messages.
117    pub struct E_HTTP_EXCEPTION {
118        status = 500,
119        self_handled = true,
120        fields: {
121            pub message: String,
122        }
123    }
124}
125
126rok_exception! {
127    /// Raised when `Response::abort()` is called inside a handler.
128    pub struct E_HTTP_REQUEST_ABORTED {
129        status = 500,
130        self_handled = true,
131        fields: {
132            pub message: String,
133        }
134    }
135}
136
137rok_exception! {
138    /// Raised when the server receives a request for a non-existing route.
139    pub struct E_ROUTE_NOT_FOUND {
140        status = 404,
141        self_handled = true,
142        fields: {
143            pub method: String,
144            pub path: String,
145        }
146    }
147}
148
149rok_exception! {
150    /// Raised when a route exists but the HTTP method does not match.
151    pub struct E_METHOD_NOT_ALLOWED {
152        status = 405,
153        self_handled = true,
154        fields: {
155            pub method: String,
156            pub path: String,
157        }
158    }
159}
160
161rok_exception! {
162    /// Raised when attempting to generate a URL for a route name that does not exist.
163    pub struct E_CANNOT_LOOKUP_ROUTE {
164        status = 500,
165        self_handled = false,
166        fields: {
167            pub route_name: String,
168        }
169    }
170}
171
172rok_exception! {
173    /// Raised when a required route parameter is not present in the URL.
174    pub struct E_MISSING_ROUTE_PARAM {
175        status = 500,
176        self_handled = false,
177        fields: {
178            pub param_name: String,
179        }
180    }
181}
182
183rok_exception! {
184    /// Raised when `APP_KEY` length is less than 16 characters.
185    pub struct E_INSECURE_APP_KEY {
186        status = 500,
187        self_handled = false,
188        fields: {
189            pub actual_length: usize,
190        }
191    }
192}
193
194rok_exception! {
195    /// Raised when `APP_KEY` is not defined in config.
196    pub struct E_MISSING_APP_KEY {
197        status = 500,
198        self_handled = false,
199        fields: {
200        }
201    }
202}
203
204rok_exception! {
205    /// Raised when one or more environment variables fail validation.
206    pub struct E_INVALID_ENV_VARIABLES {
207        status = 500,
208        self_handled = false,
209        fields: {
210            pub help: String,
211        }
212    }
213}
214
215rok_exception! {
216    /// Raised when a required config key is missing.
217    pub struct E_MISSING_CONFIG_KEY {
218        status = 500,
219        self_handled = false,
220        fields: {
221            pub key: String,
222        }
223    }
224}
225
226rok_exception! {
227    /// Raised when a config value cannot be parsed into the expected type.
228    pub struct E_CONFIG_PARSE_ERROR {
229        status = 500,
230        self_handled = false,
231        fields: {
232            pub key: String,
233            pub expected: String,
234        }
235    }
236}
237
238rok_exception! {
239    /// Raised when attempting to write to a session opened in read-only mode.
240    pub struct E_SESSION_NOT_MUTABLE {
241        status = 500,
242        self_handled = false,
243        fields: {
244        }
245    }
246}
247
248rok_exception! {
249    /// Raised when the session store is accessed before the session middleware has run.
250    pub struct E_SESSION_NOT_READY {
251        status = 500,
252        self_handled = false,
253        fields: {
254        }
255    }
256}
257
258// ── Box<dyn RokException> → axum IntoResponse ────────────────────────────────
259
260#[cfg(feature = "axum")]
261impl axum::response::IntoResponse for Box<dyn RokException> {
262    fn into_response(self) -> axum::response::Response {
263        let status = axum::http::StatusCode::from_u16(self.status_code())
264            .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
265        let body = serde_json::json!({
266            "error": self.name(),
267            "message": self.to_string(),
268            "statusCode": self.status_code(),
269        });
270        (status, axum::Json(body)).into_response()
271    }
272}
273
274// ── rok_exception! macro ─────────────────────────────────────────────────────
275
276/// Define a typed exception struct implementing [`RokException`].
277///
278/// Self-handled exceptions (default) also implement `axum::IntoResponse`
279/// when the `axum` feature is enabled.
280///
281/// # Example
282///
283/// ```rust,ignore
284/// use rok_core::rok_exception;
285///
286/// rok_exception! {
287///     /// Raised when a query returns no rows.
288///     pub struct E_ROW_NOT_FOUND {
289///         status = 404,
290///         self_handled = false,
291///         fields: {
292///             pub model: Option<&'static str>,
293///             pub id: Option<String>,
294///         },
295///     }
296/// }
297/// ```
298#[macro_export]
299macro_rules! rok_exception {
300    // ── Self-handled + translation ──────────────────────────────────────────
301    (
302        $(#[$meta:meta])*
303        $vis:vis struct $name:ident {
304            status = $status:expr,
305            self_handled = true,
306            translation = $translation:expr,
307            fields: { $(pub $field:ident: $ty:ty),* $(,)? }
308        }
309    ) => {
310        $(#[$meta])*
311        #[allow(non_camel_case_types)]
312        #[derive(Debug)]
313        $vis struct $name {
314            $(pub $field: $ty,)*
315        }
316        impl $name { $vis fn new($($field: $ty),*) -> Self { Self { $($field),* } } }
317        impl std::fmt::Display for $name {
318            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
319                use $crate::RokException;
320                write!(f, concat!("{} (", stringify!($name), ")"), self.status_code())
321            }
322        }
323        impl std::error::Error for $name {}
324        impl $crate::error::RokException for $name {
325            fn name(&self) -> &'static str { stringify!($name) }
326            fn status_code(&self) -> u16 { $status }
327            fn self_handled(&self) -> bool { true }
328            fn translation_id(&self) -> Option<&'static str> { Some($translation) }
329        }
330        #[cfg(feature = "axum")]
331        impl axum::response::IntoResponse for $name {
332            fn into_response(self) -> axum::response::Response {
333                let status = axum::http::StatusCode::from_u16($status)
334                    .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
335                let body = serde_json::json!({
336                    "error": stringify!($name),
337                    "message": self.to_string(),
338                    "statusCode": $status,
339                });
340                (status, axum::Json(body)).into_response()
341            }
342        }
343    };
344
345    // ── Self-handled, no translation
346    (
347        $(#[$meta:meta])*
348        $vis:vis struct $name:ident {
349            status = $status:expr,
350            self_handled = true,
351            fields: { $(pub $field:ident: $ty:ty),* $(,)? }
352        }
353    ) => {
354        $(#[$meta])*
355        #[allow(non_camel_case_types)]
356        #[derive(Debug)]
357        $vis struct $name {
358            $(pub $field: $ty,)*
359        }
360        impl $name { $vis fn new($($field: $ty),*) -> Self { Self { $($field),* } } }
361        impl std::fmt::Display for $name {
362            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
363                use $crate::RokException;
364                write!(f, concat!("{} (", stringify!($name), ")"), self.status_code())
365            }
366        }
367        impl std::error::Error for $name {}
368        impl $crate::error::RokException for $name {
369            fn name(&self) -> &'static str { stringify!($name) }
370            fn status_code(&self) -> u16 { $status }
371            fn self_handled(&self) -> bool { true }
372            fn translation_id(&self) -> Option<&'static str> { None }
373        }
374        #[cfg(feature = "axum")]
375        impl axum::response::IntoResponse for $name {
376            fn into_response(self) -> axum::response::Response {
377                let status = axum::http::StatusCode::from_u16($status)
378                    .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
379                let body = serde_json::json!({
380                    "error": stringify!($name),
381                    "message": self.to_string(),
382                    "statusCode": $status,
383                });
384                (status, axum::Json(body)).into_response()
385            }
386        }
387    };
388
389    // ── Non-self-handled + translation ──────────────────────────────────────
390    (
391        $(#[$meta:meta])*
392        $vis:vis struct $name:ident {
393            status = $status:expr,
394            self_handled = false,
395            translation = $translation:expr,
396            fields: { $(pub $field:ident: $ty:ty),* $(,)? }
397        }
398    ) => {
399        $(#[$meta])*
400        #[allow(non_camel_case_types)]
401        #[derive(Debug)]
402        $vis struct $name {
403            $(pub $field: $ty,)*
404        }
405        impl $name { $vis fn new($($field: $ty),*) -> Self { Self { $($field),* } } }
406        impl std::fmt::Display for $name {
407            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
408                use $crate::RokException;
409                write!(f, concat!("{} (", stringify!($name), ")"), self.status_code())
410            }
411        }
412        impl std::error::Error for $name {}
413        impl $crate::error::RokException for $name {
414            fn name(&self) -> &'static str { stringify!($name) }
415            fn status_code(&self) -> u16 { $status }
416            fn self_handled(&self) -> bool { false }
417            fn translation_id(&self) -> Option<&'static str> { Some($translation) }
418        }
419    };
420
421    // ── Non-self-handled, no translation ────────────────────────────────────
422    (
423        $(#[$meta:meta])*
424        $vis:vis struct $name:ident {
425            status = $status:expr,
426            self_handled = false,
427            fields: { $(pub $field:ident: $ty:ty),* $(,)? }
428        }
429    ) => {
430        $(#[$meta])*
431        #[allow(non_camel_case_types)]
432        #[derive(Debug)]
433        $vis struct $name {
434            $(pub $field: $ty,)*
435        }
436        impl $name { $vis fn new($($field: $ty),*) -> Self { Self { $($field),* } } }
437        impl std::fmt::Display for $name {
438            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
439                use $crate::RokException;
440                write!(f, concat!("{} (", stringify!($name), ")"), self.status_code())
441            }
442        }
443        impl std::error::Error for $name {}
444        impl $crate::error::RokException for $name {
445            fn name(&self) -> &'static str { stringify!($name) }
446            fn status_code(&self) -> u16 { $status }
447            fn self_handled(&self) -> bool { false }
448            fn translation_id(&self) -> Option<&'static str> { None }
449        }
450    };
451
452    // ── No self_handled (default = true, no translation) ────────────────────
453    (
454        $(#[$meta:meta])*
455        $vis:vis struct $name:ident {
456            status = $status:expr,
457            fields: { $(pub $field:ident: $ty:ty),* $(,)? }
458        }
459    ) => {
460        $(#[$meta])*
461        #[allow(non_camel_case_types)]
462        #[derive(Debug)]
463        $vis struct $name {
464            $(pub $field: $ty,)*
465        }
466        impl $name { $vis fn new($($field: $ty),*) -> Self { Self { $($field),* } } }
467        impl std::fmt::Display for $name {
468            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
469                use $crate::RokException;
470                write!(f, concat!("{} (", stringify!($name), ")"), self.status_code())
471            }
472        }
473        impl std::error::Error for $name {}
474        impl $crate::error::RokException for $name {
475            fn name(&self) -> &'static str { stringify!($name) }
476            fn status_code(&self) -> u16 { $status }
477            fn self_handled(&self) -> bool { true }
478            fn translation_id(&self) -> Option<&'static str> { None }
479        }
480        #[cfg(feature = "axum")]
481        impl axum::response::IntoResponse for $name {
482            fn into_response(self) -> axum::response::Response {
483                let status = axum::http::StatusCode::from_u16($status)
484                    .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
485                let body = serde_json::json!({
486                    "error": stringify!($name),
487                    "message": self.to_string(),
488                    "statusCode": $status,
489                });
490                (status, axum::Json(body)).into_response()
491            }
492        }
493    };
494}