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
10pub const fn type_base_uri() -> &'static str {
12 DEFAULT_TYPE_BASE_URI
13}
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct ProblemDetail {
33 #[serde(rename = "type")]
35 pub error_type: String,
36 pub title: String,
38 pub status: u16,
40 pub detail: String,
42 #[serde(skip_serializing_if = "Option::is_none")]
44 pub instance: Option<String>,
45 pub code: ErrorCode,
47 pub retryable: bool,
49 #[serde(skip_serializing_if = "HashMap::is_empty", default)]
51 pub details: HashMap<String, serde_json::Value>,
52}
53
54fn code_to_kebab(code: ErrorCode) -> String {
56 code.as_str().to_ascii_lowercase().replace('_', "-")
57}
58
59fn 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 #[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 #[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 #[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 #[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 #[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}