1use axum::http::StatusCode;
4use axum::response::{IntoResponse, Response};
5use serde::Serialize;
6
7#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
9#[schema(example = json!({
10 "type": "about:blank",
11 "title": "Not Found",
12 "status": 404,
13 "detail": "No route matched: /unknown",
14 "instance": "/unknown"
15}))]
16pub struct ProblemDetail {
17 #[serde(rename = "type")]
19 pub type_uri: String,
20 pub title: String,
22 pub status: u16,
24 #[serde(skip_serializing_if = "Option::is_none")]
26 pub detail: Option<String>,
27 #[serde(skip_serializing_if = "Option::is_none")]
29 pub instance: Option<String>,
30 #[serde(skip_serializing_if = "Option::is_none")]
39 pub error_code: Option<&'static str>,
40}
41
42impl ProblemDetail {
43 pub fn from_status(status: StatusCode) -> Self {
45 Self {
46 type_uri: "about:blank".to_string(),
47 title: status.canonical_reason().unwrap_or("Unknown Error").to_string(),
48 status: status.as_u16(),
49 detail: None,
50 instance: None,
51 error_code: None,
52 }
53 }
54
55 #[must_use]
57 pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
58 self.detail = Some(detail.into());
59 self
60 }
61
62 #[must_use]
64 pub fn with_instance(mut self, instance: impl Into<String>) -> Self {
65 self.instance = Some(instance.into());
66 self
67 }
68
69 #[must_use]
71 pub fn with_error_code(mut self, code: &'static str) -> Self {
72 self.error_code = Some(code);
73 self
74 }
75}
76
77impl IntoResponse for ProblemDetail {
78 fn into_response(self) -> Response {
79 let status = StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
80 let body = serde_json::to_string(&self)
81 .unwrap_or_else(|_| r#"{"type":"about:blank","title":"Internal Server Error","status":500}"#.to_string());
82
83 (
84 status,
85 [(axum::http::header::CONTENT_TYPE, "application/problem+json")],
86 body,
87 )
88 .into_response()
89 }
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95
96 #[test]
97 fn error_code_is_omitted_when_unset() {
98 let problem = ProblemDetail::from_status(StatusCode::NOT_FOUND).with_detail("missing");
99 let v: serde_json::Value = serde_json::to_value(&problem).unwrap();
100 assert!(
101 v.get("error_code").is_none(),
102 "unset error_code must not appear on the wire"
103 );
104 }
105
106 #[test]
107 fn error_code_is_serialized_when_set() {
108 let problem = ProblemDetail::from_status(StatusCode::CONFLICT)
109 .with_detail("duplicate")
110 .with_error_code("rule_name_conflict");
111 let v: serde_json::Value = serde_json::to_value(&problem).unwrap();
112 assert_eq!(v["error_code"], "rule_name_conflict");
113 assert_eq!(v["status"], 409);
114 }
115}