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}