use http::StatusCode;
use serde::{Deserialize, Serialize};
use std::fmt::{Debug, Display};
use thiserror::Error;
#[cfg(feature = "utoipa")]
use utoipa::ToSchema;
pub trait CqrsErrorCode: Debug + Display + Clone + Send + Sync + 'static {
fn domain() -> &'static str;
fn domain_prefix() -> u16;
fn error_index(&self) -> u16;
fn http_status(&self) -> StatusCode;
fn internal_code(&self) -> u16 {
Self::domain_prefix() * 1000 + self.error_index()
}
fn code_string(&self) -> String {
format!("{}_{}", Self::domain().to_uppercase(), self)
}
fn error(&self, message: impl Into<String>) -> CqrsError
where
Self: Sized,
{
CqrsError::from_code(self, message)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct CqrsErrorData {
pub domain: String,
pub code: String,
pub internal_code: u16,
#[serde(skip)]
pub status: u16,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(transparent)]
pub struct CqrsError(Box<CqrsErrorData>);
impl std::ops::Deref for CqrsError {
type Target = CqrsErrorData;
fn deref(&self) -> &CqrsErrorData {
&self.0
}
}
impl std::ops::DerefMut for CqrsError {
fn deref_mut(&mut self) -> &mut CqrsErrorData {
&mut self.0
}
}
#[cfg(feature = "utoipa")]
impl utoipa::PartialSchema for CqrsError {
fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
CqrsErrorData::schema()
}
}
#[cfg(feature = "utoipa")]
impl utoipa::ToSchema for CqrsError {
fn name() -> std::borrow::Cow<'static, str> {
std::borrow::Cow::Borrowed("CqrsError")
}
}
impl CqrsError {
pub fn from_code<C: CqrsErrorCode>(code: &C, message: impl Into<String>) -> Self {
Self(Box::new(CqrsErrorData {
domain: C::domain().to_string(),
code: code.code_string(),
internal_code: code.internal_code(),
status: code.http_status().as_u16(),
message: message.into(),
details: None,
request_id: None,
}))
}
pub fn with_details(mut self, details: serde_json::Value) -> Self {
self.details = Some(details);
self
}
pub fn with_request_id(mut self, request_id: impl Into<String>) -> Self {
self.request_id = Some(request_id.into());
self
}
pub fn http_status(&self) -> StatusCode {
StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)
}
pub fn not_found(message: impl Into<String>) -> Self {
GenericErrorCode::NotFound.error(message)
}
pub fn validation(message: impl Into<String>) -> Self {
GenericErrorCode::ValidationFailed.error(message)
}
pub fn internal(message: impl Into<String>) -> Self {
GenericErrorCode::InternalError.error(message)
}
pub fn conflict(message: impl Into<String>) -> Self {
GenericErrorCode::Conflict.error(message)
}
pub fn unauthorized(message: impl Into<String>) -> Self {
GenericErrorCode::Unauthorized.error(message)
}
pub fn forbidden(message: impl Into<String>) -> Self {
GenericErrorCode::Forbidden.error(message)
}
pub fn user_error(e: impl std::fmt::Display) -> Self {
InfrastructureErrorCode::DomainError.error(e.to_string())
}
pub fn database_error(e: impl std::fmt::Display) -> Self {
InfrastructureErrorCode::DatabaseError.error(e.to_string())
}
pub fn serialization_error(e: impl std::fmt::Display) -> Self {
InfrastructureErrorCode::SerializationError.error(e.to_string())
}
pub fn concurrency_error() -> Self {
InfrastructureErrorCode::ConcurrencyError.error("Version conflict")
}
pub fn aggregate_not_found(id: &str) -> Self {
InfrastructureErrorCode::AggregateNotFound.error(format!("Aggregate '{}' not found", id))
}
pub fn aggregate_already_exists(id: &str) -> Self {
InfrastructureErrorCode::Conflict.error(format!("Aggregate '{}' already exists", id))
}
pub fn from_status(status: StatusCode, message: impl Into<String>) -> Self {
GenericErrorCode::from(status).error(message)
}
}
impl Display for CqrsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"[{}] {}: {}",
self.internal_code, self.code, self.message
)
}
}
impl std::error::Error for CqrsError {}
impl From<std::io::Error> for CqrsError {
fn from(e: std::io::Error) -> Self {
CqrsError::user_error(e)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum InfrastructureErrorCode {
#[error("INTERNAL_ERROR")]
InternalError,
#[error("VALIDATION_FAILED")]
ValidationFailed,
#[error("NOT_FOUND")]
NotFound,
#[error("CONFLICT")]
Conflict,
#[error("UNAUTHORIZED")]
Unauthorized,
#[error("FORBIDDEN")]
Forbidden,
#[error("GONE")]
Gone,
#[error("DATABASE_ERROR")]
DatabaseError,
#[error("SERIALIZATION_ERROR")]
SerializationError,
#[error("AGGREGATE_NOT_FOUND")]
AggregateNotFound,
#[error("CONCURRENCY_ERROR")]
ConcurrencyError,
#[error("DOMAIN_ERROR")]
DomainError,
#[error("CQRS_ERROR")]
CqrsInternalError,
#[error("CONFIGURATION_ERROR")]
ConfigurationError,
#[error("UNKNOWN")]
Unknown,
}
impl CqrsErrorCode for InfrastructureErrorCode {
fn domain() -> &'static str {
"infrastructure"
}
fn domain_prefix() -> u16 {
0
}
fn error_index(&self) -> u16 {
match self {
Self::InternalError => 0,
Self::ValidationFailed => 1,
Self::NotFound => 2,
Self::Conflict => 3,
Self::Unauthorized => 4,
Self::Forbidden => 5,
Self::Gone => 6,
Self::DatabaseError => 10,
Self::SerializationError => 11,
Self::AggregateNotFound => 12,
Self::ConcurrencyError => 13,
Self::DomainError => 14,
Self::CqrsInternalError => 15,
Self::ConfigurationError => 16,
Self::Unknown => 99,
}
}
fn http_status(&self) -> StatusCode {
match self {
Self::InternalError => StatusCode::INTERNAL_SERVER_ERROR,
Self::ValidationFailed => StatusCode::BAD_REQUEST,
Self::NotFound | Self::AggregateNotFound => StatusCode::NOT_FOUND,
Self::Conflict | Self::ConcurrencyError => StatusCode::CONFLICT,
Self::Unauthorized => StatusCode::UNAUTHORIZED,
Self::Forbidden => StatusCode::FORBIDDEN,
Self::Gone => StatusCode::GONE,
Self::DatabaseError
| Self::SerializationError
| Self::CqrsInternalError
| Self::ConfigurationError
| Self::Unknown => StatusCode::INTERNAL_SERVER_ERROR,
Self::DomainError => StatusCode::BAD_REQUEST,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum GenericErrorCode {
#[error("INTERNAL_ERROR")]
InternalError,
#[error("VALIDATION_FAILED")]
ValidationFailed,
#[error("NOT_FOUND")]
NotFound,
#[error("CONFLICT")]
Conflict,
#[error("UNAUTHORIZED")]
Unauthorized,
#[error("FORBIDDEN")]
Forbidden,
#[error("GONE")]
Gone,
}
impl CqrsErrorCode for GenericErrorCode {
fn domain() -> &'static str {
"generic"
}
fn domain_prefix() -> u16 {
1
}
fn error_index(&self) -> u16 {
match self {
Self::InternalError => 0,
Self::ValidationFailed => 1,
Self::NotFound => 2,
Self::Conflict => 3,
Self::Unauthorized => 4,
Self::Forbidden => 5,
Self::Gone => 6,
}
}
fn http_status(&self) -> StatusCode {
match self {
Self::InternalError => StatusCode::INTERNAL_SERVER_ERROR,
Self::ValidationFailed => StatusCode::BAD_REQUEST,
Self::NotFound => StatusCode::NOT_FOUND,
Self::Conflict => StatusCode::CONFLICT,
Self::Unauthorized => StatusCode::UNAUTHORIZED,
Self::Forbidden => StatusCode::FORBIDDEN,
Self::Gone => StatusCode::GONE,
}
}
}
impl From<StatusCode> for GenericErrorCode {
fn from(status: StatusCode) -> Self {
match status.as_u16() {
400 => GenericErrorCode::ValidationFailed,
401 => GenericErrorCode::Unauthorized,
403 => GenericErrorCode::Forbidden,
404 => GenericErrorCode::NotFound,
409 => GenericErrorCode::Conflict,
410 => GenericErrorCode::Gone,
_ => GenericErrorCode::InternalError,
}
}
}
#[deprecated(since = "0.2.0", note = "Use CqrsError instead")]
pub type AggregateError = CqrsError;
#[macro_export]
macro_rules! define_domain_errors {
(
domain: $domain:literal,
prefix: $prefix:expr,
errors: {
$( $variant:ident => ($index:expr, $status:expr, $display:literal) ),* $(,)?
}
) => {
#[derive(Debug, Clone, Copy, PartialEq, Eq, ::thiserror::Error)]
pub enum ErrorCode {
$(
#[error($display)]
$variant,
)*
}
impl $crate::CqrsErrorCode for ErrorCode {
fn domain() -> &'static str { $domain }
fn domain_prefix() -> u16 { $prefix }
fn error_index(&self) -> u16 {
match self {
$( Self::$variant => $index, )*
}
}
fn http_status(&self) -> ::http::StatusCode {
match self {
$( Self::$variant => $status, )*
}
}
}
};
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generic_error_code() {
let err = GenericErrorCode::NotFound.error("Resource not found");
assert_eq!(err.domain, "generic");
assert_eq!(err.code, "GENERIC_NOT_FOUND");
assert_eq!(err.internal_code, 1002);
assert_eq!(err.status, 404);
}
#[test]
fn test_infrastructure_error_code() {
let err = InfrastructureErrorCode::DatabaseError.error("Connection failed");
assert_eq!(err.domain, "infrastructure");
assert_eq!(err.code, "INFRASTRUCTURE_DATABASE_ERROR");
assert_eq!(err.internal_code, 10); assert_eq!(err.status, 500);
}
#[test]
fn test_convenience_constructors() {
let err = CqrsError::not_found("User not found");
assert_eq!(err.code, "GENERIC_NOT_FOUND");
let err = CqrsError::validation("Invalid email");
assert_eq!(err.code, "GENERIC_VALIDATION_FAILED");
}
#[test]
fn test_migration_constructors() {
let err = CqrsError::user_error("bad input");
assert_eq!(err.code, "INFRASTRUCTURE_DOMAIN_ERROR");
assert_eq!(err.status, 400);
let err = CqrsError::database_error("connection lost");
assert_eq!(err.code, "INFRASTRUCTURE_DATABASE_ERROR");
assert_eq!(err.status, 500);
let err = CqrsError::serialization_error("invalid json");
assert_eq!(err.code, "INFRASTRUCTURE_SERIALIZATION_ERROR");
assert_eq!(err.status, 500);
let err = CqrsError::concurrency_error();
assert_eq!(err.code, "INFRASTRUCTURE_CONCURRENCY_ERROR");
assert_eq!(err.status, 409);
let err = CqrsError::aggregate_not_found("abc");
assert_eq!(err.code, "INFRASTRUCTURE_AGGREGATE_NOT_FOUND");
assert_eq!(err.status, 404);
assert!(err.message.contains("abc"));
let err = CqrsError::aggregate_already_exists("xyz");
assert_eq!(err.code, "INFRASTRUCTURE_CONFLICT");
assert_eq!(err.status, 409);
assert!(err.message.contains("xyz"));
}
#[test]
fn test_with_details() {
let err = GenericErrorCode::NotFound
.error("User not found")
.with_details(serde_json::json!({"user_id": "123"}));
assert!(err.details.is_some());
assert_eq!(err.details.as_ref().unwrap()["user_id"], "123");
}
#[test]
fn test_serialization() {
let err = GenericErrorCode::Conflict.error("Already exists");
let json = serde_json::to_string(&err).unwrap();
assert!(json.contains("\"domain\":\"generic\""));
assert!(json.contains("\"code\":\"GENERIC_CONFLICT\""));
assert!(json.contains("\"internalCode\":1003"));
assert!(json.contains("\"message\":\"Already exists\""));
assert!(!json.contains("\"status\""));
}
}