Skip to main content

cqrs_rust_lib/
errors.rs

1//! Unified Error Handling for CQRS
2//!
3//! This module provides a structured error system where:
4//! - Each domain defines its own error codes via the `CqrsErrorCode` trait
5//! - All errors are serialized to a unified `CqrsError` format for API responses
6//! - Technical/infrastructure errors are mapped to a dedicated prefix
7//!
8//! # Domain Prefixes
9//!
10//!
11//! Internal codes are formatted as: `prefix * 1000 + error_index`
12//! Example: Tenant NotFound = 4001
13
14use http::StatusCode;
15
16use crate::{MaybeSend, MaybeSync};
17use serde::{Deserialize, Serialize};
18use std::fmt::{Debug, Display};
19use thiserror::Error;
20
21#[cfg(feature = "utoipa")]
22use utoipa::ToSchema;
23
24/// Trait that all domain error codes must implement.
25///
26/// Each domain defines its own enum implementing this trait.
27/// The trait provides the contract for error code metadata.
28///
29/// # Example
30///
31/// ```rust,ignore
32/// use cqrs_rust_lib::define_domain_errors;
33///
34/// define_domain_errors! {
35///     domain: "plan",
36///     prefix: 4,
37///     errors: {
38///         NotFound => (1, StatusCode::NOT_FOUND, "NOT_FOUND"),
39///         SlugExists => (2, StatusCode::CONFLICT, "SLUG_EXISTS"),
40///     }
41/// }
42/// ```
43pub trait CqrsErrorCode: Debug + Display + Clone + MaybeSend + MaybeSync + 'static {
44    /// The domain this error belongs to (e.g., "tenant", "license")
45    fn domain() -> &'static str;
46
47    /// Domain prefix for internal codes (0-9)
48    /// Each domain gets a unique prefix.
49    fn domain_prefix() -> u16;
50
51    /// Unique error index within the domain (0-999)
52    fn error_index(&self) -> u16;
53
54    /// HTTP status code for this error
55    fn http_status(&self) -> StatusCode;
56
57    /// Full internal code: domain_prefix * 1000 + error_index
58    /// Example: Tenant (4) + NotFound (1) = 4001
59    fn internal_code(&self) -> u16 {
60        Self::domain_prefix() * 1000 + self.error_index()
61    }
62
63    /// String representation of the error code for JSON serialization
64    /// Format: DOMAIN_ERROR_NAME (e.g., "PLAN_NOT_FOUND")
65    fn code_string(&self) -> String {
66        format!("{}_{}", Self::domain().to_uppercase(), self)
67    }
68
69    /// Create a CqrsError from this code with a message
70    fn error(&self, message: impl Into<String>) -> CqrsError
71    where
72        Self: Sized,
73    {
74        CqrsError::from_code(self, message)
75    }
76}
77
78// ============================================
79// CqrsError - Unified Error Struct
80// ============================================
81
82/// Internal data for `CqrsError`. Access fields via `Deref` on `CqrsError`.
83#[derive(Debug, Clone, Serialize, Deserialize)]
84#[cfg_attr(feature = "utoipa", derive(ToSchema))]
85#[serde(rename_all = "camelCase")]
86pub struct CqrsErrorData {
87    /// Domain this error originated from (e.g., "plan", "user")
88    pub domain: String,
89
90    /// Error code as string (e.g., "PLAN_NOT_FOUND")
91    pub code: String,
92
93    /// Unique internal code for support/debugging (e.g., 4001)
94    pub internal_code: u16,
95
96    /// HTTP status code (not serialized, used for response)
97    #[serde(skip)]
98    pub status: u16,
99
100    /// Human-readable error message
101    pub message: String,
102
103    /// Additional context (optional)
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub details: Option<serde_json::Value>,
106
107    /// Request ID for tracing (optional)
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub request_id: Option<String>,
110
111    /// Overrides the `type` member of the RFC 9457 problem document (optional).
112    /// See [`crate::problem::ProblemDetails`].
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub type_uri: Option<String>,
115}
116
117/// Unified error for API responses.
118///
119/// This is a thin wrapper around `Box<CqrsErrorData>` to keep `Result<_, CqrsError>`
120/// small on the stack. Access fields via `Deref` (e.g. `err.domain`, `err.code`).
121///
122/// # JSON Format
123///
124/// ```json
125/// {
126///   "domain": "plan",
127///   "code": "PLAN_NOT_FOUND",
128///   "internalCode": 4001,
129///   "message": "Tenant with ID 'abc' not found",
130///   "details": { "id": "abc" },
131///   "requestId": "req-123"
132/// }
133/// ```
134#[derive(Debug, Clone, Serialize, Deserialize)]
135#[serde(transparent)]
136pub struct CqrsError(Box<CqrsErrorData>);
137
138impl std::ops::Deref for CqrsError {
139    type Target = CqrsErrorData;
140    fn deref(&self) -> &CqrsErrorData {
141        &self.0
142    }
143}
144
145impl std::ops::DerefMut for CqrsError {
146    fn deref_mut(&mut self) -> &mut CqrsErrorData {
147        &mut self.0
148    }
149}
150
151// The advertised schema follows the wire format actually produced by the REST
152// layer: an RFC 9457 problem document under `problem-json`, the legacy body
153// otherwise.
154#[cfg(all(feature = "utoipa", not(feature = "problem-json")))]
155impl utoipa::PartialSchema for CqrsError {
156    fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
157        CqrsErrorData::schema()
158    }
159}
160
161#[cfg(all(feature = "utoipa", feature = "problem-json"))]
162impl utoipa::PartialSchema for CqrsError {
163    fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
164        crate::problem::ProblemDetails::schema()
165    }
166}
167
168#[cfg(feature = "utoipa")]
169impl utoipa::ToSchema for CqrsError {
170    fn name() -> std::borrow::Cow<'static, str> {
171        std::borrow::Cow::Borrowed("CqrsError")
172    }
173}
174
175impl CqrsError {
176    /// Create a CqrsError from any domain error code.
177    pub fn from_code<C: CqrsErrorCode>(code: &C, message: impl Into<String>) -> Self {
178        Self(Box::new(CqrsErrorData {
179            domain: C::domain().to_string(),
180            code: code.code_string(),
181            internal_code: code.internal_code(),
182            status: code.http_status().as_u16(),
183            message: message.into(),
184            details: None,
185            request_id: None,
186            type_uri: None,
187        }))
188    }
189
190    /// Add additional details to the error.
191    pub fn with_details(mut self, details: serde_json::Value) -> Self {
192        self.details = Some(details);
193        self
194    }
195
196    /// Add a request ID for tracing.
197    pub fn with_request_id(mut self, request_id: impl Into<String>) -> Self {
198        self.request_id = Some(request_id.into());
199        self
200    }
201
202    /// Add a request ID unless the error already carries one (empty ids are
203    /// ignored). Used by the REST layer to stamp errors with the
204    /// [`crate::CqrsContext`] request id on their way out.
205    pub fn with_request_id_if_absent(mut self, request_id: impl Into<String>) -> Self {
206        let request_id = request_id.into();
207        if self.request_id.is_none() && !request_id.is_empty() {
208            self.request_id = Some(request_id);
209        }
210        self
211    }
212
213    /// Override the `type` member of the RFC 9457 problem document for this
214    /// error, bypassing both the configured base URI and the default
215    /// `urn:cqrs-error:{domain}:{code}`.
216    pub fn with_type_uri(mut self, type_uri: impl Into<String>) -> Self {
217        self.type_uri = Some(type_uri.into());
218        self
219    }
220
221    /// Get the HTTP status code for this error.
222    pub fn http_status(&self) -> StatusCode {
223        StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)
224    }
225
226    /// Render this error as an RFC 9457 problem document.
227    ///
228    /// Available regardless of the `problem-json` feature, which only controls
229    /// what the built-in Axum routers emit.
230    pub fn to_problem(&self) -> crate::problem::ProblemDetails {
231        crate::problem::ProblemDetails::from(self)
232    }
233
234    // ============================================
235    // Convenience constructors for common errors
236    // ============================================
237
238    /// Create a generic not found error.
239    pub fn not_found(message: impl Into<String>) -> Self {
240        GenericErrorCode::NotFound.error(message)
241    }
242
243    /// Create a generic validation error.
244    pub fn validation(message: impl Into<String>) -> Self {
245        GenericErrorCode::ValidationFailed.error(message)
246    }
247
248    /// Create a generic internal error.
249    pub fn internal(message: impl Into<String>) -> Self {
250        GenericErrorCode::InternalError.error(message)
251    }
252
253    /// Create a generic conflict error.
254    pub fn conflict(message: impl Into<String>) -> Self {
255        GenericErrorCode::Conflict.error(message)
256    }
257
258    /// Create a generic unauthorized error.
259    pub fn unauthorized(message: impl Into<String>) -> Self {
260        GenericErrorCode::Unauthorized.error(message)
261    }
262
263    /// Create a generic forbidden error.
264    pub fn forbidden(message: impl Into<String>) -> Self {
265        GenericErrorCode::Forbidden.error(message)
266    }
267
268    /// Create a generic gone error (410).
269    pub fn gone(message: impl Into<String>) -> Self {
270        GenericErrorCode::Gone.error(message)
271    }
272
273    /// Create a generic unprocessable entity error (422).
274    ///
275    /// Use this instead of [`Self::validation`] when the request is
276    /// syntactically valid but semantically rejected.
277    pub fn unprocessable(message: impl Into<String>) -> Self {
278        GenericErrorCode::UnprocessableEntity.error(message)
279    }
280
281    /// Create a generic precondition failed error (412).
282    pub fn precondition_failed(message: impl Into<String>) -> Self {
283        GenericErrorCode::PreconditionFailed.error(message)
284    }
285
286    /// Create a generic precondition required error (428).
287    pub fn precondition_required(message: impl Into<String>) -> Self {
288        GenericErrorCode::PreconditionRequired.error(message)
289    }
290
291    /// Create a generic unsupported media type error (415).
292    pub fn unsupported_media_type(message: impl Into<String>) -> Self {
293        GenericErrorCode::UnsupportedMediaType.error(message)
294    }
295
296    /// Create a generic payload too large error (413).
297    pub fn payload_too_large(message: impl Into<String>) -> Self {
298        GenericErrorCode::PayloadTooLarge.error(message)
299    }
300
301    /// Create a generic too many requests error (429).
302    pub fn too_many_requests(message: impl Into<String>) -> Self {
303        GenericErrorCode::TooManyRequests.error(message)
304    }
305
306    /// Create a generic not implemented error (501).
307    pub fn not_implemented(message: impl Into<String>) -> Self {
308        GenericErrorCode::NotImplemented.error(message)
309    }
310
311    /// Create a generic service unavailable error (503).
312    pub fn service_unavailable(message: impl Into<String>) -> Self {
313        GenericErrorCode::ServiceUnavailable.error(message)
314    }
315
316    // ============================================
317    // Migration helpers (mirror AggregateError variants)
318    // ============================================
319
320    /// Create a user/domain error.
321    pub fn user_error(e: impl std::fmt::Display) -> Self {
322        InfrastructureErrorCode::DomainError.error(e.to_string())
323    }
324
325    /// Create a database error.
326    pub fn database_error(e: impl std::fmt::Display) -> Self {
327        InfrastructureErrorCode::DatabaseError.error(e.to_string())
328    }
329
330    /// Create a serialization error.
331    pub fn serialization_error(e: impl std::fmt::Display) -> Self {
332        InfrastructureErrorCode::SerializationError.error(e.to_string())
333    }
334
335    /// Create a concurrency/version conflict error.
336    pub fn concurrency_error() -> Self {
337        InfrastructureErrorCode::ConcurrencyError.error("Version conflict")
338    }
339
340    /// Create an aggregate not found error.
341    pub fn aggregate_not_found(id: &str) -> Self {
342        InfrastructureErrorCode::AggregateNotFound.error(format!("Aggregate '{}' not found", id))
343    }
344
345    /// Create an aggregate already exists error.
346    pub fn aggregate_already_exists(id: &str) -> Self {
347        InfrastructureErrorCode::Conflict.error(format!("Aggregate '{}' already exists", id))
348    }
349
350    /// Create an error from an HTTP status code and message.
351    ///
352    /// The status is always preserved: statuses without a dedicated
353    /// [`GenericErrorCode`] variant fall back to [`GenericErrorCode::Other`]
354    /// (code `GENERIC_HTTP_<status>`) rather than being degraded to 500.
355    pub fn from_status(status: StatusCode, message: impl Into<String>) -> Self {
356        GenericErrorCode::from(status).error(message)
357    }
358}
359
360impl Display for CqrsError {
361    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
362        write!(
363            f,
364            "[{}] {}: {}",
365            self.internal_code, self.code, self.message
366        )
367    }
368}
369
370impl std::error::Error for CqrsError {}
371
372impl From<std::io::Error> for CqrsError {
373    fn from(e: std::io::Error) -> Self {
374        CqrsError::user_error(e)
375    }
376}
377
378// ============================================
379// Infrastructure Error Codes (prefix = 0)
380// ============================================
381
382/// Error codes for infrastructure/technical errors.
383///
384/// These are used when converting from low-level errors.
385/// Domain-specific errors should use their own error codes instead.
386#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
387pub enum InfrastructureErrorCode {
388    #[error("INTERNAL_ERROR")]
389    InternalError,
390    #[error("VALIDATION_FAILED")]
391    ValidationFailed,
392    #[error("NOT_FOUND")]
393    NotFound,
394    #[error("CONFLICT")]
395    Conflict,
396    #[error("UNAUTHORIZED")]
397    Unauthorized,
398    #[error("FORBIDDEN")]
399    Forbidden,
400    #[error("GONE")]
401    Gone,
402    #[error("DATABASE_ERROR")]
403    DatabaseError,
404    #[error("SERIALIZATION_ERROR")]
405    SerializationError,
406    #[error("AGGREGATE_NOT_FOUND")]
407    AggregateNotFound,
408    #[error("CONCURRENCY_ERROR")]
409    ConcurrencyError,
410    #[error("DOMAIN_ERROR")]
411    DomainError,
412    #[error("CQRS_ERROR")]
413    CqrsInternalError,
414    #[error("CONFIGURATION_ERROR")]
415    ConfigurationError,
416    #[error("UNKNOWN")]
417    Unknown,
418}
419
420impl CqrsErrorCode for InfrastructureErrorCode {
421    fn domain() -> &'static str {
422        "infrastructure"
423    }
424    fn domain_prefix() -> u16 {
425        0
426    }
427
428    fn error_index(&self) -> u16 {
429        match self {
430            Self::InternalError => 0,
431            Self::ValidationFailed => 1,
432            Self::NotFound => 2,
433            Self::Conflict => 3,
434            Self::Unauthorized => 4,
435            Self::Forbidden => 5,
436            Self::Gone => 6,
437            Self::DatabaseError => 10,
438            Self::SerializationError => 11,
439            Self::AggregateNotFound => 12,
440            Self::ConcurrencyError => 13,
441            Self::DomainError => 14,
442            Self::CqrsInternalError => 15,
443            Self::ConfigurationError => 16,
444            Self::Unknown => 99,
445        }
446    }
447
448    fn http_status(&self) -> StatusCode {
449        match self {
450            Self::InternalError => StatusCode::INTERNAL_SERVER_ERROR,
451            Self::ValidationFailed => StatusCode::BAD_REQUEST,
452            Self::NotFound | Self::AggregateNotFound => StatusCode::NOT_FOUND,
453            Self::Conflict | Self::ConcurrencyError => StatusCode::CONFLICT,
454            Self::Unauthorized => StatusCode::UNAUTHORIZED,
455            Self::Forbidden => StatusCode::FORBIDDEN,
456            Self::Gone => StatusCode::GONE,
457            Self::DatabaseError
458            | Self::SerializationError
459            | Self::CqrsInternalError
460            | Self::ConfigurationError
461            | Self::Unknown => StatusCode::INTERNAL_SERVER_ERROR,
462            Self::DomainError => StatusCode::BAD_REQUEST,
463        }
464    }
465}
466
467// ============================================
468// Generic Error Codes (prefix = 1)
469// ============================================
470
471/// Generic error codes for common scenarios.
472///
473/// Use these when a domain-specific error code is not available.
474///
475/// # Error indexes
476///
477/// The six historical variants keep their original indexes (0-6) so existing
478/// `internalCode` values stay stable. Every variant added later uses its HTTP
479/// status code as index, which makes the internal code self-describing
480/// (`UnprocessableEntity` -> 1422, `TooManyRequests` -> 1429).
481///
482/// `Other` is the catch-all for any status without a dedicated variant: it
483/// carries the status verbatim so nothing is ever silently degraded to 500.
484#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
485#[non_exhaustive]
486pub enum GenericErrorCode {
487    #[error("INTERNAL_ERROR")]
488    InternalError,
489    #[error("VALIDATION_FAILED")]
490    ValidationFailed,
491    #[error("NOT_FOUND")]
492    NotFound,
493    #[error("CONFLICT")]
494    Conflict,
495    #[error("UNAUTHORIZED")]
496    Unauthorized,
497    #[error("FORBIDDEN")]
498    Forbidden,
499    #[error("GONE")]
500    Gone,
501    #[error("PAYMENT_REQUIRED")]
502    PaymentRequired,
503    #[error("METHOD_NOT_ALLOWED")]
504    MethodNotAllowed,
505    #[error("NOT_ACCEPTABLE")]
506    NotAcceptable,
507    #[error("REQUEST_TIMEOUT")]
508    RequestTimeout,
509    #[error("PRECONDITION_FAILED")]
510    PreconditionFailed,
511    #[error("PAYLOAD_TOO_LARGE")]
512    PayloadTooLarge,
513    #[error("UNSUPPORTED_MEDIA_TYPE")]
514    UnsupportedMediaType,
515    #[error("UNPROCESSABLE_ENTITY")]
516    UnprocessableEntity,
517    #[error("LOCKED")]
518    Locked,
519    #[error("PRECONDITION_REQUIRED")]
520    PreconditionRequired,
521    #[error("TOO_MANY_REQUESTS")]
522    TooManyRequests,
523    #[error("NOT_IMPLEMENTED")]
524    NotImplemented,
525    #[error("SERVICE_UNAVAILABLE")]
526    ServiceUnavailable,
527    #[error("GATEWAY_TIMEOUT")]
528    GatewayTimeout,
529    /// Any other HTTP status, kept verbatim (e.g. `Other(418)` ->
530    /// `GENERIC_HTTP_418`, internal code 1418, status 418).
531    #[error("HTTP_{0}")]
532    Other(u16),
533}
534
535impl CqrsErrorCode for GenericErrorCode {
536    fn domain() -> &'static str {
537        "generic"
538    }
539    fn domain_prefix() -> u16 {
540        1
541    }
542
543    fn error_index(&self) -> u16 {
544        match self {
545            Self::InternalError => 0,
546            Self::ValidationFailed => 1,
547            Self::NotFound => 2,
548            Self::Conflict => 3,
549            Self::Unauthorized => 4,
550            Self::Forbidden => 5,
551            Self::Gone => 6,
552            // Variants added later: index == HTTP status code.
553            Self::PaymentRequired => 402,
554            Self::MethodNotAllowed => 405,
555            Self::NotAcceptable => 406,
556            Self::RequestTimeout => 408,
557            Self::PreconditionFailed => 412,
558            Self::PayloadTooLarge => 413,
559            Self::UnsupportedMediaType => 415,
560            Self::UnprocessableEntity => 422,
561            Self::Locked => 423,
562            Self::PreconditionRequired => 428,
563            Self::TooManyRequests => 429,
564            Self::NotImplemented => 501,
565            Self::ServiceUnavailable => 503,
566            Self::GatewayTimeout => 504,
567            Self::Other(status) => *status,
568        }
569    }
570
571    fn http_status(&self) -> StatusCode {
572        match self {
573            Self::InternalError => StatusCode::INTERNAL_SERVER_ERROR,
574            Self::ValidationFailed => StatusCode::BAD_REQUEST,
575            Self::NotFound => StatusCode::NOT_FOUND,
576            Self::Conflict => StatusCode::CONFLICT,
577            Self::Unauthorized => StatusCode::UNAUTHORIZED,
578            Self::Forbidden => StatusCode::FORBIDDEN,
579            Self::Gone => StatusCode::GONE,
580            Self::PaymentRequired => StatusCode::PAYMENT_REQUIRED,
581            Self::MethodNotAllowed => StatusCode::METHOD_NOT_ALLOWED,
582            Self::NotAcceptable => StatusCode::NOT_ACCEPTABLE,
583            Self::RequestTimeout => StatusCode::REQUEST_TIMEOUT,
584            Self::PreconditionFailed => StatusCode::PRECONDITION_FAILED,
585            Self::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE,
586            Self::UnsupportedMediaType => StatusCode::UNSUPPORTED_MEDIA_TYPE,
587            Self::UnprocessableEntity => StatusCode::UNPROCESSABLE_ENTITY,
588            Self::Locked => StatusCode::LOCKED,
589            Self::PreconditionRequired => StatusCode::PRECONDITION_REQUIRED,
590            Self::TooManyRequests => StatusCode::TOO_MANY_REQUESTS,
591            Self::NotImplemented => StatusCode::NOT_IMPLEMENTED,
592            Self::ServiceUnavailable => StatusCode::SERVICE_UNAVAILABLE,
593            Self::GatewayTimeout => StatusCode::GATEWAY_TIMEOUT,
594            Self::Other(status) => {
595                StatusCode::from_u16(*status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)
596            }
597        }
598    }
599}
600
601impl From<StatusCode> for GenericErrorCode {
602    /// Maps an HTTP status to its dedicated variant, falling back to
603    /// [`GenericErrorCode::Other`] — which preserves the status — instead of
604    /// degrading unknown statuses to 500.
605    fn from(status: StatusCode) -> Self {
606        match status.as_u16() {
607            400 => GenericErrorCode::ValidationFailed,
608            401 => GenericErrorCode::Unauthorized,
609            402 => GenericErrorCode::PaymentRequired,
610            403 => GenericErrorCode::Forbidden,
611            404 => GenericErrorCode::NotFound,
612            405 => GenericErrorCode::MethodNotAllowed,
613            406 => GenericErrorCode::NotAcceptable,
614            408 => GenericErrorCode::RequestTimeout,
615            409 => GenericErrorCode::Conflict,
616            410 => GenericErrorCode::Gone,
617            412 => GenericErrorCode::PreconditionFailed,
618            413 => GenericErrorCode::PayloadTooLarge,
619            415 => GenericErrorCode::UnsupportedMediaType,
620            422 => GenericErrorCode::UnprocessableEntity,
621            423 => GenericErrorCode::Locked,
622            428 => GenericErrorCode::PreconditionRequired,
623            429 => GenericErrorCode::TooManyRequests,
624            500 => GenericErrorCode::InternalError,
625            501 => GenericErrorCode::NotImplemented,
626            503 => GenericErrorCode::ServiceUnavailable,
627            504 => GenericErrorCode::GatewayTimeout,
628            other => GenericErrorCode::Other(other),
629        }
630    }
631}
632
633// ============================================
634// Backward Compatibility
635// ============================================
636
637#[deprecated(since = "0.2.0", note = "Use CqrsError instead")]
638pub type AggregateError = CqrsError;
639
640// ============================================
641// Domain Error Code Macro
642// ============================================
643
644/// Macro to define domain-specific error codes with minimal boilerplate.
645///
646/// # Example
647///
648/// ```rust,ignore
649/// use cqrs_rust_lib::define_domain_errors;
650/// use http::StatusCode;
651///
652/// define_domain_errors! {
653///     domain: "tenant",
654///     prefix: 4,
655///     errors: {
656///         NotFound => (1, StatusCode::NOT_FOUND, "NOT_FOUND"),
657///         Suspended => (2, StatusCode::BAD_REQUEST, "SUSPENDED"),
658///         Deleted => (3, StatusCode::GONE, "DELETED"),
659///         SlugExists => (4, StatusCode::CONFLICT, "SLUG_EXISTS"),
660///     }
661/// }
662///
663/// // Usage:
664/// let err = ErrorCode::NotFound.error("Tenant 'abc' not found");
665/// // -> CqrsError { domain: "tenant", code: "TENANT_NOT_FOUND", internal_code: 4001, ... }
666/// ```
667#[macro_export]
668macro_rules! define_domain_errors {
669    (
670        domain: $domain:literal,
671        prefix: $prefix:expr,
672        errors: {
673            $( $variant:ident => ($index:expr, $status:expr, $display:literal) ),* $(,)?
674        }
675    ) => {
676        /// Domain-specific error codes.
677        #[derive(Debug, Clone, Copy, PartialEq, Eq, ::thiserror::Error)]
678        pub enum ErrorCode {
679            $(
680                #[error($display)]
681                $variant,
682            )*
683        }
684
685        impl $crate::CqrsErrorCode for ErrorCode {
686            fn domain() -> &'static str { $domain }
687            fn domain_prefix() -> u16 { $prefix }
688
689            fn error_index(&self) -> u16 {
690                match self {
691                    $( Self::$variant => $index, )*
692                }
693            }
694
695            fn http_status(&self) -> ::http::StatusCode {
696                match self {
697                    $( Self::$variant => $status, )*
698                }
699            }
700        }
701    };
702}
703
704// ============================================
705// Tests
706// ============================================
707
708#[cfg(test)]
709mod tests {
710    use super::*;
711
712    #[test]
713    fn test_generic_error_code() {
714        let err = GenericErrorCode::NotFound.error("Resource not found");
715        assert_eq!(err.domain, "generic");
716        assert_eq!(err.code, "GENERIC_NOT_FOUND");
717        assert_eq!(err.internal_code, 1002);
718        assert_eq!(err.status, 404);
719    }
720
721    #[test]
722    fn test_infrastructure_error_code() {
723        let err = InfrastructureErrorCode::DatabaseError.error("Connection failed");
724        assert_eq!(err.domain, "infrastructure");
725        assert_eq!(err.code, "INFRASTRUCTURE_DATABASE_ERROR");
726        assert_eq!(err.internal_code, 10); // 0 * 1000 + 10
727        assert_eq!(err.status, 500);
728    }
729
730    #[test]
731    fn test_convenience_constructors() {
732        let err = CqrsError::not_found("User not found");
733        assert_eq!(err.code, "GENERIC_NOT_FOUND");
734
735        let err = CqrsError::validation("Invalid email");
736        assert_eq!(err.code, "GENERIC_VALIDATION_FAILED");
737    }
738
739    #[test]
740    fn test_migration_constructors() {
741        let err = CqrsError::user_error("bad input");
742        assert_eq!(err.code, "INFRASTRUCTURE_DOMAIN_ERROR");
743        assert_eq!(err.status, 400);
744
745        let err = CqrsError::database_error("connection lost");
746        assert_eq!(err.code, "INFRASTRUCTURE_DATABASE_ERROR");
747        assert_eq!(err.status, 500);
748
749        let err = CqrsError::serialization_error("invalid json");
750        assert_eq!(err.code, "INFRASTRUCTURE_SERIALIZATION_ERROR");
751        assert_eq!(err.status, 500);
752
753        let err = CqrsError::concurrency_error();
754        assert_eq!(err.code, "INFRASTRUCTURE_CONCURRENCY_ERROR");
755        assert_eq!(err.status, 409);
756
757        let err = CqrsError::aggregate_not_found("abc");
758        assert_eq!(err.code, "INFRASTRUCTURE_AGGREGATE_NOT_FOUND");
759        assert_eq!(err.status, 404);
760        assert!(err.message.contains("abc"));
761
762        let err = CqrsError::aggregate_already_exists("xyz");
763        assert_eq!(err.code, "INFRASTRUCTURE_CONFLICT");
764        assert_eq!(err.status, 409);
765        assert!(err.message.contains("xyz"));
766    }
767
768    #[test]
769    fn test_from_status_keeps_historical_codes() {
770        // Indexes 0-6 must stay stable for backward compatibility.
771        for (status, code, internal) in [
772            (StatusCode::BAD_REQUEST, "GENERIC_VALIDATION_FAILED", 1001),
773            (StatusCode::NOT_FOUND, "GENERIC_NOT_FOUND", 1002),
774            (StatusCode::CONFLICT, "GENERIC_CONFLICT", 1003),
775            (StatusCode::UNAUTHORIZED, "GENERIC_UNAUTHORIZED", 1004),
776            (StatusCode::FORBIDDEN, "GENERIC_FORBIDDEN", 1005),
777            (StatusCode::GONE, "GENERIC_GONE", 1006),
778            (
779                StatusCode::INTERNAL_SERVER_ERROR,
780                "GENERIC_INTERNAL_ERROR",
781                1000,
782            ),
783        ] {
784            let err = CqrsError::from_status(status, "boom");
785            assert_eq!(err.status, status.as_u16());
786            assert_eq!(err.code, code);
787            assert_eq!(err.internal_code, internal);
788        }
789    }
790
791    #[test]
792    fn test_from_status_supports_additional_statuses() {
793        for (status, code, internal) in [
794            (
795                StatusCode::UNPROCESSABLE_ENTITY,
796                "GENERIC_UNPROCESSABLE_ENTITY",
797                1422,
798            ),
799            (
800                StatusCode::TOO_MANY_REQUESTS,
801                "GENERIC_TOO_MANY_REQUESTS",
802                1429,
803            ),
804            (
805                StatusCode::PRECONDITION_FAILED,
806                "GENERIC_PRECONDITION_FAILED",
807                1412,
808            ),
809            (
810                StatusCode::UNSUPPORTED_MEDIA_TYPE,
811                "GENERIC_UNSUPPORTED_MEDIA_TYPE",
812                1415,
813            ),
814            (
815                StatusCode::SERVICE_UNAVAILABLE,
816                "GENERIC_SERVICE_UNAVAILABLE",
817                1503,
818            ),
819            (
820                StatusCode::PAYMENT_REQUIRED,
821                "GENERIC_PAYMENT_REQUIRED",
822                1402,
823            ),
824        ] {
825            let err = CqrsError::from_status(status, "boom");
826            assert_eq!(err.status, status.as_u16(), "status for {status}");
827            assert_eq!(err.code, code);
828            assert_eq!(err.internal_code, internal);
829        }
830    }
831
832    #[test]
833    fn test_from_status_never_degrades_unknown_status() {
834        let err = CqrsError::from_status(StatusCode::IM_A_TEAPOT, "no coffee");
835        assert_eq!(err.status, 418);
836        assert_eq!(err.code, "GENERIC_HTTP_418");
837        assert_eq!(err.internal_code, 1418);
838        assert_eq!(err.http_status(), StatusCode::IM_A_TEAPOT);
839    }
840
841    #[test]
842    fn test_additional_convenience_constructors() {
843        assert_eq!(CqrsError::unprocessable("nope").status, 422);
844        assert_eq!(CqrsError::too_many_requests("slow down").status, 429);
845        assert_eq!(CqrsError::precondition_failed("etag").status, 412);
846        assert_eq!(CqrsError::precondition_required("etag").status, 428);
847        assert_eq!(CqrsError::unsupported_media_type("xml").status, 415);
848        assert_eq!(CqrsError::payload_too_large("too big").status, 413);
849        assert_eq!(CqrsError::not_implemented("later").status, 501);
850        assert_eq!(CqrsError::service_unavailable("maintenance").status, 503);
851        assert_eq!(CqrsError::gone("removed").status, 410);
852    }
853
854    #[test]
855    fn test_with_details() {
856        let err = GenericErrorCode::NotFound
857            .error("User not found")
858            .with_details(serde_json::json!({"user_id": "123"}));
859
860        assert!(err.details.is_some());
861        assert_eq!(err.details.as_ref().unwrap()["user_id"], "123");
862    }
863
864    #[test]
865    fn test_serialization() {
866        let err = GenericErrorCode::Conflict.error("Already exists");
867        let json = serde_json::to_string(&err).unwrap();
868
869        assert!(json.contains("\"domain\":\"generic\""));
870        assert!(json.contains("\"code\":\"GENERIC_CONFLICT\""));
871        assert!(json.contains("\"internalCode\":1003"));
872        assert!(json.contains("\"message\":\"Already exists\""));
873        // status should not be serialized
874        assert!(!json.contains("\"status\""));
875    }
876}