http_problem/
custom.rs

1pub use http::{StatusCode, Uri};
2
3use super::{CowStr, Extensions, Problem};
4
5/// Define a new custom problem type.
6///
7/// Although defining a new custom type requires only implementing
8/// the [`CustomProblem`] trait, this macro simplifies the code,
9/// removing boilerplate from the definition.
10///
11/// # Example
12///
13/// ```
14/// use http_problem::prelude::{StatusCode, Uri};
15/// problem::define_custom_type! {
16///     /// An error that occurs when a transaction cannot be done
17///     /// because one of the accounts doesn't have enough credit.
18///     type OutOfCredit {
19///         type: "https://example.com/probs/out-of-credit",
20///         title: "You do not have enough credit",
21///         status: StatusCode::FORBIDDEN,
22///         detail(p): format!("You current balance is {}, but that costs {}", p.balance, p.cost),
23///         extensions: {
24///             balance: i64,
25///             cost: i64,
26///             accounts: Vec<String>
27///         }
28///     }
29/// }
30///
31/// fn do_transaction() -> problem::Result<()> {
32///     Err(OutOfCredit {
33///         balance: 50,
34///         cost: 30,
35///         accounts: vec!["/account/12345".into(), "/account/67890".into()]
36///     }.into())
37/// }
38///
39/// fn main() {
40///     let problem = do_transaction().unwrap_err();
41///     assert_eq!(problem.type_(), &Uri::from_static("https://example.com/probs/out-of-credit"));
42///     assert_eq!(problem.title(), "You do not have enough credit");
43///     assert_eq!(problem.status(), StatusCode::FORBIDDEN);
44///     assert_eq!(problem.details(), "You current balance is 50, but that costs 30");
45///     assert_eq!(problem.extensions().len(), 3);
46/// }
47/// ```
48#[macro_export]
49macro_rules! define_custom_type {
50	($(#[$meta: meta])* type $rstyp: ident {
51        type: $typ:literal,
52        title: $title:literal,
53        status: $status: expr,
54        detail($prob: ident): $detail: expr,
55        extensions: {
56            $($field:ident: $field_ty: ty),* $(,)?
57        } $(,)?
58    }) => {
59        $(#[$meta])*
60        #[derive(Debug)]
61        pub struct $rstyp {
62            $(pub $field: $field_ty),*
63        }
64
65        impl ::std::fmt::Display for $rstyp {
66            fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
67                writeln!(f, "{}", <Self as $crate::prelude::CustomProblem>::details(self))
68            }
69        }
70
71        impl ::std::error::Error for $rstyp {}
72
73        impl $crate::prelude::CustomProblem for $rstyp {
74            fn problem_type(&self) -> $crate::prelude::Uri {
75                $crate::prelude::Uri::from_static($typ)
76            }
77
78            fn title(&self) -> &'static str {
79                $title
80            }
81
82            fn status_code(&self) -> $crate::prelude::StatusCode {
83                $status
84            }
85
86            fn details(&self) -> $crate::CowStr {
87                let $prob = self;
88                $detail.into()
89            }
90
91            fn add_extensions(
92                &self,
93                _extensions: &mut $crate::Extensions)
94            {
95                $(
96                    _extensions.insert(stringify!($field), &self.$field);
97                )*
98            }
99        }
100	};
101}
102
103/// A trait defining custom problem types.
104///
105/// Implementing this trait provides enough information to create a
106/// [`Problem`] instance with the correct values for each field.
107///
108/// There is no need to implement `From<Self> for Problem` if you
109/// implement this trait.
110///
111/// See [`define_custom_type!`] for a convenient way of implementing
112/// this trait.
113pub trait CustomProblem: std::error::Error + Send + Sync + 'static {
114    /// A URI reference that identifies the problem type.
115    ///
116    /// See [`Problem::type_`] more information.
117    fn problem_type(&self) -> Uri;
118
119    /// A short, human-readable summary of the problem type.
120    ///
121    /// See [`Problem::title`] for more information.
122    fn title(&self) -> &'static str;
123
124    /// The HTTP status code for this problem type.
125    ///
126    /// See [`Problem::status`] for more information.
127    fn status_code(&self) -> StatusCode;
128
129    /// A human-readable explanation of the occurrence.
130    ///
131    /// See [`Problem::details`] for more information.
132    fn details(&self) -> CowStr;
133
134    /// Add extensions to the final problem instance.
135    ///
136    /// See [`Problem::with_extension`] for more info.
137    fn add_extensions(&self, extensions: &mut Extensions);
138}
139
140impl<C: CustomProblem> From<C> for Problem {
141    #[track_caller]
142    fn from(custom: C) -> Self {
143        let mut problem = Self::custom(custom.status_code(), custom.problem_type())
144            .with_title(custom.title())
145            .with_detail(custom.details());
146
147        custom.add_extensions(problem.extensions_mut());
148
149        problem.with_cause(custom)
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    define_custom_type! {
158        /// An error that occurs when a transaction cannot be done
159        /// because one of the accounts doesn't have enough credit.
160        type OutOfCredit {
161            type: "https://example.com/probs/out-of-credit",
162            title: "You do not have enough credit",
163            status: StatusCode::FORBIDDEN,
164            detail(p): format!("You current balance is {}, but that costs {}", p.balance, p.cost),
165            extensions: {
166                balance: i64,
167                cost: i64,
168                accounts: Vec<String>
169            }
170        }
171    }
172
173    #[test]
174    fn test_macro_output() {
175        let error = OutOfCredit {
176            balance: 30,
177            cost: 50,
178            accounts: vec!["aaa".into(), "bbb".into()],
179        };
180
181        assert_eq!(error.title(), "You do not have enough credit");
182        assert_eq!(error.status_code(), StatusCode::FORBIDDEN);
183        assert_eq!(
184            error.details(),
185            "You current balance is 30, but that costs 50"
186        );
187    }
188
189    #[test]
190    fn test_custom_problem_to_problem() {
191        let error = OutOfCredit {
192            balance: 30,
193            cost: 50,
194            accounts: vec!["aaa".into(), "bbb".into()],
195        };
196
197        let prob: Problem = error.into();
198
199        assert_eq!(prob.title(), "You do not have enough credit");
200        assert_eq!(prob.status(), StatusCode::FORBIDDEN);
201        assert_eq!(
202            prob.details(),
203            "You current balance is 30, but that costs 50"
204        );
205    }
206}