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}