Skip to main content

cqrs_rust_lib/
problem.rs

1//! RFC 9457 "Problem Details for HTTP APIs".
2//!
3//! [`ProblemDetails`] is the wire format for [`CqrsError`] when the
4//! `problem-json` feature is enabled: responses are served as
5//! `application/problem+json` with the standard members (`type`, `title`,
6//! `status`, `detail`, `instance`) plus the CQRS-specific extension members
7//! (`domain`, `code`, `internalCode`, `details`, `requestId`).
8//!
9//! ```json
10//! {
11//!   "type": "urn:cqrs-error:account:ACCOUNT_INSUFFICIENT_FUNDS",
12//!   "title": "ACCOUNT_INSUFFICIENT_FUNDS",
13//!   "status": 400,
14//!   "detail": "Cannot withdraw 500, balance is 200",
15//!   "instance": "urn:cqrs-request:req-123",
16//!   "domain": "account",
17//!   "code": "ACCOUNT_INSUFFICIENT_FUNDS",
18//!   "internalCode": 10001,
19//!   "requestId": "req-123"
20//! }
21//! ```
22//!
23//! The conversion is always available, even without the feature, so an
24//! application can render problem documents on its own routes.
25
26use crate::errors::CqrsError;
27use serde::{Deserialize, Serialize};
28use std::sync::OnceLock;
29
30#[cfg(feature = "utoipa")]
31use utoipa::ToSchema;
32
33/// Media type of a problem document (RFC 9457 §3).
34pub const PROBLEM_JSON: &str = "application/problem+json";
35
36static TYPE_BASE_URI: OnceLock<String> = OnceLock::new();
37
38/// Sets the base URI used to build the `type` member of problem documents:
39/// the resulting URI is `{base}/{code}` (e.g.
40/// `https://api.example.com/errors/ACCOUNT_INSUFFICIENT_FUNDS`).
41///
42/// Call once at startup, before serving requests. Returns `Err` with the
43/// already-configured base if it was set before. Without a base, and unless the
44/// error carries its own URI via [`CqrsError::with_type_uri`], `type` falls back
45/// to `urn:cqrs-error:{domain}:{code}`.
46pub 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/// Returns the configured base URI, if any.
53#[must_use]
54pub fn problem_type_base_uri() -> Option<&'static str> {
55    TYPE_BASE_URI.get().map(String::as_str)
56}
57
58/// An RFC 9457 problem document.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60#[cfg_attr(feature = "utoipa", derive(ToSchema))]
61#[serde(rename_all = "camelCase")]
62pub struct ProblemDetails {
63    /// URI identifying the problem type.
64    #[serde(rename = "type")]
65    pub type_uri: String,
66
67    /// Short, human-readable summary of the problem type. Stable per type.
68    pub title: String,
69
70    /// HTTP status code of the response.
71    pub status: u16,
72
73    /// Human-readable explanation specific to this occurrence.
74    pub detail: String,
75
76    /// URI identifying this specific occurrence.
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub instance: Option<String>,
79
80    // ── extension members ────────────────────────────────────────────────────
81    /// Domain the error originated from (e.g. `"account"`).
82    pub domain: String,
83
84    /// Error code as string (e.g. `"ACCOUNT_INSUFFICIENT_FUNDS"`).
85    pub code: String,
86
87    /// Internal code for support/debugging (e.g. `10001`).
88    pub internal_code: u16,
89
90    /// Additional context.
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub details: Option<serde_json::Value>,
93
94    /// Request ID for tracing.
95    #[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        // An empty request id carries no information — treat it as absent.
102        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}