rust-rfc7807 0.2.0

RFC 7807 Problem Details for HTTP APIs — lightweight, safe, ergonomic
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
use serde::{Deserialize, Serialize};
use serde_json::Map;
use std::fmt;

use crate::ValidationItem;

/// An RFC 7807 Problem Details object.
///
/// Represents a structured error response per
/// [RFC 7807](https://www.rfc-editor.org/rfc/rfc7807). All standard fields
/// are optional and omitted from JSON when `None`. Extension fields are
/// flattened into the top-level JSON object.
///
/// # Internal Cause
///
/// Use [`Problem::with_cause`] to attach a diagnostic error that is **never
/// serialized** to JSON. This is essential for 5xx errors where you want to
/// log the root cause server-side without exposing it to clients.
///
/// # Example
///
/// ```
/// use rust_rfc7807::Problem;
///
/// let problem = Problem::bad_request()
///     .title("Invalid input")
///     .detail("The 'email' field is required");
///
/// let json = serde_json::to_value(&problem).unwrap();
/// assert_eq!(json["status"], 400);
/// assert_eq!(json["title"], "Invalid input");
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Problem {
    /// A URI reference that identifies the problem type.
    /// Defaults to `"about:blank"` per RFC 7807 when absent.
    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
    pub type_uri: Option<String>,

    /// A short, human-readable summary of the problem type.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub title: Option<String>,

    /// The HTTP status code for this occurrence of the problem.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub status: Option<u16>,

    /// A human-readable explanation specific to this occurrence.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub detail: Option<String>,

    /// A URI reference that identifies this specific occurrence.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub instance: Option<String>,

    /// Extension fields beyond the RFC 7807 standard fields.
    #[serde(flatten, skip_serializing_if = "Map::is_empty")]
    pub extensions: Map<String, serde_json::Value>,

    /// Internal cause for diagnostics. Never serialized.
    #[serde(skip)]
    cause: Option<InternalCause>,
}

/// Holds an internal error cause that is never serialized.
///
/// This wrapper stores either a boxed `Error` trait object or a plain string,
/// providing server-side diagnostic information for logging without risking
/// exposure in API responses.
struct InternalCause {
    source: Box<dyn std::error::Error + Send + Sync>,
}

impl fmt::Debug for InternalCause {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "InternalCause({:?})", self.source.to_string())
    }
}

impl Clone for InternalCause {
    fn clone(&self) -> Self {
        // Clone by converting to string — the original typed error cannot be cloned generically.
        InternalCause {
            source: Box::new(StringError(self.source.to_string())),
        }
    }
}

/// A simple string-based error for cloning internal causes.
#[derive(Debug)]
struct StringError(String);

impl fmt::Display for StringError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.0)
    }
}

impl std::error::Error for StringError {}

// ---------------------------------------------------------------------------
// Constructors
// ---------------------------------------------------------------------------

impl Problem {
    /// Create a new problem with the given HTTP status code.
    ///
    /// Per RFC 7807 §4.2, when no `type` is set the problem type defaults
    /// to `"about:blank"`, and the `title` SHOULD match the HTTP status
    /// phrase. This constructor sets the title automatically.
    ///
    /// ```
    /// use rust_rfc7807::Problem;
    ///
    /// let p = Problem::new(429);
    /// assert_eq!(p.status, Some(429));
    /// assert_eq!(p.title.as_deref(), Some("Too Many Requests"));
    /// ```
    pub fn new(status: u16) -> Self {
        Self {
            type_uri: None,
            title: status_phrase(status).map(String::from),
            status: Some(status),
            detail: None,
            instance: None,
            extensions: Map::new(),
            cause: None,
        }
    }

    /// 400 Bad Request.
    pub fn bad_request() -> Self {
        Self::new(400)
    }

    /// 401 Unauthorized.
    pub fn unauthorized() -> Self {
        Self::new(401)
    }

    /// 403 Forbidden.
    pub fn forbidden() -> Self {
        Self::new(403)
    }

    /// 404 Not Found.
    pub fn not_found() -> Self {
        Self::new(404)
    }

    /// 409 Conflict.
    pub fn conflict() -> Self {
        Self::new(409)
    }

    /// 422 Unprocessable Entity with validation defaults.
    ///
    /// Sets status to 422, type to `"validation_error"`, and title to
    /// `"Validation failed"`. Add field errors with [`push_error`](Self::push_error)
    /// and [`push_error_code`](Self::push_error_code).
    ///
    /// ```
    /// use rust_rfc7807::Problem;
    ///
    /// let p = Problem::validation()
    ///     .push_error("email", "is required");
    ///
    /// let json = serde_json::to_value(&p).unwrap();
    /// assert_eq!(json["status"], 422);
    /// assert_eq!(json["type"], "validation_error");
    /// ```
    pub fn validation() -> Self {
        Self::new(422)
            .type_("validation_error")
            .title("Validation failed")
    }

    /// 422 Unprocessable Entity (without validation defaults).
    pub fn unprocessable_entity() -> Self {
        Self::new(422)
    }

    /// 429 Too Many Requests.
    pub fn too_many_requests() -> Self {
        Self::new(429)
    }

    /// 500 Internal Server Error.
    ///
    /// Returns a problem with safe generic defaults:
    /// - title: `"Internal Server Error"`
    /// - detail: `"An unexpected error occurred."`
    ///
    /// Use [`with_cause`](Self::with_cause) to attach a diagnostic error for
    /// server-side logging without leaking it to clients.
    pub fn internal_server_error() -> Self {
        Self::new(500)
            .title("Internal Server Error")
            .detail("An unexpected error occurred.")
    }
}

// ---------------------------------------------------------------------------
// Builder methods
// ---------------------------------------------------------------------------

impl Problem {
    /// Set the problem type URI.
    ///
    /// The method is named `type_` because `type` is a Rust keyword.
    pub fn type_(mut self, type_uri: impl Into<String>) -> Self {
        self.type_uri = Some(type_uri.into());
        self
    }

    /// Set the title.
    pub fn title(mut self, title: impl Into<String>) -> Self {
        self.title = Some(title.into());
        self
    }

    /// Override the HTTP status code.
    pub fn status(mut self, status: u16) -> Self {
        self.status = Some(status);
        self
    }

    /// Set the public detail message.
    pub fn detail(mut self, detail: impl Into<String>) -> Self {
        self.detail = Some(detail.into());
        self
    }

    /// Set the instance URI.
    pub fn instance(mut self, instance: impl Into<String>) -> Self {
        self.instance = Some(instance.into());
        self
    }

    /// Set the `"code"` extension field — a stable string code for clients.
    pub fn code(mut self, code: impl Into<String>) -> Self {
        self.extensions
            .insert("code".into(), serde_json::Value::String(code.into()));
        self
    }

    /// Set the `"trace_id"` extension field.
    pub fn trace_id(mut self, trace_id: impl Into<String>) -> Self {
        self.extensions.insert(
            "trace_id".into(),
            serde_json::Value::String(trace_id.into()),
        );
        self
    }

    /// Set the `"request_id"` extension field.
    pub fn request_id(mut self, request_id: impl Into<String>) -> Self {
        self.extensions.insert(
            "request_id".into(),
            serde_json::Value::String(request_id.into()),
        );
        self
    }

    /// Add an arbitrary extension field.
    pub fn extension(
        mut self,
        key: impl Into<String>,
        value: impl Into<serde_json::Value>,
    ) -> Self {
        self.extensions.insert(key.into(), value.into());
        self
    }

    /// Append a field-level validation error.
    ///
    /// Creates or appends to the `"errors"` extension array.
    pub fn push_error(self, field: impl Into<String>, message: impl Into<String>) -> Self {
        self.push_validation_item(ValidationItem::new(field, message))
    }

    /// Append a field-level validation error with an error code.
    ///
    /// Creates or appends to the `"errors"` extension array.
    pub fn push_error_code(
        self,
        field: impl Into<String>,
        message: impl Into<String>,
        code: impl Into<String>,
    ) -> Self {
        self.push_validation_item(ValidationItem::new(field, message).code(code))
    }

    /// Append a [`ValidationItem`] to the `"errors"` extension array.
    fn push_validation_item(mut self, item: ValidationItem) -> Self {
        let value = serde_json::to_value(&item).expect("ValidationItem is always serializable");
        match self.extensions.get_mut("errors") {
            Some(serde_json::Value::Array(arr)) => {
                arr.push(value);
            }
            _ => {
                self.extensions
                    .insert("errors".into(), serde_json::Value::Array(vec![value]));
            }
        }
        self
    }

    /// Replace the `"errors"` extension with a complete list of validation items.
    pub fn errors(mut self, items: Vec<ValidationItem>) -> Self {
        self.extensions.insert(
            "errors".into(),
            serde_json::to_value(items).expect("ValidationItem is always serializable"),
        );
        self
    }

    /// Attach an internal cause for server-side diagnostics.
    ///
    /// The cause is **never serialized** to JSON. Access it via
    /// [`internal_cause`](Self::internal_cause) for logging.
    pub fn with_cause(mut self, err: impl std::error::Error + Send + Sync + 'static) -> Self {
        self.cause = Some(InternalCause {
            source: Box::new(err),
        });
        self
    }

    /// Attach a string message as the internal cause.
    ///
    /// Convenience alternative to [`with_cause`](Self::with_cause) when you
    /// don't have a typed error.
    pub fn with_cause_str(mut self, message: impl Into<String>) -> Self {
        self.cause = Some(InternalCause {
            source: Box::new(StringError(message.into())),
        });
        self
    }
}

// ---------------------------------------------------------------------------
// Accessors
// ---------------------------------------------------------------------------

impl Problem {
    /// The default problem type URI per RFC 7807 §4.2.
    ///
    /// When the `"type"` member is absent, its value is assumed to be
    /// `"about:blank"`, indicating that the problem has no additional
    /// semantics beyond the HTTP status code.
    pub const ABOUT_BLANK: &'static str = "about:blank";

    /// Returns the effective problem type URI.
    ///
    /// Returns the `"type"` value if set, or [`ABOUT_BLANK`](Self::ABOUT_BLANK)
    /// per RFC 7807 §4.2 when absent.
    pub fn get_type(&self) -> &str {
        self.type_uri.as_deref().unwrap_or(Self::ABOUT_BLANK)
    }

    /// Returns the HTTP status code, defaulting to 500 if not set.
    pub fn status_code(&self) -> u16 {
        self.status.unwrap_or(500)
    }

    /// Returns `true` if the status code is 5xx.
    pub fn is_server_error(&self) -> bool {
        self.status_code() >= 500
    }

    /// Returns the `"code"` extension value, if set.
    pub fn get_code(&self) -> Option<&str> {
        self.extensions.get("code").and_then(|v| v.as_str())
    }

    /// Returns the `"trace_id"` extension value, if set.
    pub fn get_trace_id(&self) -> Option<&str> {
        self.extensions.get("trace_id").and_then(|v| v.as_str())
    }

    /// Returns the internal cause message, if set.
    ///
    /// This value is never included in serialized output and is intended
    /// for server-side logging only.
    pub fn internal_cause(&self) -> Option<&(dyn std::error::Error + Send + Sync)> {
        self.cause.as_ref().map(|c| c.source.as_ref())
    }

    /// Serialize to a pretty-printed JSON string. Useful in tests and debugging.
    pub fn to_json_string_pretty(&self) -> String {
        serde_json::to_string_pretty(self).expect("Problem is always serializable")
    }
}

// ---------------------------------------------------------------------------
// Trait impls
// ---------------------------------------------------------------------------

impl Default for Problem {
    fn default() -> Self {
        Self {
            type_uri: None,
            title: None,
            status: None,
            detail: None,
            instance: None,
            extensions: Map::new(),
            cause: None,
        }
    }
}

impl fmt::Display for Problem {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        if let Some(title) = &self.title {
            write!(f, "{title}")?;
        } else {
            write!(f, "Problem")?;
        }
        if let Some(status) = self.status {
            write!(f, " ({status})")?;
        }
        if let Some(detail) = &self.detail {
            write!(f, ": {detail}")?;
        }
        Ok(())
    }
}

impl std::error::Error for Problem {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        self.cause
            .as_ref()
            .map(|c| c.source.as_ref() as &(dyn std::error::Error + 'static))
    }
}

// ---------------------------------------------------------------------------
// HTTP status phrase lookup (RFC 7231 / 6585 / 9110)
// ---------------------------------------------------------------------------

/// Returns the standard HTTP reason phrase for common status codes.
fn status_phrase(status: u16) -> Option<&'static str> {
    match status {
        400 => Some("Bad Request"),
        401 => Some("Unauthorized"),
        402 => Some("Payment Required"),
        403 => Some("Forbidden"),
        404 => Some("Not Found"),
        405 => Some("Method Not Allowed"),
        406 => Some("Not Acceptable"),
        407 => Some("Proxy Authentication Required"),
        408 => Some("Request Timeout"),
        409 => Some("Conflict"),
        410 => Some("Gone"),
        411 => Some("Length Required"),
        412 => Some("Precondition Failed"),
        413 => Some("Content Too Large"),
        414 => Some("URI Too Long"),
        415 => Some("Unsupported Media Type"),
        416 => Some("Range Not Satisfiable"),
        417 => Some("Expectation Failed"),
        418 => Some("I'm a Teapot"),
        421 => Some("Misdirected Request"),
        422 => Some("Unprocessable Content"),
        423 => Some("Locked"),
        424 => Some("Failed Dependency"),
        425 => Some("Too Early"),
        426 => Some("Upgrade Required"),
        428 => Some("Precondition Required"),
        429 => Some("Too Many Requests"),
        431 => Some("Request Header Fields Too Large"),
        451 => Some("Unavailable For Legal Reasons"),
        500 => Some("Internal Server Error"),
        501 => Some("Not Implemented"),
        502 => Some("Bad Gateway"),
        503 => Some("Service Unavailable"),
        504 => Some("Gateway Timeout"),
        505 => Some("HTTP Version Not Supported"),
        506 => Some("Variant Also Negotiates"),
        507 => Some("Insufficient Storage"),
        508 => Some("Loop Detected"),
        510 => Some("Not Extended"),
        511 => Some("Network Authentication Required"),
        _ => None,
    }
}