Skip to main content

rskit_errors/
response.rs

1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::AppError;
6use crate::code::ErrorCode;
7
8const DEFAULT_TYPE_BASE_URI: &str = "https://rskit.dev/errors/";
9
10/// Returns the canonical RFC 9457 type base URI used by rskit errors.
11pub const fn type_base_uri() -> &'static str {
12    DEFAULT_TYPE_BASE_URI
13}
14
15// ── ProblemDetail ─────────────────────────────────────────────────────────────
16
17/// RFC 9457 Problem Details — the single canonical error response envelope for
18/// transport error responses in rskit.
19///
20/// Serialises to:
21/// ```json
22/// {
23///   "type": "https://rskit.dev/errors/not-found",
24///   "title": "Not Found",
25///   "status": 404,
26///   "detail": "user '42' not found",
27///   "code": "NOT_FOUND",
28///   "retryable": false
29/// }
30/// ```
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct ProblemDetail {
33    /// URI reference that identifies the problem type (RFC 9457 §3.1.1).
34    #[serde(rename = "type")]
35    pub error_type: String,
36    /// Short, human-readable summary of the problem type (RFC 9457 §3.1.2).
37    pub title: String,
38    /// HTTP status code (RFC 9457 §3.1.3).
39    pub status: u16,
40    /// Human-readable explanation specific to this occurrence (RFC 9457 §3.1.4).
41    pub detail: String,
42    /// URI reference identifying this specific occurrence (RFC 9457 §3.1.5).
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub instance: Option<String>,
45    /// Machine-readable error code (rskit extension).
46    pub code: ErrorCode,
47    /// Whether the operation that produced this error is safe to retry (rskit extension).
48    pub retryable: bool,
49    /// Arbitrary key-value pairs providing additional context (rskit extension).
50    #[serde(skip_serializing_if = "HashMap::is_empty", default)]
51    pub details: HashMap<String, serde_json::Value>,
52}
53
54/// Convert an `ErrorCode`'s `SCREAMING_SNAKE_CASE` representation to `kebab-case`.
55fn code_to_kebab(code: ErrorCode) -> String {
56    code.as_str().to_ascii_lowercase().replace('_', "-")
57}
58
59/// Convert an `ErrorCode`'s `SCREAMING_SNAKE_CASE` representation to `Title Case`.
60fn code_to_title(code: ErrorCode) -> String {
61    code.as_str()
62        .split('_')
63        .map(|word| {
64            let mut chars = word.chars();
65            match chars.next() {
66                None => String::new(),
67                Some(first) => {
68                    let rest: String = chars.map(|c| c.to_ascii_lowercase()).collect();
69                    format!("{}{}", first.to_ascii_uppercase(), rest)
70                }
71            }
72        })
73        .collect::<Vec<_>>()
74        .join(" ")
75}
76
77impl From<&AppError> for ProblemDetail {
78    fn from(err: &AppError) -> Self {
79        Self {
80            error_type: format!("{}{}", type_base_uri(), code_to_kebab(err.code())),
81            title: code_to_title(err.code()),
82            status: err.http_status().as_u16(),
83            detail: err.message().to_owned(),
84            instance: None,
85            code: err.code(),
86            retryable: err.is_retryable(),
87            details: err.details().clone(),
88        }
89    }
90}
91
92impl From<AppError> for ProblemDetail {
93    fn from(err: AppError) -> Self {
94        ProblemDetail::from(&err)
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use crate::{AppError, ErrorCode};
102
103    // ── code_to_kebab ─────────────────────────────────────────────────────
104
105    #[test]
106    fn kebab_not_found() {
107        assert_eq!(code_to_kebab(ErrorCode::NotFound), "not-found");
108    }
109
110    #[test]
111    fn kebab_service_unavailable() {
112        assert_eq!(
113            code_to_kebab(ErrorCode::ServiceUnavailable),
114            "service-unavailable"
115        );
116    }
117
118    #[test]
119    fn kebab_internal() {
120        assert_eq!(code_to_kebab(ErrorCode::Internal), "internal-error");
121    }
122
123    // ── code_to_title ─────────────────────────────────────────────────────
124
125    #[test]
126    fn title_not_found() {
127        assert_eq!(code_to_title(ErrorCode::NotFound), "Not Found");
128    }
129
130    #[test]
131    fn title_service_unavailable() {
132        assert_eq!(
133            code_to_title(ErrorCode::ServiceUnavailable),
134            "Service Unavailable"
135        );
136    }
137
138    #[test]
139    fn title_internal() {
140        assert_eq!(code_to_title(ErrorCode::Internal), "Internal Error");
141    }
142
143    // ── ProblemDetail::from(&AppError) ────────────────────────────────────
144
145    #[test]
146    fn from_app_error_sets_all_fields() {
147        let err = AppError::new(ErrorCode::NotFound, "item not found");
148        let pd = ProblemDetail::from(&err);
149        assert_eq!(pd.status, 404);
150        assert_eq!(pd.detail, "item not found");
151        assert_eq!(pd.title, "Not Found");
152        assert_eq!(pd.code, ErrorCode::NotFound);
153        assert!(!pd.retryable);
154        assert!(pd.instance.is_none());
155        assert!(pd.details.is_empty());
156    }
157
158    #[test]
159    fn from_app_error_type_uri_uses_base_and_kebab() {
160        let err = AppError::new(ErrorCode::NotFound, "gone");
161        let pd = ProblemDetail::from(&err);
162        assert!(
163            pd.error_type.ends_with("not-found"),
164            "type was: {}",
165            pd.error_type
166        );
167        assert!(
168            pd.error_type.starts_with("https://"),
169            "type was: {}",
170            pd.error_type
171        );
172    }
173
174    #[test]
175    fn from_app_error_retryable_flag() {
176        let err = AppError::new(ErrorCode::ServiceUnavailable, "down");
177        let pd = ProblemDetail::from(&err);
178        assert!(pd.retryable);
179    }
180
181    #[test]
182    fn from_app_error_preserves_details() {
183        let err = AppError::new(ErrorCode::InvalidInput, "bad field").with_detail("field", "email");
184        let pd = ProblemDetail::from(&err);
185        assert_eq!(
186            pd.details.get("field").and_then(|v| v.as_str()),
187            Some("email")
188        );
189    }
190
191    #[test]
192    fn from_owned_app_error() {
193        let err = AppError::new(ErrorCode::Unauthorized, "bad token");
194        let pd = ProblemDetail::from(err);
195        assert_eq!(pd.status, 401);
196        assert_eq!(pd.code, ErrorCode::Unauthorized);
197    }
198
199    // ── Serialisation ─────────────────────────────────────────────────────
200
201    #[test]
202    fn serializes_to_rfc9457_shape() {
203        let err = AppError::new(ErrorCode::NotFound, "item not found");
204        let pd = ProblemDetail::from(&err);
205        let json = serde_json::to_value(&pd).unwrap();
206
207        assert!(json.get("type").is_some());
208        assert_eq!(json["title"], "Not Found");
209        assert_eq!(json["status"], 404);
210        assert_eq!(json["detail"], "item not found");
211        assert_eq!(json["code"], "NOT_FOUND");
212        assert_eq!(json["retryable"], false);
213        assert!(json.get("instance").is_none(), "instance should be absent");
214        assert!(
215            json.get("details").is_none(),
216            "empty details should be absent"
217        );
218    }
219
220    #[test]
221    fn serializes_instance_when_set() {
222        let mut pd = ProblemDetail::from(&AppError::new(ErrorCode::NotFound, "gone"));
223        pd.instance = Some("/api/users/42".into());
224        let json = serde_json::to_value(&pd).unwrap();
225        assert_eq!(json["instance"], "/api/users/42");
226    }
227
228    #[test]
229    fn json_roundtrip() {
230        let err = AppError::new(ErrorCode::Forbidden, "not allowed")
231            .with_detail("resource", "admin_panel");
232        let pd = ProblemDetail::from(&err);
233        let json_str = serde_json::to_string(&pd).unwrap();
234        let back: ProblemDetail = serde_json::from_str(&json_str).unwrap();
235
236        assert_eq!(back.code, pd.code);
237        assert_eq!(back.detail, pd.detail);
238        assert_eq!(back.retryable, pd.retryable);
239        assert_eq!(back.details, pd.details);
240        assert_eq!(back.error_type, pd.error_type);
241        assert_eq!(back.title, pd.title);
242        assert_eq!(back.status, pd.status);
243    }
244
245    #[test]
246    fn json_roundtrip_with_instance_and_details() {
247        let mut pd = ProblemDetail::from(&AppError::new(ErrorCode::Internal, "err"));
248        pd.instance = Some("/api/v1/users/42".into());
249        pd.details
250            .insert("trace_id".into(), serde_json::json!("abc-123"));
251
252        let json_str = serde_json::to_string(&pd).unwrap();
253        let back: ProblemDetail = serde_json::from_str(&json_str).unwrap();
254
255        assert_eq!(
256            back.details.get("trace_id").and_then(|v| v.as_str()),
257            Some("abc-123")
258        );
259        assert_eq!(back.instance.as_deref(), Some("/api/v1/users/42"));
260    }
261
262    #[test]
263    fn deserialize_from_raw_json() {
264        let raw = r#"{
265            "type": "https://rskit.dev/errors/not-found",
266            "title": "Not Found",
267            "status": 404,
268            "detail": "user not found",
269            "code": "NOT_FOUND",
270            "retryable": false
271        }"#;
272        let pd: ProblemDetail = serde_json::from_str(raw).unwrap();
273        assert_eq!(pd.status, 404);
274        assert_eq!(pd.title, "Not Found");
275        assert_eq!(pd.detail, "user not found");
276        assert!(pd.details.is_empty());
277        assert!(pd.instance.is_none());
278    }
279
280    // ── type_base_uri ─────────────────────────────────────────────────────
281
282    #[test]
283    fn type_base_uri_default() {
284        let uri = type_base_uri();
285        assert!(uri.starts_with("https://"), "uri: {uri}");
286        assert!(uri.ends_with('/'), "uri: {uri}");
287    }
288
289    #[test]
290    fn error_type_uri_all_codes() {
291        let cases: &[(ErrorCode, &str)] = &[
292            (ErrorCode::ServiceUnavailable, "service-unavailable"),
293            (ErrorCode::ConnectionFailed, "connection-failed"),
294            (ErrorCode::TokenExpired, "token-expired"),
295            (ErrorCode::InvalidInput, "invalid-input"),
296            (ErrorCode::DatabaseError, "database-error"),
297            (ErrorCode::ExternalService, "external-service-error"),
298        ];
299        for (code, expected_slug) in cases {
300            let err = AppError::new(*code, "test");
301            let pd = ProblemDetail::from(&err);
302            assert!(
303                pd.error_type.ends_with(expected_slug),
304                "URI for {:?} was: {}",
305                code,
306                pd.error_type
307            );
308        }
309    }
310}