Skip to main content

webx_core/
problem.rs

1//! RFC 7807 / RFC 9457 Problem Details for HTTP APIs.
2//!
3//! Standard error response format for machine-readable errors.
4
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct ProblemDetails {
10    #[serde(skip_serializing_if = "Option::is_none", rename = "type")]
11    pub typ: Option<String>,
12    pub title: String,
13    pub status: u16,
14    #[serde(skip_serializing_if = "Option::is_none")]
15    pub detail: Option<String>,
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub instance: Option<String>,
18    #[serde(skip_serializing_if = "Option::is_none", flatten)]
19    pub extensions: Option<HashMap<String, serde_json::Value>>,
20}
21
22impl ProblemDetails {
23    pub fn new(status: u16, title: impl Into<String>) -> Self {
24        Self {
25            typ: None,
26            title: title.into(),
27            status,
28            detail: None,
29            instance: None,
30            extensions: None,
31        }
32    }
33
34    pub fn not_found(resource: impl Into<String>, id: impl std::fmt::Display) -> Self {
35        let r = resource.into();
36        let is = format!("{}", id);
37        Self {
38            typ: Some("https://httpstatuses.com/404".into()),
39            title: "Resource Not Found".into(),
40            status: 404,
41            detail: Some(format!("{} '{}' not found", r, is)),
42            instance: Some(format!("/{}s/{}", r.to_lowercase(), is)),
43            extensions: None,
44        }
45    }
46
47    /// Build a 400 validation problem from field-level failures.
48    ///
49    /// `errors` is serialized as ASP.NET Core does — an object keyed by field
50    /// name, each holding every message for that field:
51    ///
52    /// ```json
53    /// { "errors": { "page": ["The `page` field must be a valid integer (got `x`)."] } }
54    /// ```
55    pub fn validation(errors: Vec<FieldError>) -> Self {
56        let mut grouped: std::collections::BTreeMap<String, Vec<String>> =
57            std::collections::BTreeMap::new();
58        for error in errors {
59            grouped.entry(error.field).or_default().push(error.message);
60        }
61
62        let mut ext = HashMap::new();
63        ext.insert(
64            "errors".into(),
65            serde_json::to_value(grouped).unwrap_or_default(),
66        );
67        Self {
68            typ: Some("https://httpstatuses.com/400".into()),
69            title: "Validation Failed".into(),
70            status: 400,
71            detail: Some("One or more validation errors occurred".into()),
72            instance: None,
73            extensions: Some(ext),
74        }
75    }
76
77    pub fn with_detail(mut self, d: impl Into<String>) -> Self {
78        self.detail = Some(d.into());
79        self
80    }
81
82    pub fn with_instance(mut self, i: impl Into<String>) -> Self {
83        self.instance = Some(i.into());
84        self
85    }
86
87    /// Carry this problem through the error pipeline unchanged.
88    ///
89    /// The host writes it verbatim (status, `application/problem+json`, and the
90    /// `errors` extension), so binding failures keep their per-field detail.
91    pub fn to_error(self) -> crate::error::Error {
92        crate::error::Error::Problem(Box::new(self))
93    }
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct FieldError {
98    pub field: String,
99    pub message: String,
100}
101
102impl FieldError {
103    pub fn new(field: impl Into<String>, message: impl Into<String>) -> Self {
104        Self {
105            field: field.into(),
106            message: message.into(),
107        }
108    }
109}