1use crate::errors::CqrsError;
27use serde::{Deserialize, Serialize};
28use std::sync::OnceLock;
29
30#[cfg(feature = "utoipa")]
31use utoipa::ToSchema;
32
33pub const PROBLEM_JSON: &str = "application/problem+json";
35
36static TYPE_BASE_URI: OnceLock<String> = OnceLock::new();
37
38pub fn set_problem_type_base_uri(base: impl Into<String>) -> Result<(), &'static str> {
47 TYPE_BASE_URI
48 .set(base.into().trim_end_matches('/').to_string())
49 .map_err(|_| "problem type base URI is already set")
50}
51
52#[must_use]
54pub fn problem_type_base_uri() -> Option<&'static str> {
55 TYPE_BASE_URI.get().map(String::as_str)
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
60#[cfg_attr(feature = "utoipa", derive(ToSchema))]
61#[serde(rename_all = "camelCase")]
62pub struct ProblemDetails {
63 #[serde(rename = "type")]
65 pub type_uri: String,
66
67 pub title: String,
69
70 pub status: u16,
72
73 pub detail: String,
75
76 #[serde(skip_serializing_if = "Option::is_none")]
78 pub instance: Option<String>,
79
80 pub domain: String,
83
84 pub code: String,
86
87 pub internal_code: u16,
89
90 #[serde(skip_serializing_if = "Option::is_none")]
92 pub details: Option<serde_json::Value>,
93
94 #[serde(skip_serializing_if = "Option::is_none")]
96 pub request_id: Option<String>,
97}
98
99impl From<&CqrsError> for ProblemDetails {
100 fn from(err: &CqrsError) -> Self {
101 let request_id = err.request_id.clone().filter(|id| !id.is_empty());
103
104 Self {
105 type_uri: problem_type_uri(err),
106 title: err.code.clone(),
107 status: err.http_status().as_u16(),
108 detail: err.message.clone(),
109 instance: request_id
110 .as_ref()
111 .map(|id| format!("urn:cqrs-request:{id}")),
112 domain: err.domain.clone(),
113 code: err.code.clone(),
114 internal_code: err.internal_code,
115 details: err.details.clone(),
116 request_id,
117 }
118 }
119}
120
121impl From<CqrsError> for ProblemDetails {
122 fn from(err: CqrsError) -> Self {
123 Self::from(&err)
124 }
125}
126
127fn problem_type_uri(err: &CqrsError) -> String {
128 if let Some(uri) = err.type_uri.as_deref() {
129 return uri.to_string();
130 }
131 match problem_type_base_uri() {
132 Some(base) => format!("{base}/{}", err.code),
133 None => format!("urn:cqrs-error:{}:{}", err.domain, err.code),
134 }
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140 use crate::errors::GenericErrorCode;
141 use crate::CqrsErrorCode;
142
143 #[test]
144 fn maps_every_rfc_member() {
145 let err = GenericErrorCode::NotFound
146 .error("User 'abc' not found")
147 .with_details(serde_json::json!({ "id": "abc" }))
148 .with_request_id("req-123");
149 let problem = ProblemDetails::from(&err);
150
151 assert_eq!(problem.type_uri, "urn:cqrs-error:generic:GENERIC_NOT_FOUND");
152 assert_eq!(problem.title, "GENERIC_NOT_FOUND");
153 assert_eq!(problem.status, 404);
154 assert_eq!(problem.detail, "User 'abc' not found");
155 assert_eq!(
156 problem.instance.as_deref(),
157 Some("urn:cqrs-request:req-123")
158 );
159 assert_eq!(problem.domain, "generic");
160 assert_eq!(problem.internal_code, 1002);
161 assert_eq!(problem.details.unwrap()["id"], "abc");
162 assert_eq!(problem.request_id.as_deref(), Some("req-123"));
163 }
164
165 #[test]
166 fn omits_instance_without_request_id() {
167 let problem = ProblemDetails::from(&CqrsError::conflict("boom"));
168 assert!(problem.instance.is_none());
169 assert!(problem.request_id.is_none());
170 }
171
172 #[test]
173 fn empty_request_id_is_not_reported() {
174 let err = CqrsError::conflict("boom").with_request_id("");
175 let problem = ProblemDetails::from(&err);
176 assert!(problem.instance.is_none());
177 assert!(problem.request_id.is_none());
178 }
179
180 #[test]
181 fn per_error_type_uri_wins() {
182 let err = CqrsError::validation("nope").with_type_uri("https://errors.example.com/nope");
183 assert_eq!(
184 ProblemDetails::from(&err).type_uri,
185 "https://errors.example.com/nope"
186 );
187 }
188
189 #[test]
190 fn serializes_with_rfc_member_names() {
191 let err = CqrsError::from_status(http::StatusCode::TOO_MANY_REQUESTS, "slow down");
192 let json = serde_json::to_value(ProblemDetails::from(&err)).unwrap();
193
194 assert_eq!(
195 json["type"],
196 "urn:cqrs-error:generic:GENERIC_TOO_MANY_REQUESTS"
197 );
198 assert_eq!(json["status"], 429);
199 assert_eq!(json["detail"], "slow down");
200 assert_eq!(json["internalCode"], 1429);
201 assert!(json.get("message").is_none());
202 assert!(json.get("instance").is_none());
203 }
204}