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