Skip to main content

ferro_rs/
error.rs

1//! Framework-wide error types
2//!
3//! Provides a unified error type that can be used throughout the framework
4//! and automatically converts to appropriate HTTP responses.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use thiserror::Error;
9
10/// Trait for errors that can be converted to HTTP responses
11///
12/// Implement this trait on your domain errors to customize the HTTP status code
13/// and message that will be returned when the error is converted to a response.
14///
15/// # Example
16///
17/// ```rust,ignore
18/// use ferro_rs::HttpError;
19///
20/// #[derive(Debug)]
21/// struct UserNotFoundError { user_id: i32 }
22///
23/// impl std::fmt::Display for UserNotFoundError {
24///     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25///         write!(f, "User {} not found", self.user_id)
26///     }
27/// }
28///
29/// impl std::error::Error for UserNotFoundError {}
30///
31/// impl HttpError for UserNotFoundError {
32///     fn status_code(&self) -> u16 { 404 }
33/// }
34/// ```
35pub trait HttpError: std::error::Error + Send + Sync + 'static {
36    /// HTTP status code (default: 500)
37    fn status_code(&self) -> u16 {
38        500
39    }
40
41    /// Error message for HTTP response (default: error's Display)
42    fn error_message(&self) -> String {
43        self.to_string()
44    }
45}
46
47/// Simple wrapper for creating one-off domain errors
48///
49/// Use this for inline/ad-hoc errors when you don't want to create
50/// a dedicated error type.
51///
52/// # Example
53///
54/// ```rust,ignore
55/// use ferro_rs::{AppError, FrameworkError};
56///
57/// pub async fn process() -> Result<(), FrameworkError> {
58///     if invalid {
59///         return Err(AppError::bad_request("Invalid input").into());
60///     }
61///     Ok(())
62/// }
63/// ```
64#[derive(Debug, Clone)]
65pub struct AppError {
66    message: String,
67    status_code: u16,
68}
69
70impl AppError {
71    /// Create a new AppError with status 500 (Internal Server Error)
72    pub fn new(message: impl Into<String>) -> Self {
73        Self {
74            message: message.into(),
75            status_code: 500,
76        }
77    }
78
79    /// Set the HTTP status code
80    pub fn status(mut self, code: u16) -> Self {
81        self.status_code = code;
82        self
83    }
84
85    /// Create a 404 Not Found error
86    pub fn not_found(message: impl Into<String>) -> Self {
87        Self::new(message).status(404)
88    }
89
90    /// Create a 400 Bad Request error
91    pub fn bad_request(message: impl Into<String>) -> Self {
92        Self::new(message).status(400)
93    }
94
95    /// Create a 401 Unauthorized error
96    pub fn unauthorized(message: impl Into<String>) -> Self {
97        Self::new(message).status(401)
98    }
99
100    /// Create a 403 Forbidden error
101    pub fn forbidden(message: impl Into<String>) -> Self {
102        Self::new(message).status(403)
103    }
104
105    /// Create a 422 Unprocessable Entity error
106    pub fn unprocessable(message: impl Into<String>) -> Self {
107        Self::new(message).status(422)
108    }
109
110    /// Create a 409 Conflict error
111    pub fn conflict(message: impl Into<String>) -> Self {
112        Self::new(message).status(409)
113    }
114}
115
116impl std::fmt::Display for AppError {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        write!(f, "{}", self.message)
119    }
120}
121
122impl std::error::Error for AppError {}
123
124impl HttpError for AppError {
125    fn status_code(&self) -> u16 {
126        self.status_code
127    }
128
129    fn error_message(&self) -> String {
130        self.message.clone()
131    }
132}
133
134impl From<AppError> for FrameworkError {
135    fn from(e: AppError) -> Self {
136        FrameworkError::Domain {
137            message: e.message,
138            status_code: e.status_code,
139        }
140    }
141}
142
143/// Validation errors with Laravel/Inertia-compatible format
144///
145/// Contains a map of field names to error messages, supporting multiple
146/// errors per field.
147///
148/// # Response Format
149///
150/// When converted to an HTTP response, produces Laravel-compatible JSON:
151///
152/// ```json
153/// {
154///     "message": "The given data was invalid.",
155///     "errors": {
156///         "email": ["The email field must be a valid email address."],
157///         "password": ["The password field must be at least 8 characters."]
158///     }
159/// }
160/// ```
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct ValidationErrors {
163    /// Map of field names to their validation error messages
164    #[serde(flatten)]
165    pub errors: HashMap<String, Vec<String>>,
166}
167
168impl ValidationErrors {
169    /// Create a new empty ValidationErrors
170    pub fn new() -> Self {
171        Self {
172            errors: HashMap::new(),
173        }
174    }
175
176    /// Add an error for a specific field
177    pub fn add(&mut self, field: impl Into<String>, message: impl Into<String>) {
178        self.errors
179            .entry(field.into())
180            .or_default()
181            .push(message.into());
182    }
183
184    /// Check if there are any errors
185    pub fn is_empty(&self) -> bool {
186        self.errors.is_empty()
187    }
188
189    /// Convert from validator crate's ValidationErrors
190    pub fn from_validator(errors: validator::ValidationErrors) -> Self {
191        let mut result = Self::new();
192        for (field, field_errors) in errors.field_errors() {
193            for error in field_errors {
194                let message = error
195                    .message
196                    .as_ref()
197                    .map(|m| m.to_string())
198                    .unwrap_or_else(|| format!("Validation failed for field '{field}'"));
199                result.add(field.to_string(), message);
200            }
201        }
202        result
203    }
204
205    /// Convert to JSON Value for response
206    pub fn to_json(&self) -> serde_json::Value {
207        serde_json::json!({
208            "message": "The given data was invalid.",
209            "errors": self.errors
210        })
211    }
212}
213
214impl Default for ValidationErrors {
215    fn default() -> Self {
216        Self::new()
217    }
218}
219
220impl std::fmt::Display for ValidationErrors {
221    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
222        write!(f, "Validation failed: {:?}", self.errors)
223    }
224}
225
226impl std::error::Error for ValidationErrors {}
227
228/// Framework-wide error type
229///
230/// This enum represents all possible errors that can occur in the framework.
231/// It implements `From<FrameworkError> for Response` so errors can be propagated
232/// using the `?` operator in controller handlers.
233///
234/// # Example
235///
236/// ```rust,ignore
237/// use ferro_rs::{App, FrameworkError, Response};
238///
239/// pub async fn index(_req: Request) -> Response {
240///     let service = App::resolve::<MyService>()?;  // Returns FrameworkError on failure
241///     // ...
242/// }
243/// ```
244///
245/// # Automatic Error Conversion
246///
247/// `FrameworkError` implements `From` for common error types, allowing seamless
248/// use of the `?` operator:
249///
250/// ```rust,ignore
251/// use ferro_rs::{DB, FrameworkError};
252/// use sea_orm::ActiveModelTrait;
253///
254/// pub async fn create_todo() -> Result<Todo, FrameworkError> {
255///     let todo = new_todo.insert(&*DB::get()?).await?;  // DbErr converts automatically!
256///     Ok(todo)
257/// }
258/// ```
259#[derive(Debug, Clone, Error)]
260pub enum FrameworkError {
261    /// Service not found in the dependency injection container
262    #[error("Service '{type_name}' not registered in container")]
263    ServiceNotFound {
264        /// The type name of the service that was not found
265        type_name: &'static str,
266    },
267
268    /// Parameter extraction failed (missing or invalid parameter)
269    #[error("Missing required parameter: {param_name}")]
270    ParamError {
271        /// The name of the parameter that failed extraction
272        param_name: String,
273    },
274
275    /// Validation error
276    #[error("Validation error for '{field}': {message}")]
277    ValidationError {
278        /// The field that failed validation
279        field: String,
280        /// The validation error message
281        message: String,
282    },
283
284    /// Database error
285    #[error("Database error: {0}")]
286    Database(String),
287
288    /// Generic internal server error
289    #[error("Internal server error: {message}")]
290    Internal {
291        /// The error message
292        message: String,
293    },
294
295    /// Domain/application error with custom status code
296    ///
297    /// Used for user-defined domain errors that need custom HTTP status codes.
298    #[error("{message}")]
299    Domain {
300        /// The error message
301        message: String,
302        /// HTTP status code
303        status_code: u16,
304    },
305
306    /// Form validation errors (422 Unprocessable Entity)
307    ///
308    /// Contains multiple field validation errors in Laravel/Inertia format.
309    #[error("Validation failed")]
310    Validation(ValidationErrors),
311
312    /// Authorization failed (403 Forbidden)
313    ///
314    /// Used when FormRequest::authorize() returns false.
315    #[error("This action is unauthorized.")]
316    Unauthorized,
317
318    /// Model not found (404 Not Found)
319    ///
320    /// Used when route model binding fails to find the requested resource.
321    #[error("{model_name} not found")]
322    ModelNotFound {
323        /// The name of the model that was not found
324        model_name: String,
325    },
326
327    /// Parameter parse error (400 Bad Request)
328    ///
329    /// Used when a path parameter cannot be parsed to the expected type.
330    #[error("Invalid parameter '{param}': expected {expected_type}")]
331    ParamParse {
332        /// The parameter value that failed to parse
333        param: String,
334        /// The expected type (e.g., "i32", "uuid")
335        expected_type: &'static str,
336    },
337}
338
339impl FrameworkError {
340    /// Create a ServiceNotFound error for a given type
341    pub fn service_not_found<T: ?Sized>() -> Self {
342        Self::ServiceNotFound {
343            type_name: std::any::type_name::<T>(),
344        }
345    }
346
347    /// Create a ParamError for a missing parameter
348    pub fn param(name: impl Into<String>) -> Self {
349        Self::ParamError {
350            param_name: name.into(),
351        }
352    }
353
354    /// Create a ValidationError
355    pub fn validation(field: impl Into<String>, message: impl Into<String>) -> Self {
356        Self::ValidationError {
357            field: field.into(),
358            message: message.into(),
359        }
360    }
361
362    /// Create a DatabaseError
363    pub fn database(message: impl Into<String>) -> Self {
364        Self::Database(message.into())
365    }
366
367    /// Create an Internal error
368    pub fn internal(message: impl Into<String>) -> Self {
369        Self::Internal {
370            message: message.into(),
371        }
372    }
373
374    /// Create a Domain error with custom status code
375    pub fn domain(message: impl Into<String>, status_code: u16) -> Self {
376        Self::Domain {
377            message: message.into(),
378            status_code,
379        }
380    }
381
382    /// Get the HTTP status code for this error
383    pub fn status_code(&self) -> u16 {
384        match self {
385            Self::ServiceNotFound { .. } => 500,
386            Self::ParamError { .. } => 400,
387            Self::ValidationError { .. } => 422,
388            Self::Database(_) => 500,
389            Self::Internal { .. } => 500,
390            Self::Domain { status_code, .. } => *status_code,
391            Self::Validation(_) => 422,
392            Self::Unauthorized => 403,
393            Self::ModelNotFound { .. } => 404,
394            Self::ParamParse { .. } => 400,
395        }
396    }
397
398    /// Create a Validation error from ValidationErrors struct
399    pub fn validation_errors(errors: ValidationErrors) -> Self {
400        Self::Validation(errors)
401    }
402
403    /// Create a ModelNotFound error (404)
404    pub fn model_not_found(name: impl Into<String>) -> Self {
405        Self::ModelNotFound {
406            model_name: name.into(),
407        }
408    }
409
410    /// Create a ParamParse error (400)
411    pub fn param_parse(param: impl Into<String>, expected_type: &'static str) -> Self {
412        Self::ParamParse {
413            param: param.into(),
414            expected_type,
415        }
416    }
417
418    /// Returns an actionable hint guiding the developer toward a fix.
419    ///
420    /// Hints are included in JSON error responses during development to help
421    /// developers quickly resolve common issues. Variants with user-provided
422    /// messages (Internal, Domain) or self-describing content (Validation,
423    /// ValidationError) return `None`.
424    pub fn hint(&self) -> Option<String> {
425        match self {
426            Self::ServiceNotFound { type_name } => Some(format!(
427                "Register with App::bind::<{type_name}>() in your bootstrap.rs or a service provider"
428            )),
429            Self::ParamError { param_name } => Some(format!(
430                "Check your route definition includes :{param_name} or verify the request body contains this field"
431            )),
432            Self::ModelNotFound { model_name } => Some(format!(
433                "Verify the {model_name} exists in the database, or check that the route parameter matches a valid ID"
434            )),
435            Self::ParamParse {
436                param,
437                expected_type,
438            } => Some(format!(
439                "Route received '{param}' but expected a valid {expected_type}. Check the URL parameter format."
440            )),
441            Self::Database(_) => Some(
442                "Check DATABASE_URL in .env and verify the database is running".to_string(),
443            ),
444            Self::Unauthorized => Some(
445                "Check that the handler's form request authorize() returns true, or verify the user has the required permissions".to_string(),
446            ),
447            // No hints for user-provided or self-describing errors
448            Self::Internal { .. } | Self::Domain { .. } | Self::ValidationError { .. } | Self::Validation(_) => None,
449        }
450    }
451}
452
453// Implement From<DbErr> for automatic error conversion with ?
454impl From<sea_orm::DbErr> for FrameworkError {
455    fn from(e: sea_orm::DbErr) -> Self {
456        Self::Database(e.to_string())
457    }
458}
459
460// Implement From<ferro_projections::Error> for automatic error conversion with ?
461#[cfg(feature = "projections")]
462impl From<ferro_projections::Error> for FrameworkError {
463    fn from(e: ferro_projections::Error) -> Self {
464        Self::Internal {
465            message: e.to_string(),
466        }
467    }
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473    use crate::http::HttpResponse;
474
475    /// Helper: convert a FrameworkError to HttpResponse and parse its JSON body.
476    fn error_to_json(err: FrameworkError) -> serde_json::Value {
477        let resp: HttpResponse = err.into();
478        serde_json::from_str(resp.body()).expect("response body should be valid JSON")
479    }
480
481    #[test]
482    fn service_not_found_includes_hint() {
483        let err = FrameworkError::service_not_found::<String>();
484        let json = error_to_json(err);
485
486        assert!(json.get("message").is_some(), "should have 'message' key");
487        let hint = json
488            .get("hint")
489            .and_then(|v| v.as_str())
490            .expect("should have 'hint' key");
491        assert!(
492            hint.contains("App::bind"),
493            "hint should mention App::bind, got: {hint}"
494        );
495    }
496
497    #[test]
498    fn param_error_includes_hint() {
499        let err = FrameworkError::param("user_id");
500        let json = error_to_json(err);
501
502        assert!(json.get("message").is_some());
503        let hint = json
504            .get("hint")
505            .and_then(|v| v.as_str())
506            .expect("should have hint");
507        assert!(
508            hint.contains(":user_id"),
509            "hint should reference param name, got: {hint}"
510        );
511    }
512
513    #[test]
514    fn model_not_found_includes_hint() {
515        let err = FrameworkError::model_not_found("User");
516        let json = error_to_json(err);
517
518        assert_eq!(json["message"], "User not found");
519        let hint = json
520            .get("hint")
521            .and_then(|v| v.as_str())
522            .expect("should have hint");
523        assert!(hint.contains("User"), "hint should reference model name");
524    }
525
526    #[test]
527    fn param_parse_includes_hint() {
528        let err = FrameworkError::param_parse("abc", "i32");
529        let json = error_to_json(err);
530
531        let hint = json
532            .get("hint")
533            .and_then(|v| v.as_str())
534            .expect("should have hint");
535        assert!(hint.contains("abc"), "hint should include received value");
536        assert!(hint.contains("i32"), "hint should include expected type");
537    }
538
539    #[test]
540    fn database_error_includes_hint() {
541        let err = FrameworkError::database("connection refused");
542        let json = error_to_json(err);
543
544        let hint = json
545            .get("hint")
546            .and_then(|v| v.as_str())
547            .expect("should have hint");
548        assert!(
549            hint.contains("DATABASE_URL"),
550            "hint should mention DATABASE_URL"
551        );
552    }
553
554    #[test]
555    fn unauthorized_includes_hint() {
556        let err = FrameworkError::Unauthorized;
557        let json = error_to_json(err);
558
559        assert_eq!(json["message"], "This action is unauthorized.");
560        let hint = json
561            .get("hint")
562            .and_then(|v| v.as_str())
563            .expect("should have hint");
564        assert!(
565            hint.contains("authorize()"),
566            "hint should mention authorize()"
567        );
568    }
569
570    #[test]
571    fn internal_error_has_no_hint() {
572        let err = FrameworkError::internal("something broke");
573        let json = error_to_json(err);
574
575        assert!(json.get("message").is_some());
576        assert!(
577            json.get("hint").is_none(),
578            "Internal errors should not have hints"
579        );
580    }
581
582    #[test]
583    fn domain_error_has_no_hint() {
584        let err = FrameworkError::domain("custom message", 409);
585        let json = error_to_json(err);
586
587        assert_eq!(json["message"], "custom message");
588        assert!(
589            json.get("hint").is_none(),
590            "Domain errors should not have hints"
591        );
592    }
593
594    #[test]
595    fn validation_errors_have_no_hint() {
596        let mut errors = ValidationErrors::new();
597        errors.add("email", "Email is required");
598        let err = FrameworkError::validation_errors(errors);
599        let json = error_to_json(err);
600
601        assert!(
602            json.get("hint").is_none(),
603            "Validation errors should not have hints"
604        );
605        assert!(json.get("errors").is_some(), "should have errors field");
606    }
607
608    #[cfg(feature = "projections")]
609    #[test]
610    fn projection_error_converts_to_500() {
611        let err = FrameworkError::from(ferro_projections::Error::Definition(
612            "missing field".to_string(),
613        ));
614        assert_eq!(err.status_code(), 500);
615
616        let json = error_to_json(err);
617        let msg = json["message"].as_str().unwrap();
618        assert!(
619            msg.contains("missing field"),
620            "error message should contain original text, got: {msg}"
621        );
622    }
623
624    #[test]
625    fn status_codes_are_correct() {
626        assert_eq!(
627            FrameworkError::service_not_found::<String>().status_code(),
628            500
629        );
630        assert_eq!(FrameworkError::param("x").status_code(), 400);
631        assert_eq!(FrameworkError::model_not_found("X").status_code(), 404);
632        assert_eq!(FrameworkError::param_parse("x", "i32").status_code(), 400);
633        assert_eq!(FrameworkError::database("err").status_code(), 500);
634        assert_eq!(FrameworkError::internal("err").status_code(), 500);
635        assert_eq!(FrameworkError::domain("err", 409).status_code(), 409);
636        assert_eq!(FrameworkError::Unauthorized.status_code(), 403);
637    }
638}