masterror/response/
details.rs

1// SPDX-FileCopyrightText: 2025 RAprogramm <andrey.rozanov.vl@gmail.com>
2//
3// SPDX-License-Identifier: MIT
4
5#[cfg(not(feature = "serde_json"))]
6use alloc::string::String;
7
8#[cfg(feature = "serde_json")]
9use serde::Serialize;
10#[cfg(feature = "serde_json")]
11use serde_json::{Value as JsonValue, to_value};
12
13use super::core::ErrorResponse;
14#[cfg(feature = "serde_json")]
15use crate::{AppError, AppResult};
16
17#[cfg(not(feature = "serde_json"))]
18impl ErrorResponse {
19    /// Attach plain-text details (available when `serde_json` is disabled).
20    #[must_use]
21    pub fn with_details_text(mut self, details: impl Into<String>) -> Self {
22        self.details = Some(details.into());
23        self
24    }
25}
26
27#[cfg(feature = "serde_json")]
28impl ErrorResponse {
29    /// Attach structured JSON details (available when `serde_json` is enabled).
30    #[must_use]
31    pub fn with_details_json(mut self, details: JsonValue) -> Self {
32        self.details = Some(details);
33        self
34    }
35
36    /// Serialize and attach structured details from any [`Serialize`] value.
37    ///
38    /// # Errors
39    ///
40    /// Returns [`AppError`] if serialization fails.
41    ///
42    /// # Examples
43    /// ```
44    /// # #[cfg(feature = "serde_json")]
45    /// # {
46    /// use masterror::{AppCode, ErrorResponse};
47    /// use serde::Serialize;
48    ///
49    /// #[derive(Serialize)]
50    /// struct Extra {
51    ///     reason: String
52    /// }
53    ///
54    /// let payload = Extra {
55    ///     reason: "missing".into()
56    /// };
57    /// let resp = ErrorResponse::new(404, AppCode::NotFound, "no user")
58    ///     .expect("status")
59    ///     .with_details(payload)
60    ///     .expect("details");
61    /// assert!(resp.details.is_some());
62    /// # }
63    /// ```
64    #[allow(clippy::result_large_err)]
65    pub fn with_details<T>(self, payload: T) -> AppResult<Self>
66    where
67        T: Serialize
68    {
69        let details = to_value(payload).map_err(|e| AppError::bad_request(e.to_string()))?;
70        Ok(self.with_details_json(details))
71    }
72}
73#[cfg(feature = "serde_json")]
74use alloc::string::ToString;
75
76#[cfg(test)]
77mod tests {
78    use crate::{AppCode, ErrorResponse};
79
80    #[cfg(not(feature = "serde_json"))]
81    #[test]
82    fn with_details_text_attaches_string() {
83        let resp = ErrorResponse::new(400, AppCode::BadRequest, "error")
84            .unwrap()
85            .with_details_text("detailed explanation");
86        assert_eq!(resp.details.as_deref(), Some("detailed explanation"));
87    }
88
89    #[cfg(not(feature = "serde_json"))]
90    #[test]
91    fn with_details_text_accepts_owned_string() {
92        let resp = ErrorResponse::new(422, AppCode::Validation, "invalid")
93            .unwrap()
94            .with_details_text(String::from("field error"));
95        assert_eq!(resp.details.as_deref(), Some("field error"));
96    }
97
98    #[cfg(feature = "serde_json")]
99    #[test]
100    fn with_details_json_attaches_value() {
101        use serde_json::json;
102        let details = json!({"field": "email", "error": "invalid"});
103        let resp = ErrorResponse::new(422, AppCode::Validation, "bad input")
104            .unwrap()
105            .with_details_json(details.clone());
106        assert_eq!(resp.details, Some(details));
107    }
108
109    #[cfg(feature = "serde_json")]
110    #[test]
111    fn with_details_serializes_struct() {
112        use serde::Serialize;
113        #[derive(Serialize)]
114        struct ErrorInfo {
115            field: String,
116            code:  u32
117        }
118        let info = ErrorInfo {
119            field: "username".to_owned(),
120            code:  1001
121        };
122        let resp = ErrorResponse::new(400, AppCode::BadRequest, "validation failed")
123            .unwrap()
124            .with_details(info)
125            .unwrap();
126        assert!(resp.details.is_some());
127        let details = resp.details.unwrap();
128        assert_eq!(details["field"], "username");
129        assert_eq!(details["code"], 1001);
130    }
131
132    #[cfg(feature = "serde_json")]
133    #[test]
134    fn with_details_serializes_nan_as_null() {
135        use serde::Serialize;
136        #[derive(Serialize)]
137        struct DataWithNaN {
138            value: f64
139        }
140        let data = DataWithNaN {
141            value: f64::NAN
142        };
143        let resp = ErrorResponse::new(500, AppCode::Internal, "error")
144            .unwrap()
145            .with_details(data)
146            .unwrap();
147        assert!(resp.details.is_some());
148        let details = resp.details.unwrap();
149        assert!(details["value"].is_null());
150    }
151
152    #[cfg(feature = "serde_json")]
153    #[test]
154    fn with_details_preserves_other_fields() {
155        use serde::Serialize;
156        #[derive(Serialize)]
157        struct Extra {
158            info: String
159        }
160        let mut resp = ErrorResponse::new(429, AppCode::RateLimited, "too many").unwrap();
161        resp.retry = Some(crate::response::core::RetryAdvice {
162            after_seconds: 60
163        });
164        let resp = resp
165            .with_details(Extra {
166                info: "try later".to_owned()
167            })
168            .unwrap();
169        assert!(resp.details.is_some());
170        assert!(resp.retry.is_some());
171        assert_eq!(resp.status, 429);
172        assert_eq!(resp.code, AppCode::RateLimited);
173    }
174
175    #[cfg(not(feature = "serde_json"))]
176    #[test]
177    fn with_details_text_builder_pattern() {
178        let resp = ErrorResponse::new(404, AppCode::NotFound, "missing")
179            .unwrap()
180            .with_details_text("resource not found in database");
181        assert_eq!(resp.status, 404);
182        assert_eq!(resp.message, "missing");
183        assert!(resp.details.is_some());
184    }
185}