//! API error handling //! //! Maps domain errors to HTTP responses use axum::{ Json, http::StatusCode, response::{IntoResponse, Response}, }; use serde::Serialize; use thiserror::Error; use domain::DomainError; /// API-level errors #[derive(Debug, Error)] pub enum ApiError { #[error("{0}")] Domain(#[from] DomainError), #[error("Validation error: {0}")] Validation(String), #[error("Internal server error")] Internal(String), #[error("Forbidden: {0}")] Forbidden(String), #[error("Unauthorized: {0}")] Unauthorized(String), #[error("password_required")] PasswordRequired, #[error("auth_required")] AuthRequired, #[allow(dead_code)] #[error("Not found: {0}")] NotFound(String), #[error("Not implemented: {0}")] NotImplemented(String), #[error("Conflict: {0}")] Conflict(String), } /// Error response body #[derive(Debug, Serialize)] pub struct ErrorResponse { pub error: String, #[serde(skip_serializing_if = "Option::is_none")] pub details: Option, } impl IntoResponse for ApiError { fn into_response(self) -> Response { let (status, error_response) = match &self { ApiError::Domain(domain_error) => { let status = match domain_error { DomainError::UserNotFound(_) | DomainError::ChannelNotFound(_) | DomainError::NoActiveSchedule(_) => { StatusCode::NOT_FOUND } DomainError::UserAlreadyExists(_) => StatusCode::CONFLICT, DomainError::ValidationError(_) | DomainError::TimezoneError(_) => { StatusCode::BAD_REQUEST } // Unauthenticated = not logged in → 401 DomainError::Unauthenticated(_) => StatusCode::UNAUTHORIZED, // Forbidden = not allowed to perform action → 403 DomainError::Forbidden(_) => StatusCode::FORBIDDEN, DomainError::RepositoryError(_) | DomainError::InfrastructureError(_) => { StatusCode::INTERNAL_SERVER_ERROR } _ => StatusCode::INTERNAL_SERVER_ERROR, }; ( status, ErrorResponse { error: domain_error.to_string(), details: None, }, ) } ApiError::Validation(msg) => ( StatusCode::BAD_REQUEST, ErrorResponse { error: "Validation error".to_string(), details: Some(msg.clone()), }, ), ApiError::Internal(msg) => { tracing::error!("Internal error: {}", msg); ( StatusCode::INTERNAL_SERVER_ERROR, ErrorResponse { error: "Internal server error".to_string(), details: None, }, ) } ApiError::Forbidden(msg) => ( StatusCode::FORBIDDEN, ErrorResponse { error: "Forbidden".to_string(), details: Some(msg.clone()), }, ), ApiError::Unauthorized(msg) => ( StatusCode::UNAUTHORIZED, ErrorResponse { error: "Unauthorized".to_string(), details: Some(msg.clone()), }, ), ApiError::PasswordRequired => ( StatusCode::UNAUTHORIZED, ErrorResponse { error: "password_required".to_string(), details: None, }, ), ApiError::AuthRequired => ( StatusCode::UNAUTHORIZED, ErrorResponse { error: "auth_required".to_string(), details: None, }, ), ApiError::NotFound(msg) => ( StatusCode::NOT_FOUND, ErrorResponse { error: "Not found".to_string(), details: Some(msg.clone()), }, ), ApiError::NotImplemented(msg) => ( StatusCode::NOT_IMPLEMENTED, ErrorResponse { error: "Not implemented".to_string(), details: Some(msg.clone()), }, ), ApiError::Conflict(msg) => ( StatusCode::CONFLICT, ErrorResponse { error: "Conflict".to_string(), details: Some(msg.clone()), }, ), }; (status, Json(error_response)).into_response() } } impl ApiError { pub fn validation(msg: impl Into) -> Self { Self::Validation(msg.into()) } pub fn internal(msg: impl Into) -> Self { Self::Internal(msg.into()) } pub fn not_found(msg: impl Into) -> Self { Self::NotFound(msg.into()) } pub fn conflict(msg: impl Into) -> Self { Self::Conflict(msg.into()) } pub fn not_implemented(msg: impl Into) -> Self { Self::NotImplemented(msg.into()) } }