use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde::Serialize;
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
#[schema(example = json!({
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "No route matched: /unknown",
"instance": "/unknown"
}))]
pub struct ProblemDetail {
#[serde(rename = "type")]
pub type_uri: String,
pub title: String,
pub status: u16,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub instance: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_code: Option<&'static str>,
}
impl ProblemDetail {
pub fn from_status(status: StatusCode) -> Self {
Self {
type_uri: "about:blank".to_string(),
title: status.canonical_reason().unwrap_or("Unknown Error").to_string(),
status: status.as_u16(),
detail: None,
instance: None,
error_code: None,
}
}
#[must_use]
pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
self.detail = Some(detail.into());
self
}
#[must_use]
pub fn with_instance(mut self, instance: impl Into<String>) -> Self {
self.instance = Some(instance.into());
self
}
#[must_use]
pub fn with_error_code(mut self, code: &'static str) -> Self {
self.error_code = Some(code);
self
}
}
impl IntoResponse for ProblemDetail {
fn into_response(self) -> Response {
let status = StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
let body = serde_json::to_string(&self)
.unwrap_or_else(|_| r#"{"type":"about:blank","title":"Internal Server Error","status":500}"#.to_string());
(
status,
[(axum::http::header::CONTENT_TYPE, "application/problem+json")],
body,
)
.into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn error_code_is_omitted_when_unset() {
let problem = ProblemDetail::from_status(StatusCode::NOT_FOUND).with_detail("missing");
let v: serde_json::Value = serde_json::to_value(&problem).unwrap();
assert!(
v.get("error_code").is_none(),
"unset error_code must not appear on the wire"
);
}
#[test]
fn error_code_is_serialized_when_set() {
let problem = ProblemDetail::from_status(StatusCode::CONFLICT)
.with_detail("duplicate")
.with_error_code("rule_name_conflict");
let v: serde_json::Value = serde_json::to_value(&problem).unwrap();
assert_eq!(v["error_code"], "rule_name_conflict");
assert_eq!(v["status"], 409);
}
}