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//! # Content negotiation (opt-in)
26//!
27//! [`Problem`]'s plain [`IntoResponse`] impl always emits
28//! `Content-Type: application/problem+json`; that behavior is frozen and does
29//! not change for existing users. Handlers can opt into Accept-header
30//! negotiation, serving the same body bytes as plain `application/json` when
31//! the client strictly prefers it: extract [`ProblemFormat`] and finish with
32//! [`Problem::into_response_with`], or call [`Problem::into_response_for`]
33//! with the request's [`HeaderMap`]. The exact (deliberately minimal) rules
34//! live on [`ProblemFormat::negotiate`]; every ambiguous case, including no
35//! `Accept` header and `*/*`, stays `application/problem+json`.
36//!
37//! # problem+json extractor rejections (opt-in)
38//!
39//! The [`ProblemJson`](crate::ProblemJson) (features `problem` + `extract`)
40//! and [`ProblemValidatedJson`](crate::ProblemValidatedJson) (features
41//! `problem` + `validator`) extractors are the problem-flavored siblings of
42//! [`ApiJson`](crate::ApiJson) and [`ValidatedJson`](crate::ValidatedJson):
43//! same deserialization, validation, and status codes, but failures reject
44//! with an RFC 9457 body (`Content-Type` negotiated via [`ProblemFormat`]).
45//! The existing extractors' rejection bodies are frozen and never change; the
46//! format is chosen by naming the extractor in the handler signature.
47//!
48//! # Out of scope
49//!
50//! - HTTP-date `Retry-After` values (only delay-seconds are emitted).
51//!
52//! A candidate for a future minor release.
53
54use std::convert::Infallible;
55
56use axum::{
57    extract::FromRequestParts,
58    http::{header, request::Parts, HeaderMap, HeaderValue, StatusCode},
59    response::{IntoResponse, Response},
60    Json,
61};
62use serde::Serialize;
63
64use crate::ApiError;
65
66/// The `application/problem+json` media type from RFC 9457.
67///
68/// # Example
69///
70/// ```rust
71/// use axum_api_kit::APPLICATION_PROBLEM_JSON;
72///
73/// assert_eq!(APPLICATION_PROBLEM_JSON, "application/problem+json");
74/// ```
75pub const APPLICATION_PROBLEM_JSON: &str = "application/problem+json";
76
77/// An RFC 9457 problem details response body.
78///
79/// Serializes as:
80/// ```json
81/// { "title": "Not Found", "status": 404 }
82/// { "type": "https://example.com/probs/out-of-credit", "title": "Insufficient credit",
83///   "status": 403, "detail": "Balance is 30, item costs 50",
84///   "instance": "/account/12345/msgs/abc", "balance": 30 }
85/// ```
86///
87/// Implements [`IntoResponse`] with `Content-Type: application/problem+json`
88/// and an optional delay-seconds `Retry-After` header.
89///
90/// # Example
91///
92/// ```rust
93/// use axum::{http::StatusCode, response::IntoResponse};
94/// use axum_api_kit::Problem;
95///
96/// async fn handler() -> impl IntoResponse {
97///     Problem::new(StatusCode::FORBIDDEN, "Insufficient credit")
98///         .with_type("https://example.com/probs/out-of-credit")
99///         .with_detail("Balance is 30, item costs 50")
100///         .with_instance("/account/12345/msgs/abc")
101///         .with_extension("balance", 30)
102/// }
103/// ```
104#[derive(Debug, Clone, Serialize)]
105#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
106pub struct Problem {
107    /// A URI reference identifying the problem type. Absent means "about:blank" per RFC 9457.
108    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
109    pub type_uri: Option<String>,
110    /// A short, human-readable summary of the problem type. Stable per problem type.
111    pub title: String,
112    /// The HTTP status code, duplicated in the body per RFC 9457.
113    pub status: u16,
114    /// A human-readable explanation specific to this occurrence.
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub detail: Option<String>,
117    /// A URI reference identifying this specific occurrence.
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub instance: Option<String>,
120    /// RFC 9457 extension members, flattened to top-level JSON keys.
121    #[serde(flatten)]
122    #[cfg_attr(feature = "openapi", schema(value_type = Object))]
123    pub extensions: serde_json::Map<String, serde_json::Value>,
124    /// Optional Retry-After header delay. Header-only; never serialized in the body.
125    #[serde(skip)]
126    #[cfg_attr(feature = "openapi", schema(ignore))]
127    pub retry_after: Option<std::time::Duration>,
128}
129
130impl Problem {
131    /// Builds a minimal `Problem` with the given status and title.
132    ///
133    /// `type`, `detail`, and `instance` start absent, extensions start empty,
134    /// and no `Retry-After` header is emitted.
135    ///
136    /// # Example
137    ///
138    /// ```rust
139    /// use axum::http::StatusCode;
140    /// use axum_api_kit::Problem;
141    ///
142    /// let problem = Problem::new(StatusCode::NOT_FOUND, "Not Found");
143    /// assert_eq!(
144    ///     serde_json::to_value(&problem).unwrap(),
145    ///     serde_json::json!({ "title": "Not Found", "status": 404 })
146    /// );
147    /// ```
148    pub fn new(status: StatusCode, title: impl Into<String>) -> Self {
149        Self {
150            type_uri: None,
151            title: title.into(),
152            status: status.as_u16(),
153            detail: None,
154            instance: None,
155            extensions: serde_json::Map::new(),
156            retry_after: None,
157        }
158    }
159
160    /// Sets the `type` member: a URI reference identifying the problem type.
161    ///
162    /// # Example
163    ///
164    /// ```rust
165    /// use axum::http::StatusCode;
166    /// use axum_api_kit::Problem;
167    ///
168    /// let problem = Problem::new(StatusCode::FORBIDDEN, "Insufficient credit")
169    ///     .with_type("https://example.com/probs/out-of-credit");
170    /// let v = serde_json::to_value(&problem).unwrap();
171    /// assert_eq!(v["type"], "https://example.com/probs/out-of-credit");
172    /// ```
173    pub fn with_type(mut self, type_uri: impl Into<String>) -> Self {
174        self.type_uri = Some(type_uri.into());
175        self
176    }
177
178    /// Sets the `detail` member: a human-readable explanation specific to this
179    /// occurrence.
180    ///
181    /// # Example
182    ///
183    /// ```rust
184    /// use axum::http::StatusCode;
185    /// use axum_api_kit::Problem;
186    ///
187    /// let problem = Problem::new(StatusCode::FORBIDDEN, "Insufficient credit")
188    ///     .with_detail("Balance is 30, item costs 50");
189    /// let v = serde_json::to_value(&problem).unwrap();
190    /// assert_eq!(v["detail"], "Balance is 30, item costs 50");
191    /// ```
192    pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
193        self.detail = Some(detail.into());
194        self
195    }
196
197    /// Sets the `instance` member: a URI reference identifying this specific
198    /// occurrence.
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_instance("/account/12345/msgs/abc");
208    /// let v = serde_json::to_value(&problem).unwrap();
209    /// assert_eq!(v["instance"], "/account/12345/msgs/abc");
210    /// ```
211    pub fn with_instance(mut self, instance: impl Into<String>) -> Self {
212        self.instance = Some(instance.into());
213        self
214    }
215
216    /// Adds an RFC 9457 extension member, serialized as a top-level JSON key.
217    ///
218    /// If `key` is one of the reserved members (`"type"`, `"title"`,
219    /// `"status"`, `"detail"`, `"instance"`), the call is a silent no-op so
220    /// the flattened extensions can never emit duplicate JSON keys. This
221    /// mirrors the [`ApiError::with_source`] silent-no-op precedent.
222    ///
223    /// # Example
224    ///
225    /// ```rust
226    /// use axum::http::StatusCode;
227    /// use axum_api_kit::Problem;
228    ///
229    /// let problem = Problem::new(StatusCode::FORBIDDEN, "Insufficient credit")
230    ///     .with_extension("balance", 30)
231    ///     .with_extension("status", 999); // reserved key: ignored
232    /// let v = serde_json::to_value(&problem).unwrap();
233    /// assert_eq!(v["balance"], 30);
234    /// assert_eq!(v["status"], 403);
235    /// ```
236    pub fn with_extension(
237        mut self,
238        key: impl Into<String>,
239        value: impl Into<serde_json::Value>,
240    ) -> Self {
241        let key = key.into();
242        if matches!(
243            key.as_str(),
244            "type" | "title" | "status" | "detail" | "instance"
245        ) {
246            return self;
247        }
248        self.extensions.insert(key, value.into());
249        self
250    }
251
252    /// Emits a delay-seconds `Retry-After` header on the response, rounded up
253    /// to whole seconds (1500ms becomes `"2"`).
254    ///
255    /// The delay never appears in the JSON body; add it via
256    /// [`with_extension`](Self::with_extension) if body presence is wanted.
257    ///
258    /// # Example
259    ///
260    /// ```rust
261    /// use axum::{http::StatusCode, response::IntoResponse};
262    /// use axum_api_kit::Problem;
263    /// use std::time::Duration;
264    ///
265    /// let res = Problem::new(StatusCode::TOO_MANY_REQUESTS, "Too Many Requests")
266    ///     .with_retry_after(Duration::from_secs(30))
267    ///     .into_response();
268    /// assert_eq!(res.headers().get("retry-after").unwrap(), "30");
269    /// ```
270    pub fn with_retry_after(mut self, delay: std::time::Duration) -> Self {
271        self.retry_after = Some(delay);
272        self
273    }
274
275    /// Returns the `status` field as a [`StatusCode`], falling back to
276    /// `500 Internal Server Error` when it is not a valid status.
277    ///
278    /// `StatusCode::from_u16` accepts `100..=999`, so the fallback only
279    /// triggers for a hand-set `pub status` outside that range.
280    ///
281    /// # Example
282    ///
283    /// ```rust
284    /// use axum::http::StatusCode;
285    /// use axum_api_kit::Problem;
286    ///
287    /// let problem = Problem::new(StatusCode::NOT_FOUND, "Not Found");
288    /// assert_eq!(problem.status_code(), StatusCode::NOT_FOUND);
289    /// ```
290    pub fn status_code(&self) -> StatusCode {
291        StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)
292    }
293
294    /// Converts into a response, choosing the `Content-Type` by negotiating
295    /// against the request's `Accept` headers.
296    ///
297    /// Shorthand for `self.into_response_with(ProblemFormat::negotiate(headers))`;
298    /// see [`ProblemFormat::negotiate`] for the exact rules. Plain
299    /// `application/json` is served only when the client strictly prefers it;
300    /// every ambiguous case (no `Accept` header, `*/*`, ties, unparseable
301    /// values) serves `application/problem+json`. The body bytes and any
302    /// `Retry-After` header are identical either way, and the plain
303    /// [`IntoResponse`] behavior is untouched.
304    ///
305    /// # Example
306    ///
307    /// ```rust
308    /// use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
309    /// use axum_api_kit::Problem;
310    ///
311    /// let mut headers = HeaderMap::new();
312    /// headers.insert(header::ACCEPT, HeaderValue::from_static("application/json"));
313    ///
314    /// let res = Problem::new(StatusCode::NOT_FOUND, "Not Found").into_response_for(&headers);
315    /// assert_eq!(res.headers().get(header::CONTENT_TYPE).unwrap(), "application/json");
316    ///
317    /// let res = Problem::new(StatusCode::NOT_FOUND, "Not Found").into_response_for(&HeaderMap::new());
318    /// assert_eq!(
319    ///     res.headers().get(header::CONTENT_TYPE).unwrap(),
320    ///     "application/problem+json"
321    /// );
322    /// ```
323    pub fn into_response_for(self, headers: &HeaderMap) -> Response {
324        self.into_response_with(ProblemFormat::negotiate(headers))
325    }
326
327    /// Converts into a response served as the given [`ProblemFormat`].
328    ///
329    /// Pairs with the [`ProblemFormat`] extractor, which negotiates the format
330    /// from the request's `Accept` headers before the handler runs. The JSON
331    /// body bytes and any `Retry-After` header are identical for both formats;
332    /// only the `Content-Type` header differs.
333    ///
334    /// # Example
335    ///
336    /// ```rust
337    /// use axum::http::{header, StatusCode};
338    /// use axum_api_kit::{Problem, ProblemFormat};
339    ///
340    /// let res = Problem::new(StatusCode::NOT_FOUND, "Not Found")
341    ///     .into_response_with(ProblemFormat::Json);
342    /// assert_eq!(res.headers().get(header::CONTENT_TYPE).unwrap(), "application/json");
343    /// ```
344    pub fn into_response_with(self, format: ProblemFormat) -> Response {
345        // axum::Json hardcodes Content-Type: application/json, so the response
346        // is built via the (StatusCode, [(HeaderName, HeaderValue); 1], Vec<u8>)
347        // tuple instead; the header part's insert overrides the
348        // application/octet-stream default that Vec<u8> alone would set. The
349        // same tuple pattern is used by Created::with_location in success.rs.
350        // On the practically unreachable serialization failure, both the status
351        // line and the body say 500 so they stay consistent.
352        let retry = self.retry_after;
353        let (status, body) = match serde_json::to_vec(&self) {
354            Ok(bytes) => (self.status_code(), bytes),
355            Err(_) => (
356                StatusCode::INTERNAL_SERVER_ERROR,
357                br#"{"title":"Internal Server Error","status":500}"#.to_vec(),
358            ),
359        };
360        let mut res = (
361            status,
362            [(
363                header::CONTENT_TYPE,
364                HeaderValue::from_static(format.content_type()),
365            )],
366            body,
367        )
368            .into_response();
369        if let Some(d) = retry {
370            res.headers_mut().insert(
371                header::RETRY_AFTER,
372                HeaderValue::from(crate::error::ceil_secs(d)),
373            );
374        }
375        res
376    }
377}
378
379impl IntoResponse for Problem {
380    fn into_response(self) -> Response {
381        // The frozen 1.x default: always application/problem+json. Content
382        // negotiation is opt-in via into_response_for / into_response_with.
383        self.into_response_with(ProblemFormat::ProblemJson)
384    }
385}
386
387/// The media type a [`Problem`] response is served as: the RFC 9457 default
388/// `application/problem+json`, or plain `application/json` for clients that
389/// explicitly prefer it.
390///
391/// This is the opt-in content negotiation half of the `problem` feature.
392/// [`Problem`]'s plain [`IntoResponse`] impl always emits
393/// `application/problem+json`; nothing changes for existing users. To
394/// negotiate, either extract `ProblemFormat` in a handler (it implements
395/// [`FromRequestParts`] infallibly, reading the `Accept` headers) and finish
396/// with [`Problem::into_response_with`], or call
397/// [`Problem::into_response_for`] with the request's [`HeaderMap`] directly.
398///
399/// Both formats serve byte-identical JSON bodies; only the `Content-Type`
400/// header differs. [`Default`] is [`ProblemFormat::ProblemJson`].
401///
402/// # Example
403///
404/// ```rust,no_run
405/// use axum::{http::StatusCode, response::Response, routing::get, Router};
406/// use axum_api_kit::{Problem, ProblemFormat};
407///
408/// // Accept: application/json          -> Content-Type: application/json
409/// // Accept: */* (or no Accept header) -> Content-Type: application/problem+json
410/// async fn not_found(format: ProblemFormat) -> Response {
411///     Problem::new(StatusCode::NOT_FOUND, "Not Found").into_response_with(format)
412/// }
413///
414/// let app: Router = Router::new().route("/missing", get(not_found));
415/// ```
416#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
417pub enum ProblemFormat {
418    /// Serve `Content-Type: application/problem+json` (the RFC 9457 media
419    /// type, and the default in every ambiguous case).
420    #[default]
421    ProblemJson,
422    /// Serve `Content-Type: application/json`, for clients whose `Accept`
423    /// header strictly prefers plain JSON.
424    Json,
425}
426
427impl ProblemFormat {
428    /// Chooses the response format from a request's `Accept` headers.
429    ///
430    /// This is a minimal, hand-rolled matcher, deliberately not a full
431    /// RFC 9110 implementation. The rules:
432    ///
433    /// 1. Recognized media ranges are `application/problem+json`,
434    ///    `application/json`, `application/*`, and `*/*` (ASCII
435    ///    case-insensitive, across all `Accept` headers). Every other range,
436    ///    including other `+json` suffix types, is ignored.
437    /// 2. A range's weight is its `q` parameter (default `1`). Parameters
438    ///    other than the first `q` are ignored. A range whose `q` value does
439    ///    not parse per the RFC 9110 `qvalue` grammar (`0` to `1` with at
440    ///    most three decimals) is ignored entirely.
441    /// 3. Each of the two servable types takes its weight from the most
442    ///    specific matching range (an exact match beats `application/*`,
443    ///    which beats `*/*`); among equally specific matches the highest `q`
444    ///    wins.
445    /// 4. [`ProblemFormat::Json`] is returned only when plain
446    ///    `application/json` ends up with a strictly higher weight than
447    ///    `application/problem+json`. Everything else (no `Accept` header,
448    ///    `*/*`, `application/*`, equal weights, `q=0` on both, unparseable
449    ///    headers) returns [`ProblemFormat::ProblemJson`].
450    ///
451    /// | `Accept` | result |
452    /// |---|---|
453    /// | (absent) | problem+json |
454    /// | `*/*` | problem+json |
455    /// | `application/*` | problem+json |
456    /// | `application/json` | plain JSON |
457    /// | `application/problem+json` | problem+json |
458    /// | `application/json, */*` | problem+json (tie at `q=1`) |
459    /// | `application/json;q=0.9, application/problem+json;q=0.5` | plain JSON |
460    /// | `application/problem+json, application/json` | problem+json (tie) |
461    /// | `text/html` | problem+json (neither matched) |
462    ///
463    /// # Example
464    ///
465    /// ```rust
466    /// use axum::http::{header, HeaderMap, HeaderValue};
467    /// use axum_api_kit::ProblemFormat;
468    ///
469    /// let mut headers = HeaderMap::new();
470    /// headers.insert(header::ACCEPT, HeaderValue::from_static("application/json"));
471    /// assert_eq!(ProblemFormat::negotiate(&headers), ProblemFormat::Json);
472    ///
473    /// assert_eq!(
474    ///     ProblemFormat::negotiate(&HeaderMap::new()),
475    ///     ProblemFormat::ProblemJson
476    /// );
477    /// ```
478    pub fn negotiate(headers: &HeaderMap) -> Self {
479        // Weight of the best matching range seen so far for each servable
480        // type: (specificity, q in thousandths). Specificity: 3 exact match,
481        // 2 application/*, 1 */*, 0 nothing matched yet.
482        let mut problem = (0u8, 0u16);
483        let mut json = (0u8, 0u16);
484
485        for value in headers.get_all(header::ACCEPT) {
486            let Ok(value) = value.to_str() else {
487                // Non-UTF8 header values cannot express a preference we
488                // recognize; skip them (the default then wins).
489                continue;
490            };
491            for range in value.split(',') {
492                let mut parts = range.split(';');
493                // split always yields at least one segment.
494                let media = parts.next().unwrap_or("").trim().to_ascii_lowercase();
495
496                let mut q = 1000u16; // qvalue defaults to 1 per RFC 9110
497                let mut malformed = false;
498                for param in parts {
499                    let (key, val) = match param.split_once('=') {
500                        Some((key, val)) => (key.trim(), Some(val.trim())),
501                        None => (param.trim(), None),
502                    };
503                    if key.eq_ignore_ascii_case("q") {
504                        match val.and_then(parse_qvalue) {
505                            Some(thousandths) => q = thousandths,
506                            None => malformed = true,
507                        }
508                        break; // the first q parameter decides
509                    }
510                }
511                if malformed {
512                    continue;
513                }
514
515                let (specificity, to_problem, to_json) = match media.as_str() {
516                    "application/problem+json" => (3u8, true, false),
517                    "application/json" => (3, false, true),
518                    "application/*" => (2, true, true),
519                    "*/*" => (1, true, true),
520                    _ => continue,
521                };
522                let update = |slot: &mut (u8, u16)| {
523                    if specificity > slot.0 {
524                        *slot = (specificity, q);
525                    } else if specificity == slot.0 && q > slot.1 {
526                        slot.1 = q;
527                    }
528                };
529                if to_problem {
530                    update(&mut problem);
531                }
532                if to_json {
533                    update(&mut json);
534                }
535            }
536        }
537
538        if json.1 > problem.1 {
539            Self::Json
540        } else {
541            Self::ProblemJson
542        }
543    }
544
545    /// The `Content-Type` value this format serves.
546    ///
547    /// # Example
548    ///
549    /// ```rust
550    /// use axum_api_kit::{ProblemFormat, APPLICATION_PROBLEM_JSON};
551    ///
552    /// assert_eq!(ProblemFormat::ProblemJson.content_type(), APPLICATION_PROBLEM_JSON);
553    /// assert_eq!(ProblemFormat::Json.content_type(), "application/json");
554    /// ```
555    pub fn content_type(self) -> &'static str {
556        match self {
557            Self::ProblemJson => APPLICATION_PROBLEM_JSON,
558            Self::Json => "application/json",
559        }
560    }
561}
562
563impl<S> FromRequestParts<S> for ProblemFormat
564where
565    S: Send + Sync,
566{
567    type Rejection = Infallible;
568
569    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
570        Ok(Self::negotiate(&parts.headers))
571    }
572}
573
574/// Parses an RFC 9110 `qvalue` into thousandths (`0..=1000`).
575///
576/// Grammar: `( "0" [ "." *3DIGIT ] ) / ( "1" [ "." *3("0") ] )`. Anything
577/// else (empty, `.5`, `1.5`, more than three decimals, stray characters)
578/// returns `None`, and [`ProblemFormat::negotiate`] ignores the whole media
579/// range that carried it.
580fn parse_qvalue(s: &str) -> Option<u16> {
581    let (int, frac) = match s.split_once('.') {
582        Some((int, frac)) => (int, frac),
583        None => (s, ""),
584    };
585    if frac.len() > 3 || !frac.bytes().all(|b| b.is_ascii_digit()) {
586        return None;
587    }
588    match int {
589        "0" => {
590            let mut thousandths = 0u16;
591            for digit in frac.bytes() {
592                thousandths = thousandths * 10 + u16::from(digit - b'0');
593            }
594            for _ in frac.len()..3 {
595                thousandths *= 10;
596            }
597            Some(thousandths)
598        }
599        "1" => frac.bytes().all(|digit| digit == b'0').then_some(1000),
600        _ => None,
601    }
602}
603
604/// Convert a `(StatusCode, ApiError)` pair into a [`Problem`], losslessly.
605///
606/// | source | `Problem` member |
607/// |---|---|
608/// | status | `status` |
609/// | status canonical reason | `title` (falls back to `code` for nonstandard statuses; cosmetic) |
610/// | `message` | `detail` |
611/// | `code` | `"code"` extension member |
612/// | `details` (when present) | `"details"` extension member, verbatim |
613///
614/// `details` is kept under the single `"details"` key, never flattened, so
615/// validation field maps cannot collide with reserved members. `type` and
616/// `instance` are left absent, and no `Retry-After` header is set.
617impl From<(StatusCode, ApiError)> for Problem {
618    fn from((status, err): (StatusCode, ApiError)) -> Self {
619        let title = status
620            .canonical_reason()
621            .map(str::to_owned)
622            .unwrap_or_else(|| err.code.clone());
623        let mut extensions = serde_json::Map::new();
624        extensions.insert("code".to_owned(), serde_json::Value::String(err.code));
625        if let Some(details) = err.details {
626            extensions.insert("details".to_owned(), details);
627        }
628        Self {
629            type_uri: None,
630            title,
631            status: status.as_u16(),
632            detail: Some(err.message),
633            instance: None,
634            extensions,
635            retry_after: None,
636        }
637    }
638}
639
640/// Convert a `(StatusCode, Json<ApiError>)` factory tuple into a [`Problem`].
641///
642/// Unwraps the [`Json`] and delegates to [`From<(StatusCode, ApiError)>`], so
643/// existing factory results convert directly:
644/// `Problem::from(ApiError::not_found("nope"))`.
645impl From<(StatusCode, Json<ApiError>)> for Problem {
646    fn from((status, Json(err)): (StatusCode, Json<ApiError>)) -> Self {
647        Problem::from((status, err))
648    }
649}
650
651#[cfg(test)]
652mod tests {
653    use super::*;
654    use serde_json::json;
655    use std::time::Duration;
656
657    #[test]
658    fn minimal_serialization_omits_optional_members() {
659        let problem = Problem::new(StatusCode::NOT_FOUND, "Not Found");
660        let v = serde_json::to_value(&problem).unwrap();
661        assert_eq!(v, json!({ "title": "Not Found", "status": 404 }));
662        assert!(v.get("type").is_none());
663        assert!(v.get("detail").is_none());
664        assert!(v.get("instance").is_none());
665    }
666
667    #[test]
668    fn full_shape_serializes_all_rfc_members() {
669        let problem = Problem::new(StatusCode::FORBIDDEN, "Insufficient credit")
670            .with_type("https://example.com/probs/out-of-credit")
671            .with_detail("Balance is 30, item costs 50")
672            .with_instance("/account/12345/msgs/abc");
673        let v = serde_json::to_value(&problem).unwrap();
674        assert_eq!(v["type"], "https://example.com/probs/out-of-credit");
675        assert_eq!(v["title"], "Insufficient credit");
676        assert_eq!(v["status"], 403);
677        assert_eq!(v["detail"], "Balance is 30, item costs 50");
678        assert_eq!(v["instance"], "/account/12345/msgs/abc");
679        assert!(v.get("type_uri").is_none());
680    }
681
682    #[test]
683    fn extensions_flatten_to_top_level() {
684        let problem = Problem::new(StatusCode::FORBIDDEN, "Insufficient credit")
685            .with_extension("balance", 30);
686        let v = serde_json::to_value(&problem).unwrap();
687        assert_eq!(v["balance"], 30);
688        assert!(v.get("extensions").is_none());
689    }
690
691    #[test]
692    fn with_extension_ignores_reserved_keys() {
693        let problem = Problem::new(StatusCode::NOT_FOUND, "Not Found")
694            .with_extension("status", 999)
695            .with_extension("type", "https://example.com/overridden")
696            .with_extension("title", "Overridden")
697            .with_extension("detail", "overridden")
698            .with_extension("instance", "/overridden");
699        let v = serde_json::to_value(&problem).unwrap();
700        assert_eq!(v, json!({ "title": "Not Found", "status": 404 }));
701    }
702
703    #[test]
704    fn retry_after_never_serialized() {
705        let problem = Problem::new(StatusCode::TOO_MANY_REQUESTS, "Too Many Requests")
706            .with_retry_after(Duration::from_secs(30));
707        let v = serde_json::to_value(&problem).unwrap();
708        assert!(v.get("retry_after").is_none());
709    }
710
711    #[tokio::test]
712    async fn into_response_status_content_type_body() {
713        let res = Problem::new(StatusCode::FORBIDDEN, "Insufficient credit")
714            .with_type("https://example.com/probs/out-of-credit")
715            .with_detail("Balance is 30, item costs 50")
716            .with_instance("/account/12345/msgs/abc")
717            .with_extension("balance", 30)
718            .into_response();
719        assert_eq!(res.status(), StatusCode::FORBIDDEN);
720        assert_eq!(
721            res.headers().get(header::CONTENT_TYPE).unwrap(),
722            APPLICATION_PROBLEM_JSON
723        );
724        let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
725            .await
726            .unwrap();
727        let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
728        assert_eq!(
729            body,
730            json!({
731                "type": "https://example.com/probs/out-of-credit",
732                "title": "Insufficient credit",
733                "status": 403,
734                "detail": "Balance is 30, item costs 50",
735                "instance": "/account/12345/msgs/abc",
736                "balance": 30
737            })
738        );
739    }
740
741    #[tokio::test]
742    async fn retry_after_header_seconds() {
743        let res = Problem::new(StatusCode::TOO_MANY_REQUESTS, "Too Many Requests")
744            .with_retry_after(Duration::from_secs(30))
745            .into_response();
746        assert_eq!(res.headers().get(header::RETRY_AFTER).unwrap(), "30");
747
748        let res = Problem::new(StatusCode::TOO_MANY_REQUESTS, "Too Many Requests")
749            .with_retry_after(Duration::from_millis(1500))
750            .into_response();
751        assert_eq!(res.headers().get(header::RETRY_AFTER).unwrap(), "2");
752    }
753
754    #[test]
755    fn from_status_apierror_maps_fields() {
756        let err =
757            ApiError::new("NOT_FOUND", "item 42 does not exist").with_details(json!({ "id": 42 }));
758        let problem = Problem::from((StatusCode::NOT_FOUND, err));
759        assert_eq!(problem.title, "Not Found");
760        assert_eq!(problem.status, 404);
761        assert_eq!(problem.detail.as_deref(), Some("item 42 does not exist"));
762        assert_eq!(problem.extensions["code"], "NOT_FOUND");
763        assert_eq!(problem.extensions["details"], json!({ "id": 42 }));
764
765        let no_details = Problem::from((StatusCode::NOT_FOUND, ApiError::new("NOT_FOUND", "nope")));
766        assert!(!no_details.extensions.contains_key("details"));
767    }
768
769    #[test]
770    fn from_status_json_apierror_unwraps() {
771        let via_factory = Problem::from(ApiError::not_found("nope"));
772        let direct = Problem::from((StatusCode::NOT_FOUND, ApiError::new("NOT_FOUND", "nope")));
773        assert_eq!(
774            serde_json::to_value(&via_factory).unwrap(),
775            serde_json::to_value(&direct).unwrap()
776        );
777    }
778
779    #[test]
780    fn status_code_falls_back_to_500() {
781        // StatusCode::from_u16 accepts 100..=999, so use a value below 100.
782        let problem = Problem {
783            type_uri: None,
784            title: "weird".to_owned(),
785            status: 42,
786            detail: None,
787            instance: None,
788            extensions: serde_json::Map::new(),
789            retry_after: None,
790        };
791        assert_eq!(problem.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
792    }
793
794    fn accept(value: &'static str) -> HeaderMap {
795        let mut headers = HeaderMap::new();
796        headers.insert(header::ACCEPT, HeaderValue::from_static(value));
797        headers
798    }
799
800    #[test]
801    fn qvalue_grammar() {
802        assert_eq!(parse_qvalue("1"), Some(1000));
803        assert_eq!(parse_qvalue("1."), Some(1000));
804        assert_eq!(parse_qvalue("1.0"), Some(1000));
805        assert_eq!(parse_qvalue("1.000"), Some(1000));
806        assert_eq!(parse_qvalue("0"), Some(0));
807        assert_eq!(parse_qvalue("0."), Some(0));
808        assert_eq!(parse_qvalue("0.5"), Some(500));
809        assert_eq!(parse_qvalue("0.85"), Some(850));
810        assert_eq!(parse_qvalue("0.855"), Some(855));
811
812        assert_eq!(parse_qvalue("1.001"), None);
813        assert_eq!(parse_qvalue("1.5"), None);
814        assert_eq!(parse_qvalue("0.8555"), None); // more than three decimals
815        assert_eq!(parse_qvalue(".5"), None);
816        assert_eq!(parse_qvalue(""), None);
817        assert_eq!(parse_qvalue("abc"), None);
818        assert_eq!(parse_qvalue("01"), None);
819        assert_eq!(parse_qvalue("-1"), None);
820        assert_eq!(parse_qvalue("0.5x"), None);
821    }
822
823    #[test]
824    fn negotiate_defaults_to_problem_json_in_every_ambiguous_case() {
825        // No Accept header at all.
826        assert_eq!(
827            ProblemFormat::negotiate(&HeaderMap::new()),
828            ProblemFormat::ProblemJson
829        );
830        for value in [
831            "*/*",
832            "application/*",
833            "application/problem+json",
834            "application/json, */*",                      // tie at q=1
835            "application/problem+json, application/json", // tie at q=1
836            "application/json;q=0.5, application/problem+json;q=0.5", // explicit tie
837            "text/html",                                  // neither matched
838            "application/json;q=0",                       // refused, nothing else
839            "application/json;q=abc",                     // malformed q: range ignored
840            "application/json;q",                         // bare q with no value: range ignored
841            "application/json;q=1.5", // q outside the RFC grammar: range ignored
842            "application/vnd.api+json", // +json suffix types are not matched
843            ";;;,,,",                 // unparseable garbage
844        ] {
845            assert_eq!(
846                ProblemFormat::negotiate(&accept(value)),
847                ProblemFormat::ProblemJson,
848                "Accept: {value}"
849            );
850        }
851    }
852
853    #[test]
854    fn negotiate_serves_plain_json_on_strict_preference() {
855        for value in [
856            "application/json",
857            "Application/JSON", // ASCII case-insensitive
858            " application/json ; q=1 ",
859            "application/json;q=0.9, application/problem+json;q=0.5",
860            "application/json, application/problem+json;q=0.8",
861            "text/html;q=1, application/json;q=0.9", // unrelated types are ignored
862            "*/*;q=0.1, application/json;q=0.2",
863            // Specificity: the exact problem+json match (q=0.5) beats the
864            // wildcard (q=1) for problem+json, so plain JSON wins at 1 > 0.5.
865            "application/*;q=1, application/problem+json;q=0.5",
866        ] {
867            assert_eq!(
868                ProblemFormat::negotiate(&accept(value)),
869                ProblemFormat::Json,
870                "Accept: {value}"
871            );
872        }
873    }
874
875    #[test]
876    fn negotiate_keeps_problem_json_on_strict_preference_for_it() {
877        for value in [
878            "application/problem+json;q=0.9, application/json;q=0.5",
879            "application/json;q=0.1, application/problem+json",
880            "application/*;q=1, application/json;q=0.5", // specificity, mirrored
881        ] {
882            assert_eq!(
883                ProblemFormat::negotiate(&accept(value)),
884                ProblemFormat::ProblemJson,
885                "Accept: {value}"
886            );
887        }
888    }
889
890    #[test]
891    fn negotiate_scans_all_accept_headers() {
892        let mut headers = HeaderMap::new();
893        headers.append(header::ACCEPT, HeaderValue::from_static("text/html"));
894        headers.append(header::ACCEPT, HeaderValue::from_static("application/json"));
895        assert_eq!(ProblemFormat::negotiate(&headers), ProblemFormat::Json);
896    }
897
898    #[test]
899    fn negotiate_skips_non_utf8_accept_values() {
900        let mut headers = HeaderMap::new();
901        headers.insert(header::ACCEPT, HeaderValue::from_bytes(&[0xFF]).unwrap());
902        assert_eq!(
903            ProblemFormat::negotiate(&headers),
904            ProblemFormat::ProblemJson
905        );
906    }
907
908    #[test]
909    fn problem_format_default_and_content_types() {
910        assert_eq!(ProblemFormat::default(), ProblemFormat::ProblemJson);
911        assert_eq!(
912            ProblemFormat::ProblemJson.content_type(),
913            APPLICATION_PROBLEM_JSON
914        );
915        assert_eq!(ProblemFormat::Json.content_type(), "application/json");
916    }
917
918    #[tokio::test]
919    async fn into_response_with_json_changes_only_content_type() {
920        let problem = || {
921            Problem::new(StatusCode::FORBIDDEN, "Insufficient credit")
922                .with_detail("Balance is 30, item costs 50")
923                .with_extension("balance", 30)
924                .with_retry_after(Duration::from_secs(30))
925        };
926        let default = problem().into_response();
927        let negotiated = problem().into_response_with(ProblemFormat::Json);
928
929        assert_eq!(negotiated.status(), default.status());
930        assert_eq!(
931            negotiated.headers().get(header::CONTENT_TYPE).unwrap(),
932            "application/json"
933        );
934        assert_eq!(negotiated.headers().get(header::RETRY_AFTER).unwrap(), "30");
935
936        let default_body = axum::body::to_bytes(default.into_body(), usize::MAX)
937            .await
938            .unwrap();
939        let negotiated_body = axum::body::to_bytes(negotiated.into_body(), usize::MAX)
940            .await
941            .unwrap();
942        assert_eq!(
943            default_body, negotiated_body,
944            "bodies must be byte-identical across formats"
945        );
946    }
947
948    #[tokio::test]
949    async fn into_response_for_negotiates_from_headers() {
950        let res = Problem::new(StatusCode::NOT_FOUND, "Not Found")
951            .into_response_for(&accept("application/json"));
952        assert_eq!(
953            res.headers().get(header::CONTENT_TYPE).unwrap(),
954            "application/json"
955        );
956
957        let res =
958            Problem::new(StatusCode::NOT_FOUND, "Not Found").into_response_for(&HeaderMap::new());
959        assert_eq!(
960            res.headers().get(header::CONTENT_TYPE).unwrap(),
961            APPLICATION_PROBLEM_JSON
962        );
963    }
964
965    #[tokio::test]
966    async fn plain_into_response_is_unchanged_by_negotiation_support() {
967        // Locks the frozen 1.x behavior byte-for-byte: exactly the status,
968        // header set, and body bytes Problem::into_response emitted in 1.3.0,
969        // regardless of the negotiation machinery added alongside it.
970        let res = Problem::new(StatusCode::NOT_FOUND, "Not Found")
971            .with_retry_after(Duration::from_secs(30))
972            .into_response();
973        assert_eq!(res.status(), StatusCode::NOT_FOUND);
974        assert_eq!(res.headers().len(), 2);
975        assert_eq!(
976            res.headers().get(header::CONTENT_TYPE).unwrap(),
977            APPLICATION_PROBLEM_JSON
978        );
979        assert_eq!(res.headers().get(header::RETRY_AFTER).unwrap(), "30");
980        let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
981            .await
982            .unwrap();
983        assert_eq!(&bytes[..], br#"{"title":"Not Found","status":404}"#);
984
985        // And the minimal case without Retry-After.
986        let res = Problem::new(StatusCode::NOT_FOUND, "Not Found").into_response();
987        assert_eq!(res.status(), StatusCode::NOT_FOUND);
988        assert_eq!(res.headers().len(), 1);
989        assert_eq!(
990            res.headers().get(header::CONTENT_TYPE).unwrap(),
991            APPLICATION_PROBLEM_JSON
992        );
993        let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
994            .await
995            .unwrap();
996        assert_eq!(&bytes[..], br#"{"title":"Not Found","status":404}"#);
997    }
998}