use axum::{
http::{header, 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)
}
}
impl IntoResponse for Problem {
fn into_response(self) -> 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(APPLICATION_PROBLEM_JSON),
)],
body,
)
.into_response();
if let Some(d) = retry {
res.headers_mut().insert(
header::RETRY_AFTER,
HeaderValue::from(crate::error::ceil_secs(d)),
);
}
res
}
}
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);
}
}