Skip to main content

axum_api_kit/
problem.rs

1//! RFC 9457 `application/problem+json` error responses.
2//!
3//! [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) (Problem Details for HTTP
4//! APIs) defines a standard JSON error shape - `type`, `title`, `status`,
5//! `detail`, `instance`, plus arbitrary extension members - served with the
6//! `application/problem+json` media type. [`Problem`] implements that shape as
7//! a chainable builder that converts into an Axum response.
8//!
9//! # `Problem` vs [`ApiError`](crate::ApiError)
10//!
11//! [`ApiError`](crate::ApiError)'s flat `{ code, message, details }` body is
12//! the kit's default and remains the right choice for services that own both
13//! ends of the wire. Reach for `Problem` when the error format needs to
14//! interoperate: API gateways that understand problem+json, OpenAPI tooling
15//! that expects the RFC 9457 members, or polyglot clients standardizing on the
16//! RFC across services.
17//!
18//! A separate type exists because `ApiError`'s serialization and factory
19//! tuples are frozen under the 1.x stability promise, and `axum::Json` can
20//! only emit `Content-Type: application/json`; `Problem` builds its own
21//! response so it can send `application/problem+json`. The `From` impls in
22//! this module bridge an existing `ApiError` (or a factory tuple) into a
23//! `Problem` losslessly.
24//!
25//! # Out of scope for this release
26//!
27//! - Accept-header content negotiation middleware (choosing `application/json`
28//!   or `application/problem+json` based on the request).
29//! - A problem+json rejection mode for `ValidatedJson` / `ApiJson`.
30//! - HTTP-date `Retry-After` values (only delay-seconds are emitted).
31//!
32//! All three are candidates for a future minor release.
33
34use axum::{
35    http::{header, HeaderValue, StatusCode},
36    response::{IntoResponse, Response},
37    Json,
38};
39use serde::Serialize;
40
41use crate::ApiError;
42
43/// The `application/problem+json` media type from RFC 9457.
44///
45/// # Example
46///
47/// ```rust
48/// use axum_api_kit::APPLICATION_PROBLEM_JSON;
49///
50/// assert_eq!(APPLICATION_PROBLEM_JSON, "application/problem+json");
51/// ```
52pub const APPLICATION_PROBLEM_JSON: &str = "application/problem+json";
53
54/// An RFC 9457 problem details response body.
55///
56/// Serializes as:
57/// ```json
58/// { "title": "Not Found", "status": 404 }
59/// { "type": "https://example.com/probs/out-of-credit", "title": "Insufficient credit",
60///   "status": 403, "detail": "Balance is 30, item costs 50",
61///   "instance": "/account/12345/msgs/abc", "balance": 30 }
62/// ```
63///
64/// Implements [`IntoResponse`] with `Content-Type: application/problem+json`
65/// and an optional delay-seconds `Retry-After` header.
66///
67/// # Example
68///
69/// ```rust
70/// use axum::{http::StatusCode, response::IntoResponse};
71/// use axum_api_kit::Problem;
72///
73/// async fn handler() -> impl IntoResponse {
74///     Problem::new(StatusCode::FORBIDDEN, "Insufficient credit")
75///         .with_type("https://example.com/probs/out-of-credit")
76///         .with_detail("Balance is 30, item costs 50")
77///         .with_instance("/account/12345/msgs/abc")
78///         .with_extension("balance", 30)
79/// }
80/// ```
81#[derive(Debug, Clone, Serialize)]
82#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
83pub struct Problem {
84    /// A URI reference identifying the problem type. Absent means "about:blank" per RFC 9457.
85    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
86    pub type_uri: Option<String>,
87    /// A short, human-readable summary of the problem type. Stable per problem type.
88    pub title: String,
89    /// The HTTP status code, duplicated in the body per RFC 9457.
90    pub status: u16,
91    /// A human-readable explanation specific to this occurrence.
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub detail: Option<String>,
94    /// A URI reference identifying this specific occurrence.
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub instance: Option<String>,
97    /// RFC 9457 extension members, flattened to top-level JSON keys.
98    #[serde(flatten)]
99    #[cfg_attr(feature = "openapi", schema(value_type = Object))]
100    pub extensions: serde_json::Map<String, serde_json::Value>,
101    /// Optional Retry-After header delay. Header-only; never serialized in the body.
102    #[serde(skip)]
103    #[cfg_attr(feature = "openapi", schema(ignore))]
104    pub retry_after: Option<std::time::Duration>,
105}
106
107impl Problem {
108    /// Builds a minimal `Problem` with the given status and title.
109    ///
110    /// `type`, `detail`, and `instance` start absent, extensions start empty,
111    /// and no `Retry-After` header is emitted.
112    ///
113    /// # Example
114    ///
115    /// ```rust
116    /// use axum::http::StatusCode;
117    /// use axum_api_kit::Problem;
118    ///
119    /// let problem = Problem::new(StatusCode::NOT_FOUND, "Not Found");
120    /// assert_eq!(
121    ///     serde_json::to_value(&problem).unwrap(),
122    ///     serde_json::json!({ "title": "Not Found", "status": 404 })
123    /// );
124    /// ```
125    pub fn new(status: StatusCode, title: impl Into<String>) -> Self {
126        Self {
127            type_uri: None,
128            title: title.into(),
129            status: status.as_u16(),
130            detail: None,
131            instance: None,
132            extensions: serde_json::Map::new(),
133            retry_after: None,
134        }
135    }
136
137    /// Sets the `type` member: a URI reference identifying the problem type.
138    ///
139    /// # Example
140    ///
141    /// ```rust
142    /// use axum::http::StatusCode;
143    /// use axum_api_kit::Problem;
144    ///
145    /// let problem = Problem::new(StatusCode::FORBIDDEN, "Insufficient credit")
146    ///     .with_type("https://example.com/probs/out-of-credit");
147    /// let v = serde_json::to_value(&problem).unwrap();
148    /// assert_eq!(v["type"], "https://example.com/probs/out-of-credit");
149    /// ```
150    pub fn with_type(mut self, type_uri: impl Into<String>) -> Self {
151        self.type_uri = Some(type_uri.into());
152        self
153    }
154
155    /// Sets the `detail` member: a human-readable explanation specific to this
156    /// occurrence.
157    ///
158    /// # Example
159    ///
160    /// ```rust
161    /// use axum::http::StatusCode;
162    /// use axum_api_kit::Problem;
163    ///
164    /// let problem = Problem::new(StatusCode::FORBIDDEN, "Insufficient credit")
165    ///     .with_detail("Balance is 30, item costs 50");
166    /// let v = serde_json::to_value(&problem).unwrap();
167    /// assert_eq!(v["detail"], "Balance is 30, item costs 50");
168    /// ```
169    pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
170        self.detail = Some(detail.into());
171        self
172    }
173
174    /// Sets the `instance` member: a URI reference identifying this specific
175    /// occurrence.
176    ///
177    /// # Example
178    ///
179    /// ```rust
180    /// use axum::http::StatusCode;
181    /// use axum_api_kit::Problem;
182    ///
183    /// let problem = Problem::new(StatusCode::FORBIDDEN, "Insufficient credit")
184    ///     .with_instance("/account/12345/msgs/abc");
185    /// let v = serde_json::to_value(&problem).unwrap();
186    /// assert_eq!(v["instance"], "/account/12345/msgs/abc");
187    /// ```
188    pub fn with_instance(mut self, instance: impl Into<String>) -> Self {
189        self.instance = Some(instance.into());
190        self
191    }
192
193    /// Adds an RFC 9457 extension member, serialized as a top-level JSON key.
194    ///
195    /// If `key` is one of the reserved members (`"type"`, `"title"`,
196    /// `"status"`, `"detail"`, `"instance"`), the call is a silent no-op so
197    /// the flattened extensions can never emit duplicate JSON keys. This
198    /// mirrors the [`ApiError::with_source`] silent-no-op precedent.
199    ///
200    /// # Example
201    ///
202    /// ```rust
203    /// use axum::http::StatusCode;
204    /// use axum_api_kit::Problem;
205    ///
206    /// let problem = Problem::new(StatusCode::FORBIDDEN, "Insufficient credit")
207    ///     .with_extension("balance", 30)
208    ///     .with_extension("status", 999); // reserved key: ignored
209    /// let v = serde_json::to_value(&problem).unwrap();
210    /// assert_eq!(v["balance"], 30);
211    /// assert_eq!(v["status"], 403);
212    /// ```
213    pub fn with_extension(
214        mut self,
215        key: impl Into<String>,
216        value: impl Into<serde_json::Value>,
217    ) -> Self {
218        let key = key.into();
219        if matches!(
220            key.as_str(),
221            "type" | "title" | "status" | "detail" | "instance"
222        ) {
223            return self;
224        }
225        self.extensions.insert(key, value.into());
226        self
227    }
228
229    /// Emits a delay-seconds `Retry-After` header on the response, rounded up
230    /// to whole seconds (1500ms becomes `"2"`).
231    ///
232    /// The delay never appears in the JSON body; add it via
233    /// [`with_extension`](Self::with_extension) if body presence is wanted.
234    ///
235    /// # Example
236    ///
237    /// ```rust
238    /// use axum::{http::StatusCode, response::IntoResponse};
239    /// use axum_api_kit::Problem;
240    /// use std::time::Duration;
241    ///
242    /// let res = Problem::new(StatusCode::TOO_MANY_REQUESTS, "Too Many Requests")
243    ///     .with_retry_after(Duration::from_secs(30))
244    ///     .into_response();
245    /// assert_eq!(res.headers().get("retry-after").unwrap(), "30");
246    /// ```
247    pub fn with_retry_after(mut self, delay: std::time::Duration) -> Self {
248        self.retry_after = Some(delay);
249        self
250    }
251
252    /// Returns the `status` field as a [`StatusCode`], falling back to
253    /// `500 Internal Server Error` when it is not a valid status.
254    ///
255    /// `StatusCode::from_u16` accepts `100..=999`, so the fallback only
256    /// triggers for a hand-set `pub status` outside that range.
257    ///
258    /// # Example
259    ///
260    /// ```rust
261    /// use axum::http::StatusCode;
262    /// use axum_api_kit::Problem;
263    ///
264    /// let problem = Problem::new(StatusCode::NOT_FOUND, "Not Found");
265    /// assert_eq!(problem.status_code(), StatusCode::NOT_FOUND);
266    /// ```
267    pub fn status_code(&self) -> StatusCode {
268        StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)
269    }
270}
271
272impl IntoResponse for Problem {
273    fn into_response(self) -> Response {
274        // axum::Json hardcodes Content-Type: application/json, so the response
275        // is built via the (StatusCode, [(HeaderName, HeaderValue); 1], Vec<u8>)
276        // tuple instead; the header part's insert overrides the
277        // application/octet-stream default that Vec<u8> alone would set. The
278        // same tuple pattern is used by Created::with_location in success.rs.
279        // On the practically unreachable serialization failure, both the status
280        // line and the body say 500 so they stay consistent.
281        let retry = self.retry_after;
282        let (status, body) = match serde_json::to_vec(&self) {
283            Ok(bytes) => (self.status_code(), bytes),
284            Err(_) => (
285                StatusCode::INTERNAL_SERVER_ERROR,
286                br#"{"title":"Internal Server Error","status":500}"#.to_vec(),
287            ),
288        };
289        let mut res = (
290            status,
291            [(
292                header::CONTENT_TYPE,
293                HeaderValue::from_static(APPLICATION_PROBLEM_JSON),
294            )],
295            body,
296        )
297            .into_response();
298        if let Some(d) = retry {
299            res.headers_mut().insert(
300                header::RETRY_AFTER,
301                HeaderValue::from(crate::error::ceil_secs(d)),
302            );
303        }
304        res
305    }
306}
307
308/// Convert a `(StatusCode, ApiError)` pair into a [`Problem`], losslessly.
309///
310/// | source | `Problem` member |
311/// |---|---|
312/// | status | `status` |
313/// | status canonical reason | `title` (falls back to `code` for nonstandard statuses; cosmetic) |
314/// | `message` | `detail` |
315/// | `code` | `"code"` extension member |
316/// | `details` (when present) | `"details"` extension member, verbatim |
317///
318/// `details` is kept under the single `"details"` key, never flattened, so
319/// validation field maps cannot collide with reserved members. `type` and
320/// `instance` are left absent, and no `Retry-After` header is set.
321impl From<(StatusCode, ApiError)> for Problem {
322    fn from((status, err): (StatusCode, ApiError)) -> Self {
323        let title = status
324            .canonical_reason()
325            .map(str::to_owned)
326            .unwrap_or_else(|| err.code.clone());
327        let mut extensions = serde_json::Map::new();
328        extensions.insert("code".to_owned(), serde_json::Value::String(err.code));
329        if let Some(details) = err.details {
330            extensions.insert("details".to_owned(), details);
331        }
332        Self {
333            type_uri: None,
334            title,
335            status: status.as_u16(),
336            detail: Some(err.message),
337            instance: None,
338            extensions,
339            retry_after: None,
340        }
341    }
342}
343
344/// Convert a `(StatusCode, Json<ApiError>)` factory tuple into a [`Problem`].
345///
346/// Unwraps the [`Json`] and delegates to [`From<(StatusCode, ApiError)>`], so
347/// existing factory results convert directly:
348/// `Problem::from(ApiError::not_found("nope"))`.
349impl From<(StatusCode, Json<ApiError>)> for Problem {
350    fn from((status, Json(err)): (StatusCode, Json<ApiError>)) -> Self {
351        Problem::from((status, err))
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358    use serde_json::json;
359    use std::time::Duration;
360
361    #[test]
362    fn minimal_serialization_omits_optional_members() {
363        let problem = Problem::new(StatusCode::NOT_FOUND, "Not Found");
364        let v = serde_json::to_value(&problem).unwrap();
365        assert_eq!(v, json!({ "title": "Not Found", "status": 404 }));
366        assert!(v.get("type").is_none());
367        assert!(v.get("detail").is_none());
368        assert!(v.get("instance").is_none());
369    }
370
371    #[test]
372    fn full_shape_serializes_all_rfc_members() {
373        let problem = Problem::new(StatusCode::FORBIDDEN, "Insufficient credit")
374            .with_type("https://example.com/probs/out-of-credit")
375            .with_detail("Balance is 30, item costs 50")
376            .with_instance("/account/12345/msgs/abc");
377        let v = serde_json::to_value(&problem).unwrap();
378        assert_eq!(v["type"], "https://example.com/probs/out-of-credit");
379        assert_eq!(v["title"], "Insufficient credit");
380        assert_eq!(v["status"], 403);
381        assert_eq!(v["detail"], "Balance is 30, item costs 50");
382        assert_eq!(v["instance"], "/account/12345/msgs/abc");
383        assert!(v.get("type_uri").is_none());
384    }
385
386    #[test]
387    fn extensions_flatten_to_top_level() {
388        let problem = Problem::new(StatusCode::FORBIDDEN, "Insufficient credit")
389            .with_extension("balance", 30);
390        let v = serde_json::to_value(&problem).unwrap();
391        assert_eq!(v["balance"], 30);
392        assert!(v.get("extensions").is_none());
393    }
394
395    #[test]
396    fn with_extension_ignores_reserved_keys() {
397        let problem = Problem::new(StatusCode::NOT_FOUND, "Not Found")
398            .with_extension("status", 999)
399            .with_extension("type", "https://example.com/overridden")
400            .with_extension("title", "Overridden")
401            .with_extension("detail", "overridden")
402            .with_extension("instance", "/overridden");
403        let v = serde_json::to_value(&problem).unwrap();
404        assert_eq!(v, json!({ "title": "Not Found", "status": 404 }));
405    }
406
407    #[test]
408    fn retry_after_never_serialized() {
409        let problem = Problem::new(StatusCode::TOO_MANY_REQUESTS, "Too Many Requests")
410            .with_retry_after(Duration::from_secs(30));
411        let v = serde_json::to_value(&problem).unwrap();
412        assert!(v.get("retry_after").is_none());
413    }
414
415    #[tokio::test]
416    async fn into_response_status_content_type_body() {
417        let res = Problem::new(StatusCode::FORBIDDEN, "Insufficient credit")
418            .with_type("https://example.com/probs/out-of-credit")
419            .with_detail("Balance is 30, item costs 50")
420            .with_instance("/account/12345/msgs/abc")
421            .with_extension("balance", 30)
422            .into_response();
423        assert_eq!(res.status(), StatusCode::FORBIDDEN);
424        assert_eq!(
425            res.headers().get(header::CONTENT_TYPE).unwrap(),
426            APPLICATION_PROBLEM_JSON
427        );
428        let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
429            .await
430            .unwrap();
431        let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
432        assert_eq!(
433            body,
434            json!({
435                "type": "https://example.com/probs/out-of-credit",
436                "title": "Insufficient credit",
437                "status": 403,
438                "detail": "Balance is 30, item costs 50",
439                "instance": "/account/12345/msgs/abc",
440                "balance": 30
441            })
442        );
443    }
444
445    #[tokio::test]
446    async fn retry_after_header_seconds() {
447        let res = Problem::new(StatusCode::TOO_MANY_REQUESTS, "Too Many Requests")
448            .with_retry_after(Duration::from_secs(30))
449            .into_response();
450        assert_eq!(res.headers().get(header::RETRY_AFTER).unwrap(), "30");
451
452        let res = Problem::new(StatusCode::TOO_MANY_REQUESTS, "Too Many Requests")
453            .with_retry_after(Duration::from_millis(1500))
454            .into_response();
455        assert_eq!(res.headers().get(header::RETRY_AFTER).unwrap(), "2");
456    }
457
458    #[test]
459    fn from_status_apierror_maps_fields() {
460        let err =
461            ApiError::new("NOT_FOUND", "item 42 does not exist").with_details(json!({ "id": 42 }));
462        let problem = Problem::from((StatusCode::NOT_FOUND, err));
463        assert_eq!(problem.title, "Not Found");
464        assert_eq!(problem.status, 404);
465        assert_eq!(problem.detail.as_deref(), Some("item 42 does not exist"));
466        assert_eq!(problem.extensions["code"], "NOT_FOUND");
467        assert_eq!(problem.extensions["details"], json!({ "id": 42 }));
468
469        let no_details = Problem::from((StatusCode::NOT_FOUND, ApiError::new("NOT_FOUND", "nope")));
470        assert!(!no_details.extensions.contains_key("details"));
471    }
472
473    #[test]
474    fn from_status_json_apierror_unwraps() {
475        let via_factory = Problem::from(ApiError::not_found("nope"));
476        let direct = Problem::from((StatusCode::NOT_FOUND, ApiError::new("NOT_FOUND", "nope")));
477        assert_eq!(
478            serde_json::to_value(&via_factory).unwrap(),
479            serde_json::to_value(&direct).unwrap()
480        );
481    }
482
483    #[test]
484    fn status_code_falls_back_to_500() {
485        // StatusCode::from_u16 accepts 100..=999, so use a value below 100.
486        let problem = Problem {
487            type_uri: None,
488            title: "weird".to_owned(),
489            status: 42,
490            detail: None,
491            instance: None,
492            extensions: serde_json::Map::new(),
493            retry_after: None,
494        };
495        assert_eq!(problem.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
496    }
497}