use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::AppError;
use crate::code::ErrorCode;
const DEFAULT_TYPE_BASE_URI: &str = "https://rskit.dev/errors/";
pub const fn type_base_uri() -> &'static str {
DEFAULT_TYPE_BASE_URI
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProblemDetail {
#[serde(rename = "type")]
pub error_type: String,
pub title: String,
pub status: u16,
pub detail: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub instance: Option<String>,
pub code: ErrorCode,
pub retryable: bool,
#[serde(skip_serializing_if = "HashMap::is_empty", default)]
pub details: HashMap<String, serde_json::Value>,
}
fn code_to_kebab(code: ErrorCode) -> String {
code.as_str().to_ascii_lowercase().replace('_', "-")
}
fn code_to_title(code: ErrorCode) -> String {
code.as_str()
.split('_')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => {
let rest: String = chars.map(|c| c.to_ascii_lowercase()).collect();
format!("{}{}", first.to_ascii_uppercase(), rest)
}
}
})
.collect::<Vec<_>>()
.join(" ")
}
impl From<&AppError> for ProblemDetail {
fn from(err: &AppError) -> Self {
Self {
error_type: format!("{}{}", type_base_uri(), code_to_kebab(err.code())),
title: code_to_title(err.code()),
status: err.http_status().as_u16(),
detail: err.message().to_owned(),
instance: None,
code: err.code(),
retryable: err.is_retryable(),
details: err.details().clone(),
}
}
}
impl From<AppError> for ProblemDetail {
fn from(err: AppError) -> Self {
ProblemDetail::from(&err)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{AppError, ErrorCode};
#[test]
fn kebab_not_found() {
assert_eq!(code_to_kebab(ErrorCode::NotFound), "not-found");
}
#[test]
fn kebab_service_unavailable() {
assert_eq!(
code_to_kebab(ErrorCode::ServiceUnavailable),
"service-unavailable"
);
}
#[test]
fn kebab_internal() {
assert_eq!(code_to_kebab(ErrorCode::Internal), "internal-error");
}
#[test]
fn title_not_found() {
assert_eq!(code_to_title(ErrorCode::NotFound), "Not Found");
}
#[test]
fn title_service_unavailable() {
assert_eq!(
code_to_title(ErrorCode::ServiceUnavailable),
"Service Unavailable"
);
}
#[test]
fn title_internal() {
assert_eq!(code_to_title(ErrorCode::Internal), "Internal Error");
}
#[test]
fn from_app_error_sets_all_fields() {
let err = AppError::new(ErrorCode::NotFound, "item not found");
let pd = ProblemDetail::from(&err);
assert_eq!(pd.status, 404);
assert_eq!(pd.detail, "item not found");
assert_eq!(pd.title, "Not Found");
assert_eq!(pd.code, ErrorCode::NotFound);
assert!(!pd.retryable);
assert!(pd.instance.is_none());
assert!(pd.details.is_empty());
}
#[test]
fn from_app_error_type_uri_uses_base_and_kebab() {
let err = AppError::new(ErrorCode::NotFound, "gone");
let pd = ProblemDetail::from(&err);
assert!(
pd.error_type.ends_with("not-found"),
"type was: {}",
pd.error_type
);
assert!(
pd.error_type.starts_with("https://"),
"type was: {}",
pd.error_type
);
}
#[test]
fn from_app_error_retryable_flag() {
let err = AppError::new(ErrorCode::ServiceUnavailable, "down");
let pd = ProblemDetail::from(&err);
assert!(pd.retryable);
}
#[test]
fn from_app_error_preserves_details() {
let err = AppError::new(ErrorCode::InvalidInput, "bad field").with_detail("field", "email");
let pd = ProblemDetail::from(&err);
assert_eq!(
pd.details.get("field").and_then(|v| v.as_str()),
Some("email")
);
}
#[test]
fn from_owned_app_error() {
let err = AppError::new(ErrorCode::Unauthorized, "bad token");
let pd = ProblemDetail::from(err);
assert_eq!(pd.status, 401);
assert_eq!(pd.code, ErrorCode::Unauthorized);
}
#[test]
fn serializes_to_rfc9457_shape() {
let err = AppError::new(ErrorCode::NotFound, "item not found");
let pd = ProblemDetail::from(&err);
let json = serde_json::to_value(&pd).unwrap();
assert!(json.get("type").is_some());
assert_eq!(json["title"], "Not Found");
assert_eq!(json["status"], 404);
assert_eq!(json["detail"], "item not found");
assert_eq!(json["code"], "NOT_FOUND");
assert_eq!(json["retryable"], false);
assert!(json.get("instance").is_none(), "instance should be absent");
assert!(
json.get("details").is_none(),
"empty details should be absent"
);
}
#[test]
fn serializes_instance_when_set() {
let mut pd = ProblemDetail::from(&AppError::new(ErrorCode::NotFound, "gone"));
pd.instance = Some("/api/users/42".into());
let json = serde_json::to_value(&pd).unwrap();
assert_eq!(json["instance"], "/api/users/42");
}
#[test]
fn json_roundtrip() {
let err = AppError::new(ErrorCode::Forbidden, "not allowed")
.with_detail("resource", "admin_panel");
let pd = ProblemDetail::from(&err);
let json_str = serde_json::to_string(&pd).unwrap();
let back: ProblemDetail = serde_json::from_str(&json_str).unwrap();
assert_eq!(back.code, pd.code);
assert_eq!(back.detail, pd.detail);
assert_eq!(back.retryable, pd.retryable);
assert_eq!(back.details, pd.details);
assert_eq!(back.error_type, pd.error_type);
assert_eq!(back.title, pd.title);
assert_eq!(back.status, pd.status);
}
#[test]
fn json_roundtrip_with_instance_and_details() {
let mut pd = ProblemDetail::from(&AppError::new(ErrorCode::Internal, "err"));
pd.instance = Some("/api/v1/users/42".into());
pd.details
.insert("trace_id".into(), serde_json::json!("abc-123"));
let json_str = serde_json::to_string(&pd).unwrap();
let back: ProblemDetail = serde_json::from_str(&json_str).unwrap();
assert_eq!(
back.details.get("trace_id").and_then(|v| v.as_str()),
Some("abc-123")
);
assert_eq!(back.instance.as_deref(), Some("/api/v1/users/42"));
}
#[test]
fn deserialize_from_raw_json() {
let raw = r#"{
"type": "https://rskit.dev/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "user not found",
"code": "NOT_FOUND",
"retryable": false
}"#;
let pd: ProblemDetail = serde_json::from_str(raw).unwrap();
assert_eq!(pd.status, 404);
assert_eq!(pd.title, "Not Found");
assert_eq!(pd.detail, "user not found");
assert!(pd.details.is_empty());
assert!(pd.instance.is_none());
}
#[test]
fn type_base_uri_default() {
let uri = type_base_uri();
assert!(uri.starts_with("https://"), "uri: {uri}");
assert!(uri.ends_with('/'), "uri: {uri}");
}
#[test]
fn error_type_uri_all_codes() {
let cases: &[(ErrorCode, &str)] = &[
(ErrorCode::ServiceUnavailable, "service-unavailable"),
(ErrorCode::ConnectionFailed, "connection-failed"),
(ErrorCode::TokenExpired, "token-expired"),
(ErrorCode::InvalidInput, "invalid-input"),
(ErrorCode::DatabaseError, "database-error"),
(ErrorCode::ExternalService, "external-service-error"),
];
for (code, expected_slug) in cases {
let err = AppError::new(*code, "test");
let pd = ProblemDetail::from(&err);
assert!(
pd.error_type.ends_with(expected_slug),
"URI for {:?} was: {}",
code,
pd.error_type
);
}
}
}