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/// Structured information extracted from a driver-level database error.
26///
27/// Produced by `cratestack-sqlx`'s [`cool_error_from_sqlx`] when the
28/// underlying `sqlx::Error` carries a typed `DatabaseError` (e.g.
29/// `PgDatabaseError`). Consumers can inspect `constraint` and `code` without
30/// substring-matching the stringified error message.
31///
32/// [`cool_error_from_sqlx`]: cratestack_sqlx::cool_error_from_sqlx
33#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
34pub struct DbErrorInfo {
35    /// The operator-visible detail string (equivalent to `error.to_string()`).
36    pub detail: String,
37    /// The five-character SQLSTATE code (`"23505"` for unique_violation, etc.).
38    /// `None` when the driver did not surface a code.
39    pub sqlstate: Option<String>,
40    /// The constraint name reported by the database (`"accounts_email_key"`,
41    /// etc.). `None` when the error is not constraint-related.
42    pub constraint: Option<String>,
43}
44
45#[derive(Debug, thiserror::Error)]
46#[non_exhaustive]
47pub enum CoolError {
48    /// 4xx — `String` is the public message returned to the client.
49    #[error("bad request: {0}")]
50    BadRequest(String),
51    #[error("not acceptable: {0}")]
52    NotAcceptable(String),
53    #[error("unauthorized: {0}")]
54    Unauthorized(String),
55    #[error("unsupported media type: {0}")]
56    UnsupportedMediaType(String),
57    #[error("forbidden: {0}")]
58    Forbidden(String),
59    #[error("not found: {0}")]
60    NotFound(String),
61    #[error("conflict: {0}")]
62    Conflict(String),
63    #[error("validation: {0}")]
64    Validation(String),
65    #[error("precondition failed: {0}")]
66    PreconditionFailed(String),
67    /// 5xx — `String` is operator-only detail. Never returned to clients;
68    /// the public message is a fixed canned string per variant.
69    #[error("codec: {0}")]
70    Codec(String),
71    /// Database error with only a stringified detail. Preserved for
72    /// back-compat; new code should prefer `DatabaseTyped` produced by
73    /// `cratestack_sqlx::cool_error_from_sqlx`.
74    #[error("database: {0}")]
75    Database(String),
76    /// Database error with structured information preserved from the driver.
77    ///
78    /// Use [`CoolError::db_sqlstate`] and [`CoolError::db_constraint`] to
79    /// access the typed fields without matching on this variant directly.
80    #[error("database: {}", .0.detail)]
81    DatabaseTyped(DbErrorInfo),
82    #[error("internal: {0}")]
83    Internal(String),
84}
85
86impl CoolError {
87    pub fn code(&self) -> &'static str {
88        match self {
89            Self::BadRequest(_) => "BAD_REQUEST",
90            Self::NotAcceptable(_) => "NOT_ACCEPTABLE",
91            Self::Unauthorized(_) => "UNAUTHORIZED",
92            Self::UnsupportedMediaType(_) => "UNSUPPORTED_MEDIA_TYPE",
93            Self::Forbidden(_) => "FORBIDDEN",
94            Self::NotFound(_) => "NOT_FOUND",
95            Self::Conflict(_) => "CONFLICT",
96            Self::Validation(_) => "VALIDATION_ERROR",
97            Self::PreconditionFailed(_) => "PRECONDITION_FAILED",
98            Self::Codec(_) => "CODEC_ERROR",
99            Self::Database(_) | Self::DatabaseTyped(_) => "DATABASE_ERROR",
100            Self::Internal(_) => "INTERNAL_ERROR",
101        }
102    }
103
104    pub fn status_code(&self) -> StatusCode {
105        match self {
106            Self::BadRequest(_) => StatusCode::BAD_REQUEST,
107            Self::NotAcceptable(_) => StatusCode::NOT_ACCEPTABLE,
108            Self::Unauthorized(_) => StatusCode::UNAUTHORIZED,
109            Self::UnsupportedMediaType(_) => StatusCode::UNSUPPORTED_MEDIA_TYPE,
110            Self::Forbidden(_) => StatusCode::FORBIDDEN,
111            Self::NotFound(_) => StatusCode::NOT_FOUND,
112            Self::Conflict(_) => StatusCode::CONFLICT,
113            Self::Validation(_) => StatusCode::UNPROCESSABLE_ENTITY,
114            Self::PreconditionFailed(_) => StatusCode::PRECONDITION_FAILED,
115            Self::Codec(_) => StatusCode::BAD_REQUEST,
116            Self::Database(_) | Self::DatabaseTyped(_) => StatusCode::INTERNAL_SERVER_ERROR,
117            Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
118        }
119    }
120
121    /// Public, safe-to-expose message returned in HTTP responses.
122    ///
123    /// For 4xx variants this is the caller-supplied string. For 5xx variants
124    /// this is a fixed canned message; the caller-supplied string flows to
125    /// `detail` instead and is recorded via tracing only.
126    pub fn public_message(&self) -> Cow<'_, str> {
127        match self {
128            Self::BadRequest(s)
129            | Self::NotAcceptable(s)
130            | Self::Unauthorized(s)
131            | Self::UnsupportedMediaType(s)
132            | Self::Forbidden(s)
133            | Self::NotFound(s)
134            | Self::Conflict(s)
135            | Self::Validation(s)
136            | Self::PreconditionFailed(s) => Cow::Borrowed(s.as_str()),
137            Self::Codec(_) => Cow::Borrowed("invalid request payload"),
138            Self::Database(_) | Self::DatabaseTyped(_) => Cow::Borrowed("internal error"),
139            Self::Internal(_) => Cow::Borrowed("internal error"),
140        }
141    }
142
143    /// Operator-only detail string. For 5xx variants this is the message
144    /// supplied at construction time; for 4xx variants this returns the same
145    /// string as `public_message` (callers are expected to pre-redact 4xx
146    /// messages they emit).
147    pub fn detail(&self) -> Option<&str> {
148        match self {
149            Self::BadRequest(s)
150            | Self::NotAcceptable(s)
151            | Self::Unauthorized(s)
152            | Self::UnsupportedMediaType(s)
153            | Self::Forbidden(s)
154            | Self::NotFound(s)
155            | Self::Conflict(s)
156            | Self::Validation(s)
157            | Self::PreconditionFailed(s)
158            | Self::Codec(s)
159            | Self::Database(s)
160            | Self::Internal(s) => {
161                if s.is_empty() {
162                    None
163                } else {
164                    Some(s.as_str())
165                }
166            }
167            Self::DatabaseTyped(info) => {
168                if info.detail.is_empty() {
169                    None
170                } else {
171                    Some(info.detail.as_str())
172                }
173            }
174        }
175    }
176
177    /// Returns the SQLSTATE code if this is a `DatabaseTyped` error with a
178    /// known code (e.g. `"23505"` for unique_violation).
179    ///
180    /// Always returns `None` for the legacy `Database(String)` variant; to
181    /// get typed access, use `cratestack_sqlx::cool_error_from_sqlx` at the
182    /// conversion site.
183    pub fn db_sqlstate(&self) -> Option<&str> {
184        match self {
185            Self::DatabaseTyped(info) => info.sqlstate.as_deref(),
186            _ => None,
187        }
188    }
189
190    /// Returns the constraint name if this is a `DatabaseTyped` error that
191    /// carries constraint information (e.g. `"accounts_email_key"`).
192    ///
193    /// Always returns `None` for the legacy `Database(String)` variant; to
194    /// get typed access, use `cratestack_sqlx::cool_error_from_sqlx` at the
195    /// conversion site.
196    pub fn db_constraint(&self) -> Option<&str> {
197        match self {
198            Self::DatabaseTyped(info) => info.constraint.as_deref(),
199            _ => None,
200        }
201    }
202
203    pub fn into_response(self) -> CoolErrorResponse {
204        let code = self.code().to_owned();
205        let message = self.public_message().into_owned();
206        CoolErrorResponse {
207            code,
208            message,
209            details: None,
210        }
211    }
212}
213
214pub fn parse_cuid(value: &str) -> Result<String, CoolError> {
215    if is_valid_cuid(value) {
216        Ok(value.to_owned())
217    } else {
218        Err(CoolError::BadRequest(format!(
219            "invalid cuid '{}': expected a lowercase alphanumeric id starting with 'c'",
220            value,
221        )))
222    }
223}
224
225fn is_valid_cuid(value: &str) -> bool {
226    let mut chars = value.chars();
227    let Some(first) = chars.next() else {
228        return false;
229    };
230    if first != 'c' || value.len() < 2 {
231        return false;
232    }
233    chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit())
234}