use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use thiserror::Error;
pub trait HttpError: std::error::Error + Send + Sync + 'static {
fn status_code(&self) -> u16 {
500
}
fn error_message(&self) -> String {
self.to_string()
}
}
#[derive(Debug, Clone)]
pub struct AppError {
message: String,
status_code: u16,
}
impl AppError {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
status_code: 500,
}
}
pub fn status(mut self, code: u16) -> Self {
self.status_code = code;
self
}
pub fn not_found(message: impl Into<String>) -> Self {
Self::new(message).status(404)
}
pub fn bad_request(message: impl Into<String>) -> Self {
Self::new(message).status(400)
}
pub fn unauthorized(message: impl Into<String>) -> Self {
Self::new(message).status(401)
}
pub fn forbidden(message: impl Into<String>) -> Self {
Self::new(message).status(403)
}
pub fn unprocessable(message: impl Into<String>) -> Self {
Self::new(message).status(422)
}
pub fn conflict(message: impl Into<String>) -> Self {
Self::new(message).status(409)
}
}
impl std::fmt::Display for AppError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for AppError {}
impl HttpError for AppError {
fn status_code(&self) -> u16 {
self.status_code
}
fn error_message(&self) -> String {
self.message.clone()
}
}
impl From<AppError> for FrameworkError {
fn from(e: AppError) -> Self {
FrameworkError::Domain {
message: e.message,
status_code: e.status_code,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationErrors {
#[serde(flatten)]
pub errors: HashMap<String, Vec<String>>,
}
impl ValidationErrors {
pub fn new() -> Self {
Self {
errors: HashMap::new(),
}
}
pub fn add(&mut self, field: impl Into<String>, message: impl Into<String>) {
self.errors
.entry(field.into())
.or_default()
.push(message.into());
}
pub fn is_empty(&self) -> bool {
self.errors.is_empty()
}
pub fn from_validator(errors: validator::ValidationErrors) -> Self {
let mut result = Self::new();
for (field, field_errors) in errors.field_errors() {
for error in field_errors {
let message = error
.message
.as_ref()
.map(|m| m.to_string())
.unwrap_or_else(|| format!("Validation failed for field '{field}'"));
result.add(field.to_string(), message);
}
}
result
}
pub fn to_json(&self) -> serde_json::Value {
serde_json::json!({
"message": "The given data was invalid.",
"errors": self.errors
})
}
}
impl Default for ValidationErrors {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Display for ValidationErrors {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Validation failed: {:?}", self.errors)
}
}
impl std::error::Error for ValidationErrors {}
#[derive(Debug, Clone, Error)]
pub enum FrameworkError {
#[error("Service '{type_name}' not registered in container")]
ServiceNotFound {
type_name: &'static str,
},
#[error("Missing required parameter: {param_name}")]
ParamError {
param_name: String,
},
#[error("Validation error for '{field}': {message}")]
ValidationError {
field: String,
message: String,
},
#[error("Database error: {0}")]
Database(String),
#[error("Internal server error: {message}")]
Internal {
message: String,
},
#[error("{message}")]
Domain {
message: String,
status_code: u16,
},
#[error("Validation failed")]
Validation(ValidationErrors),
#[error("This action is unauthorized.")]
Unauthorized,
#[error("{model_name} not found")]
ModelNotFound {
model_name: String,
},
#[error("Invalid parameter '{param}': expected {expected_type}")]
ParamParse {
param: String,
expected_type: &'static str,
},
}
impl FrameworkError {
pub fn service_not_found<T: ?Sized>() -> Self {
Self::ServiceNotFound {
type_name: std::any::type_name::<T>(),
}
}
pub fn param(name: impl Into<String>) -> Self {
Self::ParamError {
param_name: name.into(),
}
}
pub fn validation(field: impl Into<String>, message: impl Into<String>) -> Self {
Self::ValidationError {
field: field.into(),
message: message.into(),
}
}
pub fn database(message: impl Into<String>) -> Self {
Self::Database(message.into())
}
pub fn internal(message: impl Into<String>) -> Self {
Self::Internal {
message: message.into(),
}
}
pub fn domain(message: impl Into<String>, status_code: u16) -> Self {
Self::Domain {
message: message.into(),
status_code,
}
}
pub fn status_code(&self) -> u16 {
match self {
Self::ServiceNotFound { .. } => 500,
Self::ParamError { .. } => 400,
Self::ValidationError { .. } => 422,
Self::Database(_) => 500,
Self::Internal { .. } => 500,
Self::Domain { status_code, .. } => *status_code,
Self::Validation(_) => 422,
Self::Unauthorized => 403,
Self::ModelNotFound { .. } => 404,
Self::ParamParse { .. } => 400,
}
}
pub fn validation_errors(errors: ValidationErrors) -> Self {
Self::Validation(errors)
}
pub fn model_not_found(name: impl Into<String>) -> Self {
Self::ModelNotFound {
model_name: name.into(),
}
}
pub fn param_parse(param: impl Into<String>, expected_type: &'static str) -> Self {
Self::ParamParse {
param: param.into(),
expected_type,
}
}
pub fn hint(&self) -> Option<String> {
match self {
Self::ServiceNotFound { type_name } => Some(format!(
"Register with App::bind::<{type_name}>() in your bootstrap.rs or a service provider"
)),
Self::ParamError { param_name } => Some(format!(
"Check your route definition includes :{param_name} or verify the request body contains this field"
)),
Self::ModelNotFound { model_name } => Some(format!(
"Verify the {model_name} exists in the database, or check that the route parameter matches a valid ID"
)),
Self::ParamParse {
param,
expected_type,
} => Some(format!(
"Route received '{param}' but expected a valid {expected_type}. Check the URL parameter format."
)),
Self::Database(_) => Some(
"Check DATABASE_URL in .env and verify the database is running".to_string(),
),
Self::Unauthorized => Some(
"Check that the handler's form request authorize() returns true, or verify the user has the required permissions".to_string(),
),
Self::Internal { .. } | Self::Domain { .. } | Self::ValidationError { .. } | Self::Validation(_) => None,
}
}
}
impl From<sea_orm::DbErr> for FrameworkError {
fn from(e: sea_orm::DbErr) -> Self {
Self::Database(e.to_string())
}
}
#[cfg(feature = "projections")]
impl From<ferro_projections::Error> for FrameworkError {
fn from(e: ferro_projections::Error) -> Self {
Self::Internal {
message: e.to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::http::HttpResponse;
fn error_to_json(err: FrameworkError) -> serde_json::Value {
let resp: HttpResponse = err.into();
serde_json::from_str(resp.body()).expect("response body should be valid JSON")
}
#[test]
fn service_not_found_includes_hint() {
let err = FrameworkError::service_not_found::<String>();
let json = error_to_json(err);
assert!(json.get("message").is_some(), "should have 'message' key");
let hint = json
.get("hint")
.and_then(|v| v.as_str())
.expect("should have 'hint' key");
assert!(
hint.contains("App::bind"),
"hint should mention App::bind, got: {hint}"
);
}
#[test]
fn param_error_includes_hint() {
let err = FrameworkError::param("user_id");
let json = error_to_json(err);
assert!(json.get("message").is_some());
let hint = json
.get("hint")
.and_then(|v| v.as_str())
.expect("should have hint");
assert!(
hint.contains(":user_id"),
"hint should reference param name, got: {hint}"
);
}
#[test]
fn model_not_found_includes_hint() {
let err = FrameworkError::model_not_found("User");
let json = error_to_json(err);
assert_eq!(json["message"], "User not found");
let hint = json
.get("hint")
.and_then(|v| v.as_str())
.expect("should have hint");
assert!(hint.contains("User"), "hint should reference model name");
}
#[test]
fn param_parse_includes_hint() {
let err = FrameworkError::param_parse("abc", "i32");
let json = error_to_json(err);
let hint = json
.get("hint")
.and_then(|v| v.as_str())
.expect("should have hint");
assert!(hint.contains("abc"), "hint should include received value");
assert!(hint.contains("i32"), "hint should include expected type");
}
#[test]
fn database_error_includes_hint() {
let err = FrameworkError::database("connection refused");
let json = error_to_json(err);
let hint = json
.get("hint")
.and_then(|v| v.as_str())
.expect("should have hint");
assert!(
hint.contains("DATABASE_URL"),
"hint should mention DATABASE_URL"
);
}
#[test]
fn unauthorized_includes_hint() {
let err = FrameworkError::Unauthorized;
let json = error_to_json(err);
assert_eq!(json["message"], "This action is unauthorized.");
let hint = json
.get("hint")
.and_then(|v| v.as_str())
.expect("should have hint");
assert!(
hint.contains("authorize()"),
"hint should mention authorize()"
);
}
#[test]
fn internal_error_has_no_hint() {
let err = FrameworkError::internal("something broke");
let json = error_to_json(err);
assert!(json.get("message").is_some());
assert!(
json.get("hint").is_none(),
"Internal errors should not have hints"
);
}
#[test]
fn domain_error_has_no_hint() {
let err = FrameworkError::domain("custom message", 409);
let json = error_to_json(err);
assert_eq!(json["message"], "custom message");
assert!(
json.get("hint").is_none(),
"Domain errors should not have hints"
);
}
#[test]
fn validation_errors_have_no_hint() {
let mut errors = ValidationErrors::new();
errors.add("email", "Email is required");
let err = FrameworkError::validation_errors(errors);
let json = error_to_json(err);
assert!(
json.get("hint").is_none(),
"Validation errors should not have hints"
);
assert!(json.get("errors").is_some(), "should have errors field");
}
#[cfg(feature = "projections")]
#[test]
fn projection_error_converts_to_500() {
let err = FrameworkError::from(ferro_projections::Error::Definition(
"missing field".to_string(),
));
assert_eq!(err.status_code(), 500);
let json = error_to_json(err);
let msg = json["message"].as_str().unwrap();
assert!(
msg.contains("missing field"),
"error message should contain original text, got: {msg}"
);
}
#[test]
fn status_codes_are_correct() {
assert_eq!(
FrameworkError::service_not_found::<String>().status_code(),
500
);
assert_eq!(FrameworkError::param("x").status_code(), 400);
assert_eq!(FrameworkError::model_not_found("X").status_code(), 404);
assert_eq!(FrameworkError::param_parse("x", "i32").status_code(), 400);
assert_eq!(FrameworkError::database("err").status_code(), 500);
assert_eq!(FrameworkError::internal("err").status_code(), 500);
assert_eq!(FrameworkError::domain("err", 409).status_code(), 409);
assert_eq!(FrameworkError::Unauthorized.status_code(), 403);
}
}