Skip to main content

cratestack_core/
error.rs

1//! `CoolError` — the framework's error type, its 4xx/5xx HTTP mapping,
2//! and the public response envelope clients see on failure.
3//!
4//! 4xx variants carry caller-visible messages; 5xx variants keep the
5//! operator detail off the wire and return a canned public message
6//! while preserving the original string for `tracing` / `detail()`.
7
8use std::borrow::Cow;
9
10use http::StatusCode;
11use serde::{Deserialize, Serialize};
12
13use crate::value::Value;
14
15#[cfg(test)]
16mod tests;
17
18#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19pub struct CoolErrorResponse {
20    pub code: String,
21    pub message: String,
22    pub details: Option<Value>,
23}
24
25#[derive(Debug, thiserror::Error)]
26pub enum CoolError {
27    /// 4xx — `String` is the public message returned to the client.
28    #[error("bad request: {0}")]
29    BadRequest(String),
30    #[error("not acceptable: {0}")]
31    NotAcceptable(String),
32    #[error("unauthorized: {0}")]
33    Unauthorized(String),
34    #[error("unsupported media type: {0}")]
35    UnsupportedMediaType(String),
36    #[error("forbidden: {0}")]
37    Forbidden(String),
38    #[error("not found: {0}")]
39    NotFound(String),
40    #[error("conflict: {0}")]
41    Conflict(String),
42    #[error("validation: {0}")]
43    Validation(String),
44    #[error("precondition failed: {0}")]
45    PreconditionFailed(String),
46    /// 5xx — `String` is operator-only detail. Never returned to clients;
47    /// the public message is a fixed canned string per variant.
48    #[error("codec: {0}")]
49    Codec(String),
50    #[error("database: {0}")]
51    Database(String),
52    #[error("internal: {0}")]
53    Internal(String),
54}
55
56impl CoolError {
57    pub fn code(&self) -> &'static str {
58        match self {
59            Self::BadRequest(_) => "BAD_REQUEST",
60            Self::NotAcceptable(_) => "NOT_ACCEPTABLE",
61            Self::Unauthorized(_) => "UNAUTHORIZED",
62            Self::UnsupportedMediaType(_) => "UNSUPPORTED_MEDIA_TYPE",
63            Self::Forbidden(_) => "FORBIDDEN",
64            Self::NotFound(_) => "NOT_FOUND",
65            Self::Conflict(_) => "CONFLICT",
66            Self::Validation(_) => "VALIDATION_ERROR",
67            Self::PreconditionFailed(_) => "PRECONDITION_FAILED",
68            Self::Codec(_) => "CODEC_ERROR",
69            Self::Database(_) => "DATABASE_ERROR",
70            Self::Internal(_) => "INTERNAL_ERROR",
71        }
72    }
73
74    pub fn status_code(&self) -> StatusCode {
75        match self {
76            Self::BadRequest(_) => StatusCode::BAD_REQUEST,
77            Self::NotAcceptable(_) => StatusCode::NOT_ACCEPTABLE,
78            Self::Unauthorized(_) => StatusCode::UNAUTHORIZED,
79            Self::UnsupportedMediaType(_) => StatusCode::UNSUPPORTED_MEDIA_TYPE,
80            Self::Forbidden(_) => StatusCode::FORBIDDEN,
81            Self::NotFound(_) => StatusCode::NOT_FOUND,
82            Self::Conflict(_) => StatusCode::CONFLICT,
83            Self::Validation(_) => StatusCode::UNPROCESSABLE_ENTITY,
84            Self::PreconditionFailed(_) => StatusCode::PRECONDITION_FAILED,
85            Self::Codec(_) => StatusCode::BAD_REQUEST,
86            Self::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
87            Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
88        }
89    }
90
91    /// Public, safe-to-expose message returned in HTTP responses.
92    ///
93    /// For 4xx variants this is the caller-supplied string. For 5xx variants
94    /// this is a fixed canned message; the caller-supplied string flows to
95    /// `detail` instead and is recorded via tracing only.
96    pub fn public_message(&self) -> Cow<'_, str> {
97        match self {
98            Self::BadRequest(s)
99            | Self::NotAcceptable(s)
100            | Self::Unauthorized(s)
101            | Self::UnsupportedMediaType(s)
102            | Self::Forbidden(s)
103            | Self::NotFound(s)
104            | Self::Conflict(s)
105            | Self::Validation(s)
106            | Self::PreconditionFailed(s) => Cow::Borrowed(s.as_str()),
107            Self::Codec(_) => Cow::Borrowed("invalid request payload"),
108            Self::Database(_) => Cow::Borrowed("internal error"),
109            Self::Internal(_) => Cow::Borrowed("internal error"),
110        }
111    }
112
113    /// Operator-only detail string. For 5xx variants this is the message
114    /// supplied at construction time; for 4xx variants this returns the same
115    /// string as `public_message` (callers are expected to pre-redact 4xx
116    /// messages they emit).
117    pub fn detail(&self) -> Option<&str> {
118        match self {
119            Self::BadRequest(s)
120            | Self::NotAcceptable(s)
121            | Self::Unauthorized(s)
122            | Self::UnsupportedMediaType(s)
123            | Self::Forbidden(s)
124            | Self::NotFound(s)
125            | Self::Conflict(s)
126            | Self::Validation(s)
127            | Self::PreconditionFailed(s)
128            | Self::Codec(s)
129            | Self::Database(s)
130            | Self::Internal(s) => {
131                if s.is_empty() {
132                    None
133                } else {
134                    Some(s.as_str())
135                }
136            }
137        }
138    }
139
140    pub fn into_response(self) -> CoolErrorResponse {
141        let code = self.code().to_owned();
142        let message = self.public_message().into_owned();
143        CoolErrorResponse {
144            code,
145            message,
146            details: None,
147        }
148    }
149}
150
151pub fn parse_cuid(value: &str) -> Result<String, CoolError> {
152    if is_valid_cuid(value) {
153        Ok(value.to_owned())
154    } else {
155        Err(CoolError::BadRequest(format!(
156            "invalid cuid '{}': expected a lowercase alphanumeric id starting with 'c'",
157            value,
158        )))
159    }
160}
161
162fn is_valid_cuid(value: &str) -> bool {
163    let mut chars = value.chars();
164    let Some(first) = chars.next() else {
165        return false;
166    };
167    if first != 'c' || value.len() < 2 {
168        return false;
169    }
170    chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit())
171}