use std::convert::Infallible;
use axum::{
extract::FromRequestParts,
http::{header, request::Parts, HeaderMap, HeaderValue, StatusCode},
response::{IntoResponse, Response},
Json,
};
use serde::Serialize;
use crate::ApiError;
pub const APPLICATION_PROBLEM_JSON: &str = "application/problem+json";
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct Problem {
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub type_uri: Option<String>,
pub title: String,
pub status: u16,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub instance: Option<String>,
#[serde(flatten)]
#[cfg_attr(feature = "openapi", schema(value_type = Object))]
pub extensions: serde_json::Map<String, serde_json::Value>,
#[serde(skip)]
#[cfg_attr(feature = "openapi", schema(ignore))]
pub retry_after: Option<std::time::Duration>,
}
impl Problem {
pub fn new(status: StatusCode, title: impl Into<String>) -> Self {
Self {
type_uri: None,
title: title.into(),
status: status.as_u16(),
detail: None,
instance: None,
extensions: serde_json::Map::new(),
retry_after: None,
}
}
pub fn with_type(mut self, type_uri: impl Into<String>) -> Self {
self.type_uri = Some(type_uri.into());
self
}
pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
self.detail = Some(detail.into());
self
}
pub fn with_instance(mut self, instance: impl Into<String>) -> Self {
self.instance = Some(instance.into());
self
}
pub fn with_extension(
mut self,
key: impl Into<String>,
value: impl Into<serde_json::Value>,
) -> Self {
let key = key.into();
if matches!(
key.as_str(),
"type" | "title" | "status" | "detail" | "instance"
) {
return self;
}
self.extensions.insert(key, value.into());
self
}
pub fn with_retry_after(mut self, delay: std::time::Duration) -> Self {
self.retry_after = Some(delay);
self
}
pub fn status_code(&self) -> StatusCode {
StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)
}
pub fn into_response_for(self, headers: &HeaderMap) -> Response {
self.into_response_with(ProblemFormat::negotiate(headers))
}
pub fn into_response_with(self, format: ProblemFormat) -> Response {
let retry = self.retry_after;
let (status, body) = match serde_json::to_vec(&self) {
Ok(bytes) => (self.status_code(), bytes),
Err(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
br#"{"title":"Internal Server Error","status":500}"#.to_vec(),
),
};
let mut res = (
status,
[(
header::CONTENT_TYPE,
HeaderValue::from_static(format.content_type()),
)],
body,
)
.into_response();
if let Some(d) = retry {
res.headers_mut().insert(
header::RETRY_AFTER,
HeaderValue::from(crate::error::ceil_secs(d)),
);
}
res
}
}
impl IntoResponse for Problem {
fn into_response(self) -> Response {
self.into_response_with(ProblemFormat::ProblemJson)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ProblemFormat {
#[default]
ProblemJson,
Json,
}
impl ProblemFormat {
pub fn negotiate(headers: &HeaderMap) -> Self {
let mut problem = (0u8, 0u16);
let mut json = (0u8, 0u16);
for value in headers.get_all(header::ACCEPT) {
let Ok(value) = value.to_str() else {
continue;
};
for range in value.split(',') {
let mut parts = range.split(';');
let media = parts.next().unwrap_or("").trim().to_ascii_lowercase();
let mut q = 1000u16; let mut malformed = false;
for param in parts {
let (key, val) = match param.split_once('=') {
Some((key, val)) => (key.trim(), Some(val.trim())),
None => (param.trim(), None),
};
if key.eq_ignore_ascii_case("q") {
match val.and_then(parse_qvalue) {
Some(thousandths) => q = thousandths,
None => malformed = true,
}
break; }
}
if malformed {
continue;
}
let (specificity, to_problem, to_json) = match media.as_str() {
"application/problem+json" => (3u8, true, false),
"application/json" => (3, false, true),
"application/*" => (2, true, true),
"*/*" => (1, true, true),
_ => continue,
};
let update = |slot: &mut (u8, u16)| {
if specificity > slot.0 {
*slot = (specificity, q);
} else if specificity == slot.0 && q > slot.1 {
slot.1 = q;
}
};
if to_problem {
update(&mut problem);
}
if to_json {
update(&mut json);
}
}
}
if json.1 > problem.1 {
Self::Json
} else {
Self::ProblemJson
}
}
pub fn content_type(self) -> &'static str {
match self {
Self::ProblemJson => APPLICATION_PROBLEM_JSON,
Self::Json => "application/json",
}
}
}
impl<S> FromRequestParts<S> for ProblemFormat
where
S: Send + Sync,
{
type Rejection = Infallible;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
Ok(Self::negotiate(&parts.headers))
}
}
fn parse_qvalue(s: &str) -> Option<u16> {
let (int, frac) = match s.split_once('.') {
Some((int, frac)) => (int, frac),
None => (s, ""),
};
if frac.len() > 3 || !frac.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
match int {
"0" => {
let mut thousandths = 0u16;
for digit in frac.bytes() {
thousandths = thousandths * 10 + u16::from(digit - b'0');
}
for _ in frac.len()..3 {
thousandths *= 10;
}
Some(thousandths)
}
"1" => frac.bytes().all(|digit| digit == b'0').then_some(1000),
_ => None,
}
}
impl From<(StatusCode, ApiError)> for Problem {
fn from((status, err): (StatusCode, ApiError)) -> Self {
let title = status
.canonical_reason()
.map(str::to_owned)
.unwrap_or_else(|| err.code.clone());
let mut extensions = serde_json::Map::new();
extensions.insert("code".to_owned(), serde_json::Value::String(err.code));
if let Some(details) = err.details {
extensions.insert("details".to_owned(), details);
}
Self {
type_uri: None,
title,
status: status.as_u16(),
detail: Some(err.message),
instance: None,
extensions,
retry_after: None,
}
}
}
impl From<(StatusCode, Json<ApiError>)> for Problem {
fn from((status, Json(err)): (StatusCode, Json<ApiError>)) -> Self {
Problem::from((status, err))
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::time::Duration;
#[test]
fn minimal_serialization_omits_optional_members() {
let problem = Problem::new(StatusCode::NOT_FOUND, "Not Found");
let v = serde_json::to_value(&problem).unwrap();
assert_eq!(v, json!({ "title": "Not Found", "status": 404 }));
assert!(v.get("type").is_none());
assert!(v.get("detail").is_none());
assert!(v.get("instance").is_none());
}
#[test]
fn full_shape_serializes_all_rfc_members() {
let problem = Problem::new(StatusCode::FORBIDDEN, "Insufficient credit")
.with_type("https://example.com/probs/out-of-credit")
.with_detail("Balance is 30, item costs 50")
.with_instance("/account/12345/msgs/abc");
let v = serde_json::to_value(&problem).unwrap();
assert_eq!(v["type"], "https://example.com/probs/out-of-credit");
assert_eq!(v["title"], "Insufficient credit");
assert_eq!(v["status"], 403);
assert_eq!(v["detail"], "Balance is 30, item costs 50");
assert_eq!(v["instance"], "/account/12345/msgs/abc");
assert!(v.get("type_uri").is_none());
}
#[test]
fn extensions_flatten_to_top_level() {
let problem = Problem::new(StatusCode::FORBIDDEN, "Insufficient credit")
.with_extension("balance", 30);
let v = serde_json::to_value(&problem).unwrap();
assert_eq!(v["balance"], 30);
assert!(v.get("extensions").is_none());
}
#[test]
fn with_extension_ignores_reserved_keys() {
let problem = Problem::new(StatusCode::NOT_FOUND, "Not Found")
.with_extension("status", 999)
.with_extension("type", "https://example.com/overridden")
.with_extension("title", "Overridden")
.with_extension("detail", "overridden")
.with_extension("instance", "/overridden");
let v = serde_json::to_value(&problem).unwrap();
assert_eq!(v, json!({ "title": "Not Found", "status": 404 }));
}
#[test]
fn retry_after_never_serialized() {
let problem = Problem::new(StatusCode::TOO_MANY_REQUESTS, "Too Many Requests")
.with_retry_after(Duration::from_secs(30));
let v = serde_json::to_value(&problem).unwrap();
assert!(v.get("retry_after").is_none());
}
#[tokio::test]
async fn into_response_status_content_type_body() {
let res = Problem::new(StatusCode::FORBIDDEN, "Insufficient credit")
.with_type("https://example.com/probs/out-of-credit")
.with_detail("Balance is 30, item costs 50")
.with_instance("/account/12345/msgs/abc")
.with_extension("balance", 30)
.into_response();
assert_eq!(res.status(), StatusCode::FORBIDDEN);
assert_eq!(
res.headers().get(header::CONTENT_TYPE).unwrap(),
APPLICATION_PROBLEM_JSON
);
let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap();
let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(
body,
json!({
"type": "https://example.com/probs/out-of-credit",
"title": "Insufficient credit",
"status": 403,
"detail": "Balance is 30, item costs 50",
"instance": "/account/12345/msgs/abc",
"balance": 30
})
);
}
#[tokio::test]
async fn retry_after_header_seconds() {
let res = Problem::new(StatusCode::TOO_MANY_REQUESTS, "Too Many Requests")
.with_retry_after(Duration::from_secs(30))
.into_response();
assert_eq!(res.headers().get(header::RETRY_AFTER).unwrap(), "30");
let res = Problem::new(StatusCode::TOO_MANY_REQUESTS, "Too Many Requests")
.with_retry_after(Duration::from_millis(1500))
.into_response();
assert_eq!(res.headers().get(header::RETRY_AFTER).unwrap(), "2");
}
#[test]
fn from_status_apierror_maps_fields() {
let err =
ApiError::new("NOT_FOUND", "item 42 does not exist").with_details(json!({ "id": 42 }));
let problem = Problem::from((StatusCode::NOT_FOUND, err));
assert_eq!(problem.title, "Not Found");
assert_eq!(problem.status, 404);
assert_eq!(problem.detail.as_deref(), Some("item 42 does not exist"));
assert_eq!(problem.extensions["code"], "NOT_FOUND");
assert_eq!(problem.extensions["details"], json!({ "id": 42 }));
let no_details = Problem::from((StatusCode::NOT_FOUND, ApiError::new("NOT_FOUND", "nope")));
assert!(!no_details.extensions.contains_key("details"));
}
#[test]
fn from_status_json_apierror_unwraps() {
let via_factory = Problem::from(ApiError::not_found("nope"));
let direct = Problem::from((StatusCode::NOT_FOUND, ApiError::new("NOT_FOUND", "nope")));
assert_eq!(
serde_json::to_value(&via_factory).unwrap(),
serde_json::to_value(&direct).unwrap()
);
}
#[test]
fn status_code_falls_back_to_500() {
let problem = Problem {
type_uri: None,
title: "weird".to_owned(),
status: 42,
detail: None,
instance: None,
extensions: serde_json::Map::new(),
retry_after: None,
};
assert_eq!(problem.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
}
fn accept(value: &'static str) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(header::ACCEPT, HeaderValue::from_static(value));
headers
}
#[test]
fn qvalue_grammar() {
assert_eq!(parse_qvalue("1"), Some(1000));
assert_eq!(parse_qvalue("1."), Some(1000));
assert_eq!(parse_qvalue("1.0"), Some(1000));
assert_eq!(parse_qvalue("1.000"), Some(1000));
assert_eq!(parse_qvalue("0"), Some(0));
assert_eq!(parse_qvalue("0."), Some(0));
assert_eq!(parse_qvalue("0.5"), Some(500));
assert_eq!(parse_qvalue("0.85"), Some(850));
assert_eq!(parse_qvalue("0.855"), Some(855));
assert_eq!(parse_qvalue("1.001"), None);
assert_eq!(parse_qvalue("1.5"), None);
assert_eq!(parse_qvalue("0.8555"), None); assert_eq!(parse_qvalue(".5"), None);
assert_eq!(parse_qvalue(""), None);
assert_eq!(parse_qvalue("abc"), None);
assert_eq!(parse_qvalue("01"), None);
assert_eq!(parse_qvalue("-1"), None);
assert_eq!(parse_qvalue("0.5x"), None);
}
#[test]
fn negotiate_defaults_to_problem_json_in_every_ambiguous_case() {
assert_eq!(
ProblemFormat::negotiate(&HeaderMap::new()),
ProblemFormat::ProblemJson
);
for value in [
"*/*",
"application/*",
"application/problem+json",
"application/json, */*", "application/problem+json, application/json", "application/json;q=0.5, application/problem+json;q=0.5", "text/html", "application/json;q=0", "application/json;q=abc", "application/json;q", "application/json;q=1.5", "application/vnd.api+json", ";;;,,,", ] {
assert_eq!(
ProblemFormat::negotiate(&accept(value)),
ProblemFormat::ProblemJson,
"Accept: {value}"
);
}
}
#[test]
fn negotiate_serves_plain_json_on_strict_preference() {
for value in [
"application/json",
"Application/JSON", " application/json ; q=1 ",
"application/json;q=0.9, application/problem+json;q=0.5",
"application/json, application/problem+json;q=0.8",
"text/html;q=1, application/json;q=0.9", "*/*;q=0.1, application/json;q=0.2",
"application/*;q=1, application/problem+json;q=0.5",
] {
assert_eq!(
ProblemFormat::negotiate(&accept(value)),
ProblemFormat::Json,
"Accept: {value}"
);
}
}
#[test]
fn negotiate_keeps_problem_json_on_strict_preference_for_it() {
for value in [
"application/problem+json;q=0.9, application/json;q=0.5",
"application/json;q=0.1, application/problem+json",
"application/*;q=1, application/json;q=0.5", ] {
assert_eq!(
ProblemFormat::negotiate(&accept(value)),
ProblemFormat::ProblemJson,
"Accept: {value}"
);
}
}
#[test]
fn negotiate_scans_all_accept_headers() {
let mut headers = HeaderMap::new();
headers.append(header::ACCEPT, HeaderValue::from_static("text/html"));
headers.append(header::ACCEPT, HeaderValue::from_static("application/json"));
assert_eq!(ProblemFormat::negotiate(&headers), ProblemFormat::Json);
}
#[test]
fn negotiate_skips_non_utf8_accept_values() {
let mut headers = HeaderMap::new();
headers.insert(header::ACCEPT, HeaderValue::from_bytes(&[0xFF]).unwrap());
assert_eq!(
ProblemFormat::negotiate(&headers),
ProblemFormat::ProblemJson
);
}
#[test]
fn problem_format_default_and_content_types() {
assert_eq!(ProblemFormat::default(), ProblemFormat::ProblemJson);
assert_eq!(
ProblemFormat::ProblemJson.content_type(),
APPLICATION_PROBLEM_JSON
);
assert_eq!(ProblemFormat::Json.content_type(), "application/json");
}
#[tokio::test]
async fn into_response_with_json_changes_only_content_type() {
let problem = || {
Problem::new(StatusCode::FORBIDDEN, "Insufficient credit")
.with_detail("Balance is 30, item costs 50")
.with_extension("balance", 30)
.with_retry_after(Duration::from_secs(30))
};
let default = problem().into_response();
let negotiated = problem().into_response_with(ProblemFormat::Json);
assert_eq!(negotiated.status(), default.status());
assert_eq!(
negotiated.headers().get(header::CONTENT_TYPE).unwrap(),
"application/json"
);
assert_eq!(negotiated.headers().get(header::RETRY_AFTER).unwrap(), "30");
let default_body = axum::body::to_bytes(default.into_body(), usize::MAX)
.await
.unwrap();
let negotiated_body = axum::body::to_bytes(negotiated.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(
default_body, negotiated_body,
"bodies must be byte-identical across formats"
);
}
#[tokio::test]
async fn into_response_for_negotiates_from_headers() {
let res = Problem::new(StatusCode::NOT_FOUND, "Not Found")
.into_response_for(&accept("application/json"));
assert_eq!(
res.headers().get(header::CONTENT_TYPE).unwrap(),
"application/json"
);
let res =
Problem::new(StatusCode::NOT_FOUND, "Not Found").into_response_for(&HeaderMap::new());
assert_eq!(
res.headers().get(header::CONTENT_TYPE).unwrap(),
APPLICATION_PROBLEM_JSON
);
}
#[tokio::test]
async fn plain_into_response_is_unchanged_by_negotiation_support() {
let res = Problem::new(StatusCode::NOT_FOUND, "Not Found")
.with_retry_after(Duration::from_secs(30))
.into_response();
assert_eq!(res.status(), StatusCode::NOT_FOUND);
assert_eq!(res.headers().len(), 2);
assert_eq!(
res.headers().get(header::CONTENT_TYPE).unwrap(),
APPLICATION_PROBLEM_JSON
);
assert_eq!(res.headers().get(header::RETRY_AFTER).unwrap(), "30");
let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(&bytes[..], br#"{"title":"Not Found","status":404}"#);
let res = Problem::new(StatusCode::NOT_FOUND, "Not Found").into_response();
assert_eq!(res.status(), StatusCode::NOT_FOUND);
assert_eq!(res.headers().len(), 1);
assert_eq!(
res.headers().get(header::CONTENT_TYPE).unwrap(),
APPLICATION_PROBLEM_JSON
);
let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(&bytes[..], br#"{"title":"Not Found","status":404}"#);
}
}