Skip to main content

modkit_errors/
problem.rs

1//! RFC 9457 Problem Details for HTTP APIs (pure data model, no HTTP framework dependencies)
2
3use http::StatusCode;
4use serde::{Deserialize, Deserializer, Serialize, Serializer};
5
6#[cfg(feature = "utoipa")]
7use utoipa::ToSchema;
8
9/// Content type for Problem Details as per RFC 9457.
10pub const APPLICATION_PROBLEM_JSON: &str = "application/problem+json";
11
12/// Custom serializer for `StatusCode` to u16
13#[allow(clippy::trivially_copy_pass_by_ref)] // serde requires &T signature
14fn serialize_status_code<S>(status: &StatusCode, serializer: S) -> Result<S::Ok, S::Error>
15where
16    S: Serializer,
17{
18    serializer.serialize_u16(status.as_u16())
19}
20
21/// Custom deserializer for `StatusCode` from u16
22fn deserialize_status_code<'de, D>(deserializer: D) -> Result<StatusCode, D::Error>
23where
24    D: Deserializer<'de>,
25{
26    let code = u16::deserialize(deserializer)?;
27    StatusCode::from_u16(code).map_err(serde::de::Error::custom)
28}
29
30/// RFC 9457 Problem Details for HTTP APIs.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32#[cfg_attr(feature = "utoipa", derive(ToSchema))]
33#[cfg_attr(
34    feature = "utoipa",
35    schema(
36        title = "Problem",
37        description = "RFC 9457 Problem Details for HTTP APIs"
38    )
39)]
40#[must_use]
41pub struct Problem {
42    /// A URI reference that identifies the problem type.
43    /// When dereferenced, it might provide human-readable documentation.
44    #[serde(rename = "type")]
45    pub type_url: String,
46    /// A short, human-readable summary of the problem type.
47    pub title: String,
48    /// The HTTP status code for this occurrence of the problem.
49    /// Serializes as u16 for RFC 9457 compatibility.
50    #[serde(
51        serialize_with = "serialize_status_code",
52        deserialize_with = "deserialize_status_code"
53    )]
54    #[cfg_attr(feature = "utoipa", schema(value_type = u16))]
55    pub status: StatusCode,
56    /// A human-readable explanation specific to this occurrence of the problem.
57    pub detail: String,
58    /// A URI reference that identifies the specific occurrence of the problem.
59    pub instance: String,
60    /// Optional machine-readable error code defined by the application.
61    pub code: String,
62    /// Optional trace id useful for tracing.
63    pub trace_id: Option<String>,
64    /// Optional validation errors for 4xx problems.
65    pub errors: Option<Vec<ValidationViolation>>,
66}
67
68/// Individual validation violation for a specific field or property.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70#[cfg_attr(feature = "utoipa", derive(ToSchema))]
71#[cfg_attr(feature = "utoipa", schema(title = "ValidationViolation"))]
72pub struct ValidationViolation {
73    /// field path, e.g. "email" or "user.email"
74    pub field: String,
75    /// Human-readable message describing the validation error
76    pub message: String,
77    /// Optional machine-readable error code
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub code: Option<String>,
80}
81
82/// Collection of validation errors for 422 responses.
83#[derive(Debug, Clone, Serialize, Deserialize)]
84#[cfg_attr(feature = "utoipa", derive(ToSchema))]
85#[cfg_attr(feature = "utoipa", schema(title = "ValidationError"))]
86pub struct ValidationError {
87    /// List of individual validation violations
88    pub errors: Vec<ValidationViolation>,
89}
90
91/// Wrapper for `ValidationError` that can be used as a standalone response.
92#[derive(Debug, Clone, Serialize, Deserialize)]
93#[cfg_attr(feature = "utoipa", derive(ToSchema))]
94#[cfg_attr(feature = "utoipa", schema(title = "ValidationErrorResponse"))]
95pub struct ValidationErrorResponse {
96    /// The validation errors
97    #[serde(flatten)]
98    pub validation: ValidationError,
99}
100
101impl Problem {
102    /// Create a new Problem with the given status, title, and detail.
103    ///
104    /// Note: This function accepts `http::StatusCode` for type safety.
105    /// The status is serialized as `u16` for RFC 9457 compatibility.
106    pub fn new(status: StatusCode, title: impl Into<String>, detail: impl Into<String>) -> Self {
107        Self {
108            type_url: "about:blank".to_owned(),
109            title: title.into(),
110            status,
111            detail: detail.into(),
112            instance: String::new(),
113            code: String::new(),
114            trace_id: None,
115            errors: None,
116        }
117    }
118
119    pub fn with_type(mut self, type_url: impl Into<String>) -> Self {
120        self.type_url = type_url.into();
121        self
122    }
123
124    pub fn with_instance(mut self, uri: impl Into<String>) -> Self {
125        self.instance = uri.into();
126        self
127    }
128
129    pub fn with_code(mut self, code: impl Into<String>) -> Self {
130        self.code = code.into();
131        self
132    }
133
134    pub fn with_trace_id(mut self, id: impl Into<String>) -> Self {
135        self.trace_id = Some(id.into());
136        self
137    }
138
139    pub fn with_errors(mut self, errors: Vec<ValidationViolation>) -> Self {
140        self.errors = Some(errors);
141        self
142    }
143}
144
145/// Axum integration: make Problem directly usable as a response.
146///
147/// Automatically enriches the Problem with `trace_id` from the current
148/// tracing span if not already set.
149#[cfg(feature = "axum")]
150impl axum::response::IntoResponse for Problem {
151    fn into_response(self) -> axum::response::Response {
152        use axum::http::HeaderValue;
153
154        // Enrich with trace_id from current span if not already set
155        let problem = if self.trace_id.is_none() {
156            match tracing::Span::current().id() {
157                Some(span_id) => self.with_trace_id(span_id.into_u64().to_string()),
158                _ => self,
159            }
160        } else {
161            self
162        };
163
164        let status = problem.status;
165        let mut resp = axum::Json(problem).into_response();
166        *resp.status_mut() = status;
167        resp.headers_mut().insert(
168            axum::http::header::CONTENT_TYPE,
169            HeaderValue::from_static(APPLICATION_PROBLEM_JSON),
170        );
171        resp
172    }
173}
174
175#[cfg(test)]
176#[cfg_attr(coverage_nightly, coverage(off))]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn problem_builder_pattern() {
182        let p = Problem::new(
183            StatusCode::UNPROCESSABLE_ENTITY,
184            "Validation Failed",
185            "Input validation errors",
186        )
187        .with_code("VALIDATION_ERROR")
188        .with_instance("/users/123")
189        .with_trace_id("req-456")
190        .with_errors(vec![ValidationViolation {
191            message: "Email is required".to_owned(),
192            field: "email".to_owned(),
193            code: None,
194        }]);
195
196        assert_eq!(p.status, StatusCode::UNPROCESSABLE_ENTITY);
197        assert_eq!(p.code, "VALIDATION_ERROR");
198        assert_eq!(p.instance, "/users/123");
199        assert_eq!(p.trace_id, Some("req-456".to_owned()));
200        assert!(p.errors.is_some());
201        assert_eq!(p.errors.as_ref().unwrap().len(), 1);
202    }
203
204    #[test]
205    fn problem_serializes_status_as_u16() {
206        let p = Problem::new(StatusCode::NOT_FOUND, "Not Found", "Resource not found");
207        let json = serde_json::to_string(&p).unwrap();
208        assert!(json.contains("\"status\":404"));
209    }
210
211    #[test]
212    fn problem_deserializes_status_from_u16() {
213        let json = r#"{"type":"about:blank","title":"Not Found","status":404,"detail":"Resource not found","instance":"","code":"","trace_id":null,"errors":null}"#;
214        let p: Problem = serde_json::from_str(json).unwrap();
215        assert_eq!(p.status, StatusCode::NOT_FOUND);
216    }
217}