use crate::errors::CqrsError;
use serde::{Deserialize, Serialize};
use std::sync::OnceLock;
#[cfg(feature = "utoipa")]
use utoipa::ToSchema;
pub const PROBLEM_JSON: &str = "application/problem+json";
static TYPE_BASE_URI: OnceLock<String> = OnceLock::new();
pub fn set_problem_type_base_uri(base: impl Into<String>) -> Result<(), &'static str> {
TYPE_BASE_URI
.set(base.into().trim_end_matches('/').to_string())
.map_err(|_| "problem type base URI is already set")
}
#[must_use]
pub fn problem_type_base_uri() -> Option<&'static str> {
TYPE_BASE_URI.get().map(String::as_str)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct ProblemDetails {
#[serde(rename = "type")]
pub type_uri: String,
pub title: String,
pub status: u16,
pub detail: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub instance: Option<String>,
pub domain: String,
pub code: String,
pub internal_code: u16,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
}
impl From<&CqrsError> for ProblemDetails {
fn from(err: &CqrsError) -> Self {
let request_id = err.request_id.clone().filter(|id| !id.is_empty());
Self {
type_uri: problem_type_uri(err),
title: err.code.clone(),
status: err.http_status().as_u16(),
detail: err.message.clone(),
instance: request_id
.as_ref()
.map(|id| format!("urn:cqrs-request:{id}")),
domain: err.domain.clone(),
code: err.code.clone(),
internal_code: err.internal_code,
details: err.details.clone(),
request_id,
}
}
}
impl From<CqrsError> for ProblemDetails {
fn from(err: CqrsError) -> Self {
Self::from(&err)
}
}
fn problem_type_uri(err: &CqrsError) -> String {
if let Some(uri) = err.type_uri.as_deref() {
return uri.to_string();
}
match problem_type_base_uri() {
Some(base) => format!("{base}/{}", err.code),
None => format!("urn:cqrs-error:{}:{}", err.domain, err.code),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::errors::GenericErrorCode;
use crate::CqrsErrorCode;
#[test]
fn maps_every_rfc_member() {
let err = GenericErrorCode::NotFound
.error("User 'abc' not found")
.with_details(serde_json::json!({ "id": "abc" }))
.with_request_id("req-123");
let problem = ProblemDetails::from(&err);
assert_eq!(problem.type_uri, "urn:cqrs-error:generic:GENERIC_NOT_FOUND");
assert_eq!(problem.title, "GENERIC_NOT_FOUND");
assert_eq!(problem.status, 404);
assert_eq!(problem.detail, "User 'abc' not found");
assert_eq!(
problem.instance.as_deref(),
Some("urn:cqrs-request:req-123")
);
assert_eq!(problem.domain, "generic");
assert_eq!(problem.internal_code, 1002);
assert_eq!(problem.details.unwrap()["id"], "abc");
assert_eq!(problem.request_id.as_deref(), Some("req-123"));
}
#[test]
fn omits_instance_without_request_id() {
let problem = ProblemDetails::from(&CqrsError::conflict("boom"));
assert!(problem.instance.is_none());
assert!(problem.request_id.is_none());
}
#[test]
fn empty_request_id_is_not_reported() {
let err = CqrsError::conflict("boom").with_request_id("");
let problem = ProblemDetails::from(&err);
assert!(problem.instance.is_none());
assert!(problem.request_id.is_none());
}
#[test]
fn per_error_type_uri_wins() {
let err = CqrsError::validation("nope").with_type_uri("https://errors.example.com/nope");
assert_eq!(
ProblemDetails::from(&err).type_uri,
"https://errors.example.com/nope"
);
}
#[test]
fn serializes_with_rfc_member_names() {
let err = CqrsError::from_status(http::StatusCode::TOO_MANY_REQUESTS, "slow down");
let json = serde_json::to_value(ProblemDetails::from(&err)).unwrap();
assert_eq!(
json["type"],
"urn:cqrs-error:generic:GENERIC_TOO_MANY_REQUESTS"
);
assert_eq!(json["status"], 429);
assert_eq!(json["detail"], "slow down");
assert_eq!(json["internalCode"], 1429);
assert!(json.get("message").is_none());
assert!(json.get("instance").is_none());
}
}