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}