use http::StatusCode;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::Value;
#[cfg(feature = "utoipa")]
use utoipa::ToSchema;
pub const APPLICATION_PROBLEM_JSON: &str = "application/problem+json";
#[allow(clippy::trivially_copy_pass_by_ref)] fn serialize_status_code<S>(status: &StatusCode, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_u16(status.as_u16())
}
fn deserialize_status_code<'de, D>(deserializer: D) -> Result<StatusCode, D::Error>
where
D: Deserializer<'de>,
{
let code = u16::deserialize(deserializer)?;
StatusCode::from_u16(code).map_err(serde::de::Error::custom)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(ToSchema))]
#[cfg_attr(
feature = "utoipa",
schema(
title = "Problem",
description = "RFC 9457 Problem Details for HTTP APIs"
)
)]
#[must_use]
pub struct Problem {
#[serde(rename = "type")]
pub type_url: String,
pub title: String,
#[serde(
serialize_with = "serialize_status_code",
deserialize_with = "deserialize_status_code"
)]
#[cfg_attr(feature = "utoipa", schema(value_type = u16))]
pub status: StatusCode,
pub detail: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub instance: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub code: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trace_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub errors: Option<Vec<ValidationViolation>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(ToSchema))]
#[cfg_attr(feature = "utoipa", schema(title = "ValidationViolation"))]
pub struct ValidationViolation {
pub field: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub code: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(ToSchema))]
#[cfg_attr(feature = "utoipa", schema(title = "ValidationError"))]
pub struct ValidationError {
pub errors: Vec<ValidationViolation>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(ToSchema))]
#[cfg_attr(feature = "utoipa", schema(title = "ValidationErrorResponse"))]
pub struct ValidationErrorResponse {
#[serde(flatten)]
pub validation: ValidationError,
}
impl Problem {
pub fn new(status: StatusCode, title: impl Into<String>, detail: impl Into<String>) -> Self {
Self {
type_url: "about:blank".to_owned(),
title: title.into(),
status,
detail: detail.into(),
instance: String::new(),
code: String::new(),
trace_id: None,
errors: None,
context: None,
}
}
pub fn with_type(mut self, type_url: impl Into<String>) -> Self {
self.type_url = type_url.into();
self
}
pub fn with_instance(mut self, uri: impl Into<String>) -> Self {
self.instance = uri.into();
self
}
pub fn with_code(mut self, code: impl Into<String>) -> Self {
self.code = code.into();
self
}
pub fn with_trace_id(mut self, id: impl Into<String>) -> Self {
self.trace_id = Some(id.into());
self
}
pub fn with_errors(mut self, errors: Vec<ValidationViolation>) -> Self {
self.errors = Some(errors);
self
}
pub fn with_context(mut self, context: Value) -> Self {
self.context = Some(context);
self
}
}
#[cfg(feature = "axum")]
impl axum::response::IntoResponse for Problem {
fn into_response(self) -> axum::response::Response {
use axum::http::HeaderValue;
let problem = if self.trace_id.is_none() {
match tracing::Span::current().id() {
Some(span_id) => self.with_trace_id(span_id.into_u64().to_string()),
_ => self,
}
} else {
self
};
let status = problem.status;
let mut resp = axum::Json(problem).into_response();
*resp.status_mut() = status;
resp.headers_mut().insert(
axum::http::header::CONTENT_TYPE,
HeaderValue::from_static(APPLICATION_PROBLEM_JSON),
);
resp
}
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::*;
#[test]
fn problem_builder_pattern() {
let p = Problem::new(
StatusCode::UNPROCESSABLE_ENTITY,
"Validation Failed",
"Input validation errors",
)
.with_code("VALIDATION_ERROR")
.with_instance("/users/123")
.with_trace_id("req-456")
.with_errors(vec![ValidationViolation {
message: "Email is required".to_owned(),
field: "email".to_owned(),
code: None,
}]);
assert_eq!(p.status, StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(p.code, "VALIDATION_ERROR");
assert_eq!(p.instance, "/users/123");
assert_eq!(p.trace_id, Some("req-456".to_owned()));
assert!(p.errors.is_some());
assert_eq!(p.errors.as_ref().unwrap().len(), 1);
}
#[test]
fn problem_serializes_status_as_u16() {
let p = Problem::new(StatusCode::NOT_FOUND, "Not Found", "Resource not found");
let json = serde_json::to_string(&p).unwrap();
assert!(json.contains("\"status\":404"));
}
#[test]
fn problem_deserializes_status_from_u16() {
let json = r#"{"type":"about:blank","title":"Not Found","status":404,"detail":"Resource not found"}"#;
let p: Problem = serde_json::from_str(json).unwrap();
assert_eq!(p.status, StatusCode::NOT_FOUND);
}
}