use super::Error;
use crate::{
App,
http::{HttpBody, HttpResult, IntoResponse, StatusCode},
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
#[cfg(feature = "middleware")]
use crate::routing::{Route, RouteGroup};
#[macro_export]
#[deprecated(
since = "0.7.3",
note = "Use `volga::error::Problem` for a typed and maintainable RFC 7807 API."
)]
macro_rules! problem {
(
"status": $status:expr
$(, $key:tt : $value:tt)*
$(,)?
) => {{
let status = $crate::http::StatusCode::from_u16($status)
.unwrap_or($crate::http::StatusCode::OK);
match $crate::HttpBody::json($crate::json::json_internal!({
"type": $crate::error::problem::get_problem_type_url($status),
"title": status.canonical_reason().unwrap_or("unknown status code"),
"status": $status,
$($key: $value),*
})) {
Ok(body) => $crate::response!(
status,
body;
[
($crate::headers::CONTENT_TYPE, "problem+json"),
]
),
Err(err) => Err(err),
}
}};
(
"type": $type:expr,
"status": $status:expr
$(, $key:tt : $value:tt)* $(,)?
) => {{
let status = $crate::http::StatusCode::from_u16($status)
.unwrap_or($crate::http::StatusCode::OK);
match $crate::HttpBody::json($crate::json::json_internal!({
"type": $type,
"title": status.canonical_reason().unwrap_or("unknown status code"),
"status": $status,
$($key: $value),*
})) {
Ok(body) => $crate::response!(
status,
body;
[
($crate::headers::CONTENT_TYPE, "problem+json"),
]
),
Err(err) => Err(err),
}
}};
(
"title": $title:expr,
"status": $status:expr
$(, $key:tt : $value:tt)* $(,)?
) => {
match $crate::HttpBody::json($crate::json::json_internal!({
"type": $crate::error::problem::get_problem_type_url($status),
"title": $title,
"status": $status,
$($key: $value),*
})) {
Ok(body) => $crate::response!(
$crate::http::StatusCode::from_u16($status).unwrap_or($crate::http::StatusCode::OK),
body;
[
($crate::headers::CONTENT_TYPE, "problem+json"),
]
),
Err(err) => Err(err),
}
};
(
"type": $type:expr,
"title": $title:expr,
"status": $status:expr
$(, $key:tt : $value:tt)* $(,)?
) => {
match $crate::HttpBody::json($crate::json::json_internal!({
"type": $type,
"title": $title,
"status": $status,
$($key: $value),*
})) {
Ok(body) => $crate::response!(
$crate::http::StatusCode::from_u16($status).unwrap_or($crate::http::StatusCode::OK),
body;
[
($crate::headers::CONTENT_TYPE, "problem+json"),
]
),
Err(err) => Err(err),
}
};
}
pub type ProblemDetails = Problem<HashMap<String, Value>>;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(bound(
serialize = "E: Serialize",
deserialize = "E: Deserialize<'de> + Default"
))]
pub struct Problem<E = HashMap<String, Value>> {
#[serde(rename = "type")]
pub r#type: 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)]
pub extensions: E,
}
impl<T: Default> From<StatusCode> for Problem<T> {
#[inline]
fn from(status: StatusCode) -> Self {
Self::new(status.as_u16())
}
}
impl<T: Default> From<Error> for Problem<T> {
#[inline]
fn from(err: Error) -> Self {
let (status, instance, err) = err.into_parts();
let problem = Self::from(status).with_detail(err.to_string());
if let Some(instance) = instance {
problem.with_instance(instance)
} else {
problem
}
}
}
impl<E: Serialize> IntoResponse for Problem<E> {
#[inline]
fn into_response(self) -> HttpResult {
crate::response!(
self.status,
HttpBody::json(self)?;
[
(crate::headers::CONTENT_TYPE, "problem+json"),
]
)
}
}
impl Problem {
#[inline]
pub fn add_param<K, V>(mut self, name: K, value: V) -> Self
where
K: Into<String>,
V: Serialize,
{
self.extensions
.insert(name.into(), serde_json::to_value(value).unwrap());
self
}
}
impl<E: Default> Problem<E> {
#[inline]
pub fn new(status: u16) -> Self {
let title = StatusCode::from_u16(status)
.unwrap_or(StatusCode::OK)
.canonical_reason()
.unwrap_or("unknown status code");
Self {
r#type: get_problem_type_url(status),
title: title.into(),
detail: None,
instance: None,
extensions: Default::default(),
status,
}
}
#[inline]
pub fn with_type(mut self, t: impl Into<String>) -> Self {
self.r#type = t.into();
self
}
#[inline]
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = title.into();
self
}
#[inline]
pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
self.detail = Some(detail.into());
self
}
#[inline]
pub fn with_instance(mut self, instance: impl Into<String>) -> Self {
self.instance = Some(instance.into());
self
}
#[inline]
pub fn with_extensions(mut self, extensions: E) -> Self {
self.extensions = extensions;
self
}
}
impl App {
#[inline]
pub fn use_problem_details(&mut self) -> &mut Self {
self.map_err(make_problem_details)
}
}
#[cfg(feature = "middleware")]
impl<'a> Route<'a> {
#[inline]
pub fn map_problem(self) -> Self {
self.map_err(make_problem_details)
}
}
#[cfg(feature = "middleware")]
impl<'a> RouteGroup<'a> {
#[inline]
pub fn map_problem(&mut self) -> &mut Self {
self.map_err(make_problem_details)
}
}
#[inline]
async fn make_problem_details(err: Error) -> Problem {
Problem::from(err)
}
#[inline]
pub fn get_problem_type_url(status: u16) -> String {
let minor = if status < 500 { 5 } else { 6 };
let suffix = (status % 100) + 1;
match status {
421 => "https://tools.ietf.org/html/rfc9110#section-15.5.20".into(),
422 => "https://tools.ietf.org/html/rfc9110#section-15.5.21".into(),
426 => "https://tools.ietf.org/html/rfc9110#section-15.5.22".into(),
_ => format!("https://tools.ietf.org/html/rfc9110#section-15.{minor}.{suffix}"),
}
}
#[cfg(test)]
mod tests {
use crate::error::{Error, Problem, ProblemDetails};
use crate::http::{IntoResponse, StatusCode};
use http_body_util::BodyExt;
use serde::{Deserialize, Serialize};
use serde_json::json;
#[derive(Default, Serialize, Deserialize)]
struct ValidationError {
#[serde(rename = "invalid-params")]
invalid_params: Vec<InvalidParam>,
}
#[derive(Default, Serialize, Deserialize)]
struct InvalidParam {
name: String,
reason: String,
}
#[tokio::test]
async fn it_serializes_extensions() {
let err = ValidationError {
invalid_params: vec![InvalidParam {
name: "id".into(),
reason: "Must be a positive integer".into(),
}],
};
let problem: Problem<ValidationError> = Problem::new(400).with_extensions(err);
let body = &problem
.into_response()
.unwrap()
.body_mut()
.collect()
.await
.unwrap()
.to_bytes();
assert_eq!(
String::from_utf8_lossy(body),
r#"{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Bad Request","status":400,"invalid-params":[{"name":"id","reason":"Must be a positive integer"}]}"#
);
}
#[tokio::test]
async fn it_serializes_problem_details() {
let problem = ProblemDetails::new(400)
.with_type("https://tools.ietf.org/html/rfc9110#section-15.5.1")
.with_title("Bad Request")
.with_detail("Your request parameters didn't validate.")
.with_instance("/some/resource/path")
.add_param(
"invalid-params",
[json!({
"name": "id",
"reason": "Must be a positive integer"
})],
);
let body = &problem
.into_response()
.unwrap()
.body_mut()
.collect()
.await
.unwrap()
.to_bytes();
assert_eq!(
String::from_utf8_lossy(body),
r#"{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Bad Request","status":400,"detail":"Your request parameters didn't validate.","instance":"/some/resource/path","invalid-params":[{"name":"id","reason":"Must be a positive integer"}]}"#
);
}
#[tokio::test]
#[allow(deprecated)]
async fn it_serializes_and_deserializes_problem_details_from_macro() {
let problem = problem! {
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "Bad Request",
"status": 400,
"detail": "Your request parameters didn't validate.",
"instance": "/some/resource/path",
"invalid-params": [
{ "name": "id", "reason": "Must be a positive integer" }
]
};
let body = &problem
.unwrap()
.body_mut()
.collect()
.await
.unwrap()
.to_bytes();
let problem: ProblemDetails = serde_json::from_slice(body).unwrap();
assert_eq!(
problem.r#type,
"https://tools.ietf.org/html/rfc9110#section-15.5.1"
);
assert_eq!(problem.title, "Bad Request");
assert_eq!(problem.status, 400);
assert_eq!(
problem.detail.unwrap(),
"Your request parameters didn't validate."
);
assert_eq!(problem.instance.unwrap(), "/some/resource/path");
assert_eq!(
problem.extensions["invalid-params"],
json!([
{
"name": "id",
"reason": "Must be a positive integer"
}
])
);
}
#[test]
fn it_deserializes_extensions() {
let json = r#"{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Bad Request","status":400,"invalid-params":[{"name":"id","reason":"Must be a positive integer"}]}"#;
let problem: Problem<ValidationError> = serde_json::from_str(json).unwrap();
assert_eq!(
problem.r#type,
"https://tools.ietf.org/html/rfc9110#section-15.5.1"
);
assert_eq!(problem.title, "Bad Request");
assert_eq!(problem.status, 400);
assert_eq!(problem.extensions.invalid_params[0].name, "id");
assert_eq!(
problem.extensions.invalid_params[0].reason,
"Must be a positive integer"
);
}
#[test]
fn it_deserializes_problem_details() {
let json = r#"{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Bad Request","status":400,"detail":"Your request parameters didn't validate.","instance":"/some/resource/path","invalid-params":[{"name":"id","reason":"Must be a positive integer"}]}"#;
let problem: ProblemDetails = serde_json::from_str(json).unwrap();
assert_eq!(
problem.r#type,
"https://tools.ietf.org/html/rfc9110#section-15.5.1"
);
assert_eq!(problem.title, "Bad Request");
assert_eq!(problem.status, 400);
assert_eq!(
problem.detail.unwrap(),
"Your request parameters didn't validate."
);
assert_eq!(problem.instance.unwrap(), "/some/resource/path");
assert_eq!(
problem.extensions["invalid-params"],
json!([
{
"name": "id",
"reason": "Must be a positive integer"
}
])
);
}
#[test]
fn it_creates_problem_details_from_status_code() {
let status = StatusCode::BAD_REQUEST;
let problem = ProblemDetails::from(status);
assert_eq!(
problem.r#type,
"https://tools.ietf.org/html/rfc9110#section-15.5.1"
);
assert_eq!(problem.title, "Bad Request");
assert_eq!(problem.status, 400);
assert_eq!(problem.detail, None);
assert_eq!(problem.instance, None);
assert_eq!(problem.extensions.len(), 0);
}
#[test]
fn it_creates_problem_details_from_error() {
let error = Error::client_error("some error");
let problem = ProblemDetails::from(error);
assert_eq!(
problem.r#type,
"https://tools.ietf.org/html/rfc9110#section-15.5.1"
);
assert_eq!(problem.title, "Bad Request");
assert_eq!(problem.status, 400);
assert_eq!(problem.detail, Some("some error".into()));
assert_eq!(problem.instance, None);
assert_eq!(problem.extensions.len(), 0);
}
#[tokio::test]
async fn it_correctly_parses_type_for_client_errors() {
let client_errors = [
(400, "https://tools.ietf.org/html/rfc9110#section-15.5.1"),
(401, "https://tools.ietf.org/html/rfc9110#section-15.5.2"),
(402, "https://tools.ietf.org/html/rfc9110#section-15.5.3"),
(403, "https://tools.ietf.org/html/rfc9110#section-15.5.4"),
(404, "https://tools.ietf.org/html/rfc9110#section-15.5.5"),
(405, "https://tools.ietf.org/html/rfc9110#section-15.5.6"),
(406, "https://tools.ietf.org/html/rfc9110#section-15.5.7"),
(407, "https://tools.ietf.org/html/rfc9110#section-15.5.8"),
(408, "https://tools.ietf.org/html/rfc9110#section-15.5.9"),
(409, "https://tools.ietf.org/html/rfc9110#section-15.5.10"),
(410, "https://tools.ietf.org/html/rfc9110#section-15.5.11"),
(411, "https://tools.ietf.org/html/rfc9110#section-15.5.12"),
(412, "https://tools.ietf.org/html/rfc9110#section-15.5.13"),
(413, "https://tools.ietf.org/html/rfc9110#section-15.5.14"),
(414, "https://tools.ietf.org/html/rfc9110#section-15.5.15"),
(415, "https://tools.ietf.org/html/rfc9110#section-15.5.16"),
(416, "https://tools.ietf.org/html/rfc9110#section-15.5.17"),
(417, "https://tools.ietf.org/html/rfc9110#section-15.5.18"),
(418, "https://tools.ietf.org/html/rfc9110#section-15.5.19"),
(421, "https://tools.ietf.org/html/rfc9110#section-15.5.20"),
(422, "https://tools.ietf.org/html/rfc9110#section-15.5.21"),
(426, "https://tools.ietf.org/html/rfc9110#section-15.5.22"),
];
assert(client_errors).await;
assert_struct(client_errors).await;
}
#[tokio::test]
async fn it_correctly_parses_type_for_server_errors() {
let server_errors = [
(500, "https://tools.ietf.org/html/rfc9110#section-15.6.1"),
(501, "https://tools.ietf.org/html/rfc9110#section-15.6.2"),
(502, "https://tools.ietf.org/html/rfc9110#section-15.6.3"),
(503, "https://tools.ietf.org/html/rfc9110#section-15.6.4"),
(504, "https://tools.ietf.org/html/rfc9110#section-15.6.5"),
(505, "https://tools.ietf.org/html/rfc9110#section-15.6.6"),
];
assert(server_errors).await;
assert_struct(server_errors).await;
}
#[allow(deprecated)]
async fn assert<const N: usize>(test_cases: [(u16, &str); N]) {
for (status, url) in test_cases {
let mut problem_details = problem! { "status": status }.unwrap();
let body = &problem_details
.body_mut()
.collect()
.await
.unwrap()
.to_bytes();
assert_eq!(
String::from_utf8_lossy(body),
format!(
"{{\"status\":{},\"title\":\"{}\",\"type\":\"{}\"}}",
status,
problem_details.status().canonical_reason().unwrap(),
url
)
);
assert_eq!(problem_details.status(), status);
}
}
async fn assert_struct<const N: usize>(test_cases: [(u16, &str); N]) {
for (status, url) in test_cases {
let problem = ProblemDetails::new(status);
let mut problem_details = problem.into_response().unwrap();
let body = &problem_details
.body_mut()
.collect()
.await
.unwrap()
.to_bytes();
assert_eq!(
String::from_utf8_lossy(body),
format!(
"{{\"type\":\"{}\",\"title\":\"{}\",\"status\":{}}}",
url,
problem_details.status().canonical_reason().unwrap(),
status,
)
);
assert_eq!(problem_details.status(), status);
}
}
}