kegani 0.1.0

A developer-friendly, ergonomic, production-ready Rust web framework
Documentation
use thiserror::Error;
use actix_web::HttpResponse;
use serde::Serialize;
use std::borrow::Cow;

/// Application result type alias
pub type Result<T> = std::result::Result<T, AppError>;

/// Error response body structure
#[derive(Debug, Serialize)]
struct ErrorBody {
    error: ErrorDetail,
}

#[derive(Debug, Serialize)]
struct ErrorDetail {
    code: String,
    message: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    request_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    details: Option<Vec<FieldError>>,
}

#[derive(Debug, Serialize)]
struct FieldError {
    field: String,
    message: String,
}

/// Unified error type for Kegani applications
#[derive(Debug, Error, serde::Serialize, serde::Deserialize)]
pub enum AppError {
    // 4xx Client Errors
    #[error("Not found: {0}")]
    NotFound(Cow<'static, str>),

    #[error("Validation failed: {0}")]
    Validation(Cow<'static, str>),

    #[error("Unauthorized: {0}")]
    Unauthorized(Cow<'static, str>),

    #[error("Forbidden")]
    Forbidden,

    #[error("Conflict: {0}")]
    Conflict(Cow<'static, str>),

    #[error("Method not allowed: {0}")]
    MethodNotAllowed(Cow<'static, str>),

    #[error("Request timeout")]
    Timeout,

    #[error("Payload too large: {0}")]
    PayloadTooLarge(Cow<'static, str>),

    #[error("Too many requests: {0}")]
    TooManyRequests(Cow<'static, str>),

    // 5xx Server Errors
    #[error("Internal server error: {0}")]
    Internal(Cow<'static, str>),

    #[error("Service unavailable: {0}")]
    Unavailable(Cow<'static, str>),

    #[error("Gateway timeout: {0}")]
    GatewayTimeout(Cow<'static, str>),

    // Source errors
    #[error(transparent)]
    #[serde(skip)]
    Database(#[from] sqlx::Error),

    #[error(transparent)]
    #[serde(skip)]
    Io(#[from] std::io::Error),

    #[error(transparent)]
    #[serde(skip)]
    Serde(#[from] serde_json::Error),
}

impl AppError {
    /// Create a not found error
    pub fn not_found(msg: impl Into<Cow<'static, str>>) -> Self {
        AppError::NotFound(msg.into())
    }

    /// Create a validation error
    pub fn validation(msg: impl Into<Cow<'static, str>>) -> Self {
        AppError::Validation(msg.into())
    }

    /// Create an unauthorized error
    pub fn unauthorized(msg: impl Into<Cow<'static, str>>) -> Self {
        AppError::Unauthorized(msg.into())
    }

    /// Create an internal error
    pub fn internal(msg: impl Into<Cow<'static, str>>) -> Self {
        AppError::Internal(msg.into())
    }

    /// Get the HTTP status code
    pub fn status_code(&self) -> u16 {
        match self {
            AppError::NotFound(_) => 404,
            AppError::Validation(_) => 400,
            AppError::Unauthorized(_) => 401,
            AppError::Forbidden => 403,
            AppError::Conflict(_) => 409,
            AppError::MethodNotAllowed(_) => 405,
            AppError::Timeout => 408,
            AppError::PayloadTooLarge(_) => 413,
            AppError::TooManyRequests(_) => 429,
            AppError::Internal(_) => 500,
            AppError::Unavailable(_) => 503,
            AppError::GatewayTimeout(_) => 504,
            AppError::Database(_) | AppError::Io(_) | AppError::Serde(_) => 500,
        }
    }

    /// Get the error code string
    pub fn code(&self) -> &'static str {
        match self {
            AppError::NotFound(_) => "NOT_FOUND",
            AppError::Validation(_) => "VALIDATION_ERROR",
            AppError::Unauthorized(_) => "UNAUTHORIZED",
            AppError::Forbidden => "FORBIDDEN",
            AppError::Conflict(_) => "CONFLICT",
            AppError::MethodNotAllowed(_) => "METHOD_NOT_ALLOWED",
            AppError::Timeout => "REQUEST_TIMEOUT",
            AppError::PayloadTooLarge(_) => "PAYLOAD_TOO_LARGE",
            AppError::TooManyRequests(_) => "TOO_MANY_REQUESTS",
            AppError::Internal(_) => "INTERNAL_ERROR",
            AppError::Unavailable(_) => "SERVICE_UNAVAILABLE",
            AppError::GatewayTimeout(_) => "GATEWAY_TIMEOUT",
            AppError::Database(_) => "DATABASE_ERROR",
            AppError::Io(_) => "IO_ERROR",
            AppError::Serde(_) => "SERIALIZATION_ERROR",
        }
    }

    /// Check if this is a client error (4xx)
    pub fn is_client_error(&self) -> bool {
        matches!(self,
            AppError::NotFound(_)
            | AppError::Validation(_)
            | AppError::Unauthorized(_)
            | AppError::Forbidden
            | AppError::Conflict(_)
            | AppError::MethodNotAllowed(_)
            | AppError::Timeout
            | AppError::PayloadTooLarge(_)
            | AppError::TooManyRequests(_)
        )
    }

    /// Check if this is a server error (5xx)
    pub fn is_server_error(&self) -> bool {
        matches!(self,
            AppError::Internal(_)
            | AppError::Unavailable(_)
            | AppError::GatewayTimeout(_)
            | AppError::Database(_)
            | AppError::Io(_)
            | AppError::Serde(_)
        )
    }

    /// Map internal errors to generic messages in production
    pub fn to_response(&self, request_id: Option<&str>) -> HttpResponse {
        let body = ErrorBody {
            error: ErrorDetail {
                code: self.code().to_string(),
                message: self.to_string(),
                request_id: request_id.map(String::from),
                details: None,
            },
        };

        let status = actix_web::http::StatusCode::from_u16(self.status_code())
            .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR);

        HttpResponse::build(status).json(&body)
    }
}

impl actix_web::ResponseError for AppError {
    fn error_response(&self) -> HttpResponse {
        self.to_response(None)
    }

    fn status_code(&self) -> actix_web::http::StatusCode {
        actix_web::http::StatusCode::from_u16(self.status_code())
            .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR)
    }
}