Skip to main content

rust_rfc7807/
problem.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Map;
3use std::fmt;
4
5use crate::ValidationItem;
6
7/// An RFC 7807 Problem Details object.
8///
9/// Represents a structured error response per
10/// [RFC 7807](https://www.rfc-editor.org/rfc/rfc7807). All standard fields
11/// are optional and omitted from JSON when `None`. Extension fields are
12/// flattened into the top-level JSON object.
13///
14/// # Internal Cause
15///
16/// Use [`Problem::with_cause`] to attach a diagnostic error that is **never
17/// serialized** to JSON. This is essential for 5xx errors where you want to
18/// log the root cause server-side without exposing it to clients.
19///
20/// # Example
21///
22/// ```
23/// use rust_rfc7807::Problem;
24///
25/// let problem = Problem::bad_request()
26///     .title("Invalid input")
27///     .detail("The 'email' field is required");
28///
29/// let json = serde_json::to_value(&problem).unwrap();
30/// assert_eq!(json["status"], 400);
31/// assert_eq!(json["title"], "Invalid input");
32/// ```
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct Problem {
35    /// A URI reference that identifies the problem type.
36    /// Defaults to `"about:blank"` per RFC 7807 when absent.
37    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
38    pub type_uri: Option<String>,
39
40    /// A short, human-readable summary of the problem type.
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub title: Option<String>,
43
44    /// The HTTP status code for this occurrence of the problem.
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub status: Option<u16>,
47
48    /// A human-readable explanation specific to this occurrence.
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub detail: Option<String>,
51
52    /// A URI reference that identifies this specific occurrence.
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub instance: Option<String>,
55
56    /// Extension fields beyond the RFC 7807 standard fields.
57    #[serde(flatten, skip_serializing_if = "Map::is_empty")]
58    pub extensions: Map<String, serde_json::Value>,
59
60    /// Internal cause for diagnostics. Never serialized.
61    #[serde(skip)]
62    cause: Option<InternalCause>,
63}
64
65/// Holds an internal error cause that is never serialized.
66///
67/// This wrapper stores either a boxed `Error` trait object or a plain string,
68/// providing server-side diagnostic information for logging without risking
69/// exposure in API responses.
70struct InternalCause {
71    source: Box<dyn std::error::Error + Send + Sync>,
72}
73
74impl fmt::Debug for InternalCause {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        write!(f, "InternalCause({:?})", self.source.to_string())
77    }
78}
79
80impl Clone for InternalCause {
81    fn clone(&self) -> Self {
82        // Clone by converting to string — the original typed error cannot be cloned generically.
83        InternalCause {
84            source: Box::new(StringError(self.source.to_string())),
85        }
86    }
87}
88
89/// A simple string-based error for cloning internal causes.
90#[derive(Debug)]
91struct StringError(String);
92
93impl fmt::Display for StringError {
94    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95        f.write_str(&self.0)
96    }
97}
98
99impl std::error::Error for StringError {}
100
101// ---------------------------------------------------------------------------
102// Constructors
103// ---------------------------------------------------------------------------
104
105impl Problem {
106    /// Create a new problem with the given HTTP status code.
107    ///
108    /// Per RFC 7807 §4.2, when no `type` is set the problem type defaults
109    /// to `"about:blank"`, and the `title` SHOULD match the HTTP status
110    /// phrase. This constructor sets the title automatically.
111    ///
112    /// ```
113    /// use rust_rfc7807::Problem;
114    ///
115    /// let p = Problem::new(429);
116    /// assert_eq!(p.status, Some(429));
117    /// assert_eq!(p.title.as_deref(), Some("Too Many Requests"));
118    /// ```
119    pub fn new(status: u16) -> Self {
120        Self {
121            type_uri: None,
122            title: status_phrase(status).map(String::from),
123            status: Some(status),
124            detail: None,
125            instance: None,
126            extensions: Map::new(),
127            cause: None,
128        }
129    }
130
131    /// 400 Bad Request.
132    pub fn bad_request() -> Self {
133        Self::new(400)
134    }
135
136    /// 401 Unauthorized.
137    pub fn unauthorized() -> Self {
138        Self::new(401)
139    }
140
141    /// 403 Forbidden.
142    pub fn forbidden() -> Self {
143        Self::new(403)
144    }
145
146    /// 404 Not Found.
147    pub fn not_found() -> Self {
148        Self::new(404)
149    }
150
151    /// 409 Conflict.
152    pub fn conflict() -> Self {
153        Self::new(409)
154    }
155
156    /// 422 Unprocessable Entity with validation defaults.
157    ///
158    /// Sets status to 422, type to `"validation_error"`, and title to
159    /// `"Validation failed"`. Add field errors with [`push_error`](Self::push_error)
160    /// and [`push_error_code`](Self::push_error_code).
161    ///
162    /// ```
163    /// use rust_rfc7807::Problem;
164    ///
165    /// let p = Problem::validation()
166    ///     .push_error("email", "is required");
167    ///
168    /// let json = serde_json::to_value(&p).unwrap();
169    /// assert_eq!(json["status"], 422);
170    /// assert_eq!(json["type"], "validation_error");
171    /// ```
172    pub fn validation() -> Self {
173        Self::new(422)
174            .type_("validation_error")
175            .title("Validation failed")
176    }
177
178    /// 422 Unprocessable Entity (without validation defaults).
179    pub fn unprocessable_entity() -> Self {
180        Self::new(422)
181    }
182
183    /// 429 Too Many Requests.
184    pub fn too_many_requests() -> Self {
185        Self::new(429)
186    }
187
188    /// 500 Internal Server Error.
189    ///
190    /// Returns a problem with safe generic defaults:
191    /// - title: `"Internal Server Error"`
192    /// - detail: `"An unexpected error occurred."`
193    ///
194    /// Use [`with_cause`](Self::with_cause) to attach a diagnostic error for
195    /// server-side logging without leaking it to clients.
196    pub fn internal_server_error() -> Self {
197        Self::new(500)
198            .title("Internal Server Error")
199            .detail("An unexpected error occurred.")
200    }
201}
202
203// ---------------------------------------------------------------------------
204// Builder methods
205// ---------------------------------------------------------------------------
206
207impl Problem {
208    /// Set the problem type URI.
209    ///
210    /// The method is named `type_` because `type` is a Rust keyword.
211    pub fn type_(mut self, type_uri: impl Into<String>) -> Self {
212        self.type_uri = Some(type_uri.into());
213        self
214    }
215
216    /// Set the title.
217    pub fn title(mut self, title: impl Into<String>) -> Self {
218        self.title = Some(title.into());
219        self
220    }
221
222    /// Override the HTTP status code.
223    pub fn status(mut self, status: u16) -> Self {
224        self.status = Some(status);
225        self
226    }
227
228    /// Set the public detail message.
229    pub fn detail(mut self, detail: impl Into<String>) -> Self {
230        self.detail = Some(detail.into());
231        self
232    }
233
234    /// Set the instance URI.
235    pub fn instance(mut self, instance: impl Into<String>) -> Self {
236        self.instance = Some(instance.into());
237        self
238    }
239
240    /// Set the `"code"` extension field — a stable string code for clients.
241    pub fn code(mut self, code: impl Into<String>) -> Self {
242        self.extensions
243            .insert("code".into(), serde_json::Value::String(code.into()));
244        self
245    }
246
247    /// Set the `"trace_id"` extension field.
248    pub fn trace_id(mut self, trace_id: impl Into<String>) -> Self {
249        self.extensions.insert(
250            "trace_id".into(),
251            serde_json::Value::String(trace_id.into()),
252        );
253        self
254    }
255
256    /// Set the `"request_id"` extension field.
257    pub fn request_id(mut self, request_id: impl Into<String>) -> Self {
258        self.extensions.insert(
259            "request_id".into(),
260            serde_json::Value::String(request_id.into()),
261        );
262        self
263    }
264
265    /// Add an arbitrary extension field.
266    pub fn extension(
267        mut self,
268        key: impl Into<String>,
269        value: impl Into<serde_json::Value>,
270    ) -> Self {
271        self.extensions.insert(key.into(), value.into());
272        self
273    }
274
275    /// Append a field-level validation error.
276    ///
277    /// Creates or appends to the `"errors"` extension array.
278    pub fn push_error(self, field: impl Into<String>, message: impl Into<String>) -> Self {
279        self.push_validation_item(ValidationItem::new(field, message))
280    }
281
282    /// Append a field-level validation error with an error code.
283    ///
284    /// Creates or appends to the `"errors"` extension array.
285    pub fn push_error_code(
286        self,
287        field: impl Into<String>,
288        message: impl Into<String>,
289        code: impl Into<String>,
290    ) -> Self {
291        self.push_validation_item(ValidationItem::new(field, message).code(code))
292    }
293
294    /// Append a [`ValidationItem`] to the `"errors"` extension array.
295    fn push_validation_item(mut self, item: ValidationItem) -> Self {
296        let value = serde_json::to_value(&item).expect("ValidationItem is always serializable");
297        match self.extensions.get_mut("errors") {
298            Some(serde_json::Value::Array(arr)) => {
299                arr.push(value);
300            }
301            _ => {
302                self.extensions
303                    .insert("errors".into(), serde_json::Value::Array(vec![value]));
304            }
305        }
306        self
307    }
308
309    /// Replace the `"errors"` extension with a complete list of validation items.
310    pub fn errors(mut self, items: Vec<ValidationItem>) -> Self {
311        self.extensions.insert(
312            "errors".into(),
313            serde_json::to_value(items).expect("ValidationItem is always serializable"),
314        );
315        self
316    }
317
318    /// Attach an internal cause for server-side diagnostics.
319    ///
320    /// The cause is **never serialized** to JSON. Access it via
321    /// [`internal_cause`](Self::internal_cause) for logging.
322    pub fn with_cause(mut self, err: impl std::error::Error + Send + Sync + 'static) -> Self {
323        self.cause = Some(InternalCause {
324            source: Box::new(err),
325        });
326        self
327    }
328
329    /// Attach a string message as the internal cause.
330    ///
331    /// Convenience alternative to [`with_cause`](Self::with_cause) when you
332    /// don't have a typed error.
333    pub fn with_cause_str(mut self, message: impl Into<String>) -> Self {
334        self.cause = Some(InternalCause {
335            source: Box::new(StringError(message.into())),
336        });
337        self
338    }
339}
340
341// ---------------------------------------------------------------------------
342// Accessors
343// ---------------------------------------------------------------------------
344
345impl Problem {
346    /// The default problem type URI per RFC 7807 §4.2.
347    ///
348    /// When the `"type"` member is absent, its value is assumed to be
349    /// `"about:blank"`, indicating that the problem has no additional
350    /// semantics beyond the HTTP status code.
351    pub const ABOUT_BLANK: &'static str = "about:blank";
352
353    /// Returns the effective problem type URI.
354    ///
355    /// Returns the `"type"` value if set, or [`ABOUT_BLANK`](Self::ABOUT_BLANK)
356    /// per RFC 7807 §4.2 when absent.
357    pub fn get_type(&self) -> &str {
358        self.type_uri.as_deref().unwrap_or(Self::ABOUT_BLANK)
359    }
360
361    /// Returns the HTTP status code, defaulting to 500 if not set.
362    pub fn status_code(&self) -> u16 {
363        self.status.unwrap_or(500)
364    }
365
366    /// Returns `true` if the status code is 5xx.
367    pub fn is_server_error(&self) -> bool {
368        self.status_code() >= 500
369    }
370
371    /// Returns the `"code"` extension value, if set.
372    pub fn get_code(&self) -> Option<&str> {
373        self.extensions.get("code").and_then(|v| v.as_str())
374    }
375
376    /// Returns the `"trace_id"` extension value, if set.
377    pub fn get_trace_id(&self) -> Option<&str> {
378        self.extensions.get("trace_id").and_then(|v| v.as_str())
379    }
380
381    /// Returns the internal cause message, if set.
382    ///
383    /// This value is never included in serialized output and is intended
384    /// for server-side logging only.
385    pub fn internal_cause(&self) -> Option<&(dyn std::error::Error + Send + Sync)> {
386        self.cause.as_ref().map(|c| c.source.as_ref())
387    }
388
389    /// Serialize to a pretty-printed JSON string. Useful in tests and debugging.
390    pub fn to_json_string_pretty(&self) -> String {
391        serde_json::to_string_pretty(self).expect("Problem is always serializable")
392    }
393}
394
395// ---------------------------------------------------------------------------
396// Trait impls
397// ---------------------------------------------------------------------------
398
399impl Default for Problem {
400    fn default() -> Self {
401        Self {
402            type_uri: None,
403            title: None,
404            status: None,
405            detail: None,
406            instance: None,
407            extensions: Map::new(),
408            cause: None,
409        }
410    }
411}
412
413impl fmt::Display for Problem {
414    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
415        if let Some(title) = &self.title {
416            write!(f, "{title}")?;
417        } else {
418            write!(f, "Problem")?;
419        }
420        if let Some(status) = self.status {
421            write!(f, " ({status})")?;
422        }
423        if let Some(detail) = &self.detail {
424            write!(f, ": {detail}")?;
425        }
426        Ok(())
427    }
428}
429
430impl std::error::Error for Problem {
431    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
432        self.cause
433            .as_ref()
434            .map(|c| c.source.as_ref() as &(dyn std::error::Error + 'static))
435    }
436}
437
438// ---------------------------------------------------------------------------
439// HTTP status phrase lookup (RFC 7231 / 6585 / 9110)
440// ---------------------------------------------------------------------------
441
442/// Returns the standard HTTP reason phrase for common status codes.
443fn status_phrase(status: u16) -> Option<&'static str> {
444    match status {
445        400 => Some("Bad Request"),
446        401 => Some("Unauthorized"),
447        402 => Some("Payment Required"),
448        403 => Some("Forbidden"),
449        404 => Some("Not Found"),
450        405 => Some("Method Not Allowed"),
451        406 => Some("Not Acceptable"),
452        407 => Some("Proxy Authentication Required"),
453        408 => Some("Request Timeout"),
454        409 => Some("Conflict"),
455        410 => Some("Gone"),
456        411 => Some("Length Required"),
457        412 => Some("Precondition Failed"),
458        413 => Some("Content Too Large"),
459        414 => Some("URI Too Long"),
460        415 => Some("Unsupported Media Type"),
461        416 => Some("Range Not Satisfiable"),
462        417 => Some("Expectation Failed"),
463        418 => Some("I'm a Teapot"),
464        421 => Some("Misdirected Request"),
465        422 => Some("Unprocessable Content"),
466        423 => Some("Locked"),
467        424 => Some("Failed Dependency"),
468        425 => Some("Too Early"),
469        426 => Some("Upgrade Required"),
470        428 => Some("Precondition Required"),
471        429 => Some("Too Many Requests"),
472        431 => Some("Request Header Fields Too Large"),
473        451 => Some("Unavailable For Legal Reasons"),
474        500 => Some("Internal Server Error"),
475        501 => Some("Not Implemented"),
476        502 => Some("Bad Gateway"),
477        503 => Some("Service Unavailable"),
478        504 => Some("Gateway Timeout"),
479        505 => Some("HTTP Version Not Supported"),
480        506 => Some("Variant Also Negotiates"),
481        507 => Some("Insufficient Storage"),
482        508 => Some("Loop Detected"),
483        510 => Some("Not Extended"),
484        511 => Some("Network Authentication Required"),
485        _ => None,
486    }
487}