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