use std::collections::BTreeMap;
use axum::http::StatusCode;
use axum::http::header::{CONTENT_LENGTH, CONTENT_TYPE};
use axum::response::{IntoResponse, Response};
use serde::{Serialize, Serializer};
use serde_json::{Number, Value};
use super::kind::ProblemKind;
pub const PROBLEM_JSON: &str = "application/problem+json";
const FALLBACK_BODY: &str =
r#"{"type":"urn:arcature:problem:internal","title":"Internal server error","status":500}"#;
const STANDARD_MEMBERS: [&str; 5] = ["type", "title", "status", "detail", "instance"];
#[derive(Debug, Clone)]
pub struct Problem {
kind: ProblemKind,
custom: Option<Box<CustomParts>>,
detail: Option<String>,
instance: Option<String>,
extensions: BTreeMap<String, Value>,
}
#[derive(Debug, Clone)]
struct CustomParts {
type_uri: String,
title: String,
status: StatusCode,
}
impl Problem {
#[must_use]
pub fn of(kind: ProblemKind) -> Self {
Self {
kind,
custom: None,
detail: None,
instance: None,
extensions: BTreeMap::new(),
}
}
#[must_use]
pub fn builder(kind: ProblemKind) -> ProblemBuilder {
ProblemBuilder::new(kind)
}
#[must_use]
pub fn custom<T>(type_uri: T, status: StatusCode) -> Self
where
T: Into<String>,
{
Self {
kind: ProblemKind::Internal,
custom: Some(Box::new(CustomParts {
type_uri: type_uri.into(),
title: reason_phrase(status),
status,
})),
detail: None,
instance: None,
extensions: BTreeMap::new(),
}
}
#[must_use]
pub fn with_detail<D>(mut self, detail: D) -> Self
where
D: Into<String>,
{
self.detail = Some(detail.into());
self
}
#[must_use]
pub fn with_instance<I>(mut self, instance: I) -> Self
where
I: Into<String>,
{
self.instance = Some(instance.into());
self
}
#[must_use]
pub fn with_extension<V>(mut self, key: &str, value: V) -> Self
where
V: Serialize,
{
if let Some(value) = serialize_extension(key, value) {
self.extensions.insert(key.to_string(), value);
}
self
}
#[must_use]
pub fn with_extensions<E>(mut self, entries: &E) -> Self
where
E: Serialize,
{
extend_from(&mut self.extensions, entries);
self
}
#[must_use]
pub fn status(&self) -> StatusCode {
self.custom
.as_ref()
.map(|c| c.status)
.unwrap_or_else(|| self.kind.status())
}
#[must_use]
pub fn type_uri(&self) -> &str {
self.custom
.as_ref()
.map(|c| c.type_uri.as_str())
.unwrap_or_else(|| self.kind.type_uri())
}
#[must_use]
pub fn title(&self) -> &str {
self.custom
.as_ref()
.map(|c| c.title.as_str())
.unwrap_or_else(|| self.kind.title())
}
#[must_use]
pub fn to_json(&self) -> Value {
serialize_problem(self)
}
}
impl IntoResponse for Problem {
fn into_response(self) -> Response {
let status = self.status();
let body = serde_json::to_vec(&self).unwrap_or_else(|_| FALLBACK_BODY.as_bytes().to_vec());
let len = body.len();
let mut response = (status, body).into_response();
response.headers_mut().insert(
CONTENT_TYPE,
axum::http::HeaderValue::from_static(PROBLEM_JSON),
);
response
.headers_mut()
.insert(CONTENT_LENGTH, axum::http::HeaderValue::from(len));
response
}
}
#[derive(Debug, Clone)]
pub struct ProblemBuilder {
problem: Problem,
}
impl ProblemBuilder {
#[must_use]
pub fn new(kind: ProblemKind) -> Self {
Self {
problem: Problem::of(kind),
}
}
#[must_use]
pub fn detail<D>(mut self, detail: D) -> Self
where
D: Into<String>,
{
self.problem.detail = Some(detail.into());
self
}
#[must_use]
pub fn instance<I>(mut self, instance: I) -> Self
where
I: Into<String>,
{
self.problem.instance = Some(instance.into());
self
}
#[must_use]
pub fn extension<V>(mut self, key: &str, value: V) -> Self
where
V: Serialize,
{
if let Some(value) = serialize_extension(key, value) {
self.problem.extensions.insert(key.to_string(), value);
}
self
}
#[must_use]
pub fn extensions<E>(mut self, entries: &E) -> Self
where
E: Serialize,
{
extend_from(&mut self.problem.extensions, entries);
self
}
#[must_use]
pub fn build(self) -> Problem {
self.problem
}
}
fn is_standard_member(name: &str) -> bool {
STANDARD_MEMBERS.contains(&name)
}
fn serialize_extension<V>(key: &str, value: V) -> Option<Value>
where
V: Serialize,
{
if is_standard_member(key) {
return None;
}
match serde_json::to_value(&value) {
Ok(Value::Null) => None,
Ok(value) => Some(value),
Err(_) => None,
}
}
fn extend_from<E>(extensions: &mut BTreeMap<String, Value>, entries: &E)
where
E: Serialize,
{
if let Ok(Value::Object(map)) = serde_json::to_value(entries) {
for (key, value) in map {
if is_standard_member(&key) || value.is_null() {
continue;
}
extensions.insert(key, value);
}
}
}
fn reason_phrase(status: StatusCode) -> String {
status
.canonical_reason()
.unwrap_or("Request error")
.to_string()
}
fn serialize_problem(problem: &Problem) -> Value {
let mut map = serde_json::Map::new();
map.insert(
"type".to_string(),
Value::String(problem.type_uri().to_string()),
);
map.insert(
"title".to_string(),
Value::String(problem.title().to_string()),
);
map.insert(
"status".to_string(),
Value::Number(Number::from(problem.status().as_u16())),
);
if let Some(detail) = &problem.detail {
map.insert("detail".to_string(), Value::String(detail.clone()));
}
if let Some(instance) = &problem.instance {
map.insert("instance".to_string(), Value::String(instance.clone()));
}
for (key, value) in &problem.extensions {
map.insert(key.clone(), value.clone());
}
Value::Object(map)
}
impl Serialize for Problem {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serialize_problem(self).serialize(serializer)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn standard_problem_serializes_all_members() {
let problem = Problem::of(ProblemKind::NotFound).with_detail("user 42 missing");
let value = problem.to_json();
assert_eq!(value["type"], "urn:arcature:problem:not-found");
assert_eq!(value["title"], "Resource not found");
assert_eq!(value["status"], 404);
assert_eq!(value["detail"], "user 42 missing");
assert!(value.get("instance").is_none());
}
#[test]
fn omitted_members_are_absent() {
let value = Problem::of(ProblemKind::Conflict).to_json();
assert!(value.get("detail").is_none());
assert!(value.get("instance").is_none());
}
#[test]
fn extensions_are_serialized() {
let problem = Problem::of(ProblemKind::Validation)
.with_extension("errors", serde_json::json!({"name": ["required"]}));
let value = problem.to_json();
assert_eq!(value["errors"]["name"][0], "required");
}
#[test]
fn standard_member_keys_are_rejected_as_extensions() {
let problem = Problem::of(ProblemKind::Internal)
.with_extension("type", "attacker")
.with_extension("status", 200);
let value = problem.to_json();
assert_eq!(value["type"], "urn:arcature:problem:internal");
assert_eq!(value["status"], 500);
assert!(value.get("attacker").is_none());
}
#[test]
fn null_extension_values_are_dropped() {
let problem = Problem::of(ProblemKind::Internal).with_extension("trace", Value::Null);
let value = problem.to_json();
assert!(value.get("trace").is_none());
}
#[test]
fn builder_builds_equivalent_problem() {
let built = Problem::builder(ProblemKind::NotFound)
.detail("missing")
.instance("/users/42")
.extension("retry", false)
.build();
let value = built.to_json();
assert_eq!(value["status"], 404);
assert_eq!(value["detail"], "missing");
assert_eq!(value["instance"], "/users/42");
assert_eq!(value["retry"], false);
}
#[test]
fn custom_problem_uses_explicit_type_and_status() {
let problem = Problem::custom(
"https://example.com/probs/out-of-credit",
StatusCode::PAYMENT_REQUIRED,
)
.with_detail("insufficient credit");
let value = problem.to_json();
assert_eq!(value["type"], "https://example.com/probs/out-of-credit");
assert_eq!(value["status"], 402);
assert_eq!(value["detail"], "insufficient credit");
assert_eq!(value["title"], "Payment Required");
}
#[test]
fn about_blank_custom_uses_reason_phrase_title() {
let problem = Problem::custom("about:blank", StatusCode::BAD_REQUEST);
let value = problem.to_json();
assert_eq!(value["type"], "about:blank");
assert_eq!(value["title"], "Bad Request");
assert_eq!(value["status"], 400);
}
}