use bytes::Bytes;
use http::{HeaderValue, Response, StatusCode};
use jsonapi_core::{ApiError, Document, Error, ErrorLinks, ErrorSource, Link, Meta, Resource};
use crate::JSON_API_MEDIA_TYPE;
use crate::response::json_api_response;
#[must_use]
pub fn status_for(err: &Error) -> StatusCode {
match err {
Error::Json(_)
| Error::QueryParse { .. }
| Error::Structure(_)
| Error::InvalidIncludePath { .. }
| Error::InvalidMemberName { .. }
| Error::InvalidAtomicOperation { .. }
| Error::MalformedRelationship { .. }
| Error::IncludedRefMissing { .. }
| Error::UnexpectedDocumentShape { .. }
| Error::LidNotIndexed
| Error::LidNotAllowed { .. }
| Error::MediaTypeParse(_) => StatusCode::BAD_REQUEST,
Error::MissingAttribute { .. } | Error::NullRelationship => {
StatusCode::UNPROCESSABLE_ENTITY
}
Error::TypeMismatch { .. } | Error::RelationshipCardinalityMismatch { .. } => {
StatusCode::CONFLICT
}
Error::MediaTypeMismatch { .. } | Error::UnsupportedMediaTypeParam { .. } => {
StatusCode::UNSUPPORTED_MEDIA_TYPE
}
Error::NoAcceptableMediaType | Error::AllMediaTypesUnsupportedParams => {
StatusCode::NOT_ACCEPTABLE
}
Error::RegistryLookup { .. } => StatusCode::INTERNAL_SERVER_ERROR,
_ => StatusCode::INTERNAL_SERVER_ERROR,
}
}
#[must_use]
pub fn to_api_error(err: &Error) -> ApiError {
let status = status_for(err);
ApiError {
status: Some(status.as_u16().to_string()),
title: status.canonical_reason().map(str::to_string),
detail: Some(err.to_string()),
source: source_for(err),
..Default::default()
}
}
fn source_for(err: &Error) -> Option<ErrorSource> {
let pointer = match err {
Error::QueryParse { param, .. } => {
return Some(ErrorSource {
parameter: Some(param.clone()),
..Default::default()
});
}
Error::TypeMismatch { location, .. } => format!("{}/type", location_to_pointer(location)),
Error::MissingAttribute {
attribute,
location,
..
} => format!("{}/attributes/{attribute}", location_to_pointer(location)),
Error::MalformedRelationship { name, location, .. } => {
format!("{}/relationships/{name}", location_to_pointer(location))
}
Error::IncludedRefMissing { location, .. } => location_to_pointer(location),
_ => return None,
};
Some(ErrorSource {
pointer: Some(pointer),
..Default::default()
})
}
fn location_to_pointer(location: &str) -> String {
let mut pointer = String::with_capacity(location.len() + 1);
pointer.push('/');
for ch in location.chars() {
match ch {
'.' | '[' => pointer.push('/'),
']' => {}
'~' => pointer.push_str("~0"),
'/' => pointer.push_str("~1"),
other => pointer.push(other),
}
}
pointer
}
#[must_use]
pub fn error_response<E>(errors: E) -> Response<Bytes>
where
E: IntoIterator<Item = ApiError>,
{
let errors: Vec<ApiError> = errors.into_iter().collect();
let status = top_level_status(&errors);
let document: Document<Resource> = Document::errors(errors);
json_api_response(
status,
HeaderValue::from_static(JSON_API_MEDIA_TYPE),
&document,
)
}
#[must_use]
pub fn error_response_for(err: &Error) -> Response<Bytes> {
error_response(std::iter::once(to_api_error(err)))
}
#[must_use]
pub fn api_error_for_status(status: StatusCode, detail: Option<String>) -> ApiError {
ApiError {
status: Some(status.as_u16().to_string()),
title: status.canonical_reason().map(str::to_string),
detail,
..Default::default()
}
}
#[must_use]
pub fn error_response_for_status(status: StatusCode, detail: Option<String>) -> Response<Bytes> {
error_response(std::iter::once(api_error_for_status(status, detail)))
}
#[must_use]
pub fn with_status(status: StatusCode) -> ApiError {
ApiError {
status: Some(status.as_u16().to_string()),
title: status.canonical_reason().map(str::to_string),
..Default::default()
}
}
pub trait ApiErrorExt: Sized {
#[must_use]
fn pointer(self, pointer: impl Into<String>) -> Self;
#[must_use]
fn parameter(self, parameter: impl Into<String>) -> Self;
#[must_use]
fn detail(self, detail: impl Into<String>) -> Self;
#[must_use]
fn code(self, code: impl Into<String>) -> Self;
#[must_use]
fn title(self, title: impl Into<String>) -> Self;
#[must_use]
fn id(self, id: impl Into<String>) -> Self;
#[must_use]
fn meta(self, meta: Meta) -> Self;
#[must_use]
fn about_link(self, href: impl Into<String>) -> Self;
}
impl ApiErrorExt for ApiError {
fn pointer(mut self, pointer: impl Into<String>) -> Self {
self.source.get_or_insert_with(ErrorSource::default).pointer = Some(pointer.into());
self
}
fn parameter(mut self, parameter: impl Into<String>) -> Self {
self.source
.get_or_insert_with(ErrorSource::default)
.parameter = Some(parameter.into());
self
}
fn detail(mut self, detail: impl Into<String>) -> Self {
self.detail = Some(detail.into());
self
}
fn code(mut self, code: impl Into<String>) -> Self {
self.code = Some(code.into());
self
}
fn title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
fn id(mut self, id: impl Into<String>) -> Self {
self.id = Some(id.into());
self
}
fn meta(mut self, meta: Meta) -> Self {
self.meta = Some(meta);
self
}
fn about_link(mut self, href: impl Into<String>) -> Self {
self.links.get_or_insert_with(ErrorLinks::default).about = Some(Link::String(href.into()));
self
}
}
#[derive(Debug, Clone, Default)]
pub struct ApiErrors(Vec<ApiError>);
impl ApiErrors {
#[must_use]
pub fn new() -> Self {
Self(Vec::new())
}
pub fn push(&mut self, error: ApiError) {
self.0.push(error);
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
#[must_use]
pub fn len(&self) -> usize {
self.0.len()
}
}
impl From<ApiErrors> for Vec<ApiError> {
fn from(errors: ApiErrors) -> Self {
errors.0
}
}
impl IntoIterator for ApiErrors {
type Item = ApiError;
type IntoIter = std::vec::IntoIter<ApiError>;
fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
}
}
impl FromIterator<ApiError> for ApiErrors {
fn from_iter<I: IntoIterator<Item = ApiError>>(iter: I) -> Self {
Self(iter.into_iter().collect())
}
}
fn top_level_status(errors: &[ApiError]) -> StatusCode {
let codes: Vec<StatusCode> = errors
.iter()
.map(|e| {
e.status
.as_deref()
.and_then(|s| s.parse::<u16>().ok())
.and_then(|n| StatusCode::from_u16(n).ok())
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)
})
.collect();
match codes.split_first() {
None => StatusCode::INTERNAL_SERVER_ERROR,
Some((first, rest)) => {
if rest.iter().all(|code| code == first) {
*first
} else if codes.iter().any(StatusCode::is_server_error) {
StatusCode::INTERNAL_SERVER_ERROR
} else {
StatusCode::BAD_REQUEST
}
}
}
}
pub fn stamp_error_ids(document: &mut serde_json::Value, id: &str) -> bool {
let Some(errors) = document.get_mut("errors").and_then(|e| e.as_array_mut()) else {
return false;
};
let mut changed = false;
for error in errors {
if let Some(object) = error.as_object_mut()
&& !object.contains_key("id")
{
object.insert("id".to_string(), serde_json::Value::String(id.to_string()));
changed = true;
}
}
changed
}
#[must_use]
pub fn stamp_error_ids_in_bytes<'a>(body: &'a [u8], id: &str) -> std::borrow::Cow<'a, [u8]> {
use std::borrow::Cow;
let Ok(mut document) = serde_json::from_slice::<serde_json::Value>(body) else {
return Cow::Borrowed(body);
};
if stamp_error_ids(&mut document, id) {
match serde_json::to_vec(&document) {
Ok(bytes) => Cow::Owned(bytes),
Err(_) => Cow::Borrowed(body),
}
} else {
Cow::Borrowed(body)
}
}
#[cfg(test)]
mod tests {
use super::*;
use jsonapi_core::Cardinality;
use serde_json::Value;
#[test]
fn status_mapping_is_exhaustive_for_known_variants() {
let json_err = serde_json::from_str::<u8>("\"x\"").unwrap_err();
let cases: Vec<(Error, StatusCode)> = vec![
(Error::Json(json_err), StatusCode::BAD_REQUEST),
(
Error::InvalidMemberName {
name: "a b".into(),
reason: "space".into(),
},
StatusCode::BAD_REQUEST,
),
(
Error::RegistryLookup {
r#type: "people".into(),
id: "9".into(),
},
StatusCode::INTERNAL_SERVER_ERROR,
),
(Error::NullRelationship, StatusCode::UNPROCESSABLE_ENTITY),
(
Error::RelationshipCardinalityMismatch {
expected: Cardinality::ToOne,
},
StatusCode::CONFLICT,
),
(Error::LidNotIndexed, StatusCode::BAD_REQUEST),
(
Error::LidNotAllowed {
r#type: "people".into(),
lid: "tmp-1".into(),
},
StatusCode::BAD_REQUEST,
),
(
Error::MediaTypeMismatch {
expected: "application/vnd.api+json".into(),
got: "text/plain".into(),
},
StatusCode::UNSUPPORTED_MEDIA_TYPE,
),
(
Error::UnsupportedMediaTypeParam {
param: "charset".into(),
},
StatusCode::UNSUPPORTED_MEDIA_TYPE,
),
(Error::MediaTypeParse("bad".into()), StatusCode::BAD_REQUEST),
(Error::NoAcceptableMediaType, StatusCode::NOT_ACCEPTABLE),
(
Error::AllMediaTypesUnsupportedParams,
StatusCode::NOT_ACCEPTABLE,
),
(
Error::QueryParse {
param: "page[size]".into(),
reason: "not an int".into(),
},
StatusCode::BAD_REQUEST,
),
(Error::Structure("bad".into()), StatusCode::BAD_REQUEST),
(
Error::InvalidIncludePath {
path: "a.b".into(),
segment: "b".into(),
type_name: "a".into(),
},
StatusCode::BAD_REQUEST,
),
(
Error::InvalidAtomicOperation {
index: 0,
reason: "dangling lid".into(),
},
StatusCode::BAD_REQUEST,
),
(
Error::UnexpectedDocumentShape {
expected: "single resource",
found: "errors document",
},
StatusCode::BAD_REQUEST,
),
(
Error::TypeMismatch {
expected: "articles",
got: "people".into(),
location: "data".into(),
},
StatusCode::CONFLICT,
),
(
Error::MalformedRelationship {
name: "author".into(),
location: "data".into(),
reason: "missing data".into(),
},
StatusCode::BAD_REQUEST,
),
(
Error::MissingAttribute {
resource_type: "articles",
attribute: "title",
location: "data".into(),
},
StatusCode::UNPROCESSABLE_ENTITY,
),
(
Error::IncludedRefMissing {
name: "author".into(),
r#type: "people".into(),
id: "9".into(),
location: "data.relationships.author".into(),
},
StatusCode::BAD_REQUEST,
),
];
for (err, expected) in cases {
assert_eq!(status_for(&err), expected, "wrong status for {err:?}");
}
}
#[test]
fn to_api_error_sets_status_title_detail() {
let err = Error::NoAcceptableMediaType;
let api = to_api_error(&err);
assert_eq!(api.status.as_deref(), Some("406"));
assert_eq!(api.title.as_deref(), Some("Not Acceptable"));
assert_eq!(api.detail.as_deref(), Some(err.to_string().as_str()));
assert!(api.source.is_none());
}
#[test]
fn to_api_error_populates_source_parameter_for_query_errors() {
let err = Error::QueryParse {
param: "sort".into(),
reason: "unknown field".into(),
};
let api = to_api_error(&err);
assert_eq!(api.status.as_deref(), Some("400"));
assert_eq!(
api.source.as_ref().and_then(|s| s.parameter.as_deref()),
Some("sort")
);
}
#[test]
fn to_api_error_sets_pointer_for_missing_attribute() {
let err = Error::MissingAttribute {
resource_type: "articles",
attribute: "title",
location: "data".into(),
};
let api = to_api_error(&err);
assert_eq!(api.status.as_deref(), Some("422"));
assert_eq!(
api.source.as_ref().and_then(|s| s.pointer.as_deref()),
Some("/data/attributes/title")
);
}
#[test]
fn to_api_error_sets_pointer_for_type_mismatch() {
let err = Error::TypeMismatch {
expected: "articles",
got: "people".into(),
location: "data[3]".into(),
};
let api = to_api_error(&err);
assert_eq!(api.status.as_deref(), Some("409"));
assert_eq!(
api.source.as_ref().and_then(|s| s.pointer.as_deref()),
Some("/data/3/type")
);
}
#[test]
fn to_api_error_sets_pointer_for_malformed_relationship() {
let err = Error::MalformedRelationship {
name: "author".into(),
location: "data".into(),
reason: "relationship value must be an object".into(),
};
let api = to_api_error(&err);
assert_eq!(
api.source.as_ref().and_then(|s| s.pointer.as_deref()),
Some("/data/relationships/author")
);
}
#[test]
fn to_api_error_uses_relationship_path_for_included_ref_missing() {
let err = Error::IncludedRefMissing {
name: "author".into(),
r#type: "people".into(),
id: "9".into(),
location: "data.relationships.author".into(),
};
let api = to_api_error(&err);
assert_eq!(
api.source.as_ref().and_then(|s| s.pointer.as_deref()),
Some("/data/relationships/author")
);
}
#[test]
fn location_to_pointer_handles_nested_paths() {
assert_eq!(location_to_pointer("data"), "/data");
assert_eq!(location_to_pointer("data[3]"), "/data/3");
assert_eq!(location_to_pointer("included[2]"), "/included/2");
assert_eq!(
location_to_pointer("data[1].relationships.author"),
"/data/1/relationships/author"
);
}
#[test]
fn location_to_pointer_escapes_rfc6901_reference_tokens() {
assert_eq!(location_to_pointer("data.foo~bar"), "/data/foo~0bar");
assert_eq!(location_to_pointer("data.foo/bar"), "/data/foo~1bar");
}
#[test]
fn error_response_sets_status_content_type_and_body() {
let response = error_response_for(&Error::NoAcceptableMediaType);
assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some(JSON_API_MEDIA_TYPE)
);
let body: Value = serde_json::from_slice(response.body()).unwrap();
assert_eq!(body["errors"][0]["status"], "406");
assert!(body["data"].is_null());
}
#[test]
fn error_response_for_status_carries_status_title_and_detail() {
let response = error_response_for_status(
StatusCode::PAYLOAD_TOO_LARGE,
Some("body too large".to_string()),
);
assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
let body: Value = serde_json::from_slice(response.body()).unwrap();
assert_eq!(body["errors"][0]["status"], "413");
assert_eq!(body["errors"][0]["title"], "Payload Too Large");
assert_eq!(body["errors"][0]["detail"], "body too large");
}
#[test]
fn api_error_for_status_omits_detail_when_none() {
let api = api_error_for_status(StatusCode::NOT_FOUND, None);
assert_eq!(api.status.as_deref(), Some("404"));
assert_eq!(api.title.as_deref(), Some("Not Found"));
assert!(api.detail.is_none());
}
#[test]
fn top_level_status_all_same() {
let errors = vec![
ApiError {
status: Some("404".into()),
..Default::default()
},
ApiError {
status: Some("404".into()),
..Default::default()
},
];
assert_eq!(top_level_status(&errors), StatusCode::NOT_FOUND);
}
#[test]
fn top_level_status_mixed_4xx_collapses_to_400() {
let errors = vec![
ApiError {
status: Some("404".into()),
..Default::default()
},
ApiError {
status: Some("422".into()),
..Default::default()
},
];
assert_eq!(top_level_status(&errors), StatusCode::BAD_REQUEST);
}
#[test]
fn top_level_status_any_5xx_collapses_to_500() {
let errors = vec![
ApiError {
status: Some("400".into()),
..Default::default()
},
ApiError {
status: Some("503".into()),
..Default::default()
},
];
assert_eq!(top_level_status(&errors), StatusCode::INTERNAL_SERVER_ERROR);
}
#[test]
fn with_status_sets_numeric_status_and_canonical_title() {
let err = with_status(StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(err.status.as_deref(), Some("422"));
assert_eq!(err.title.as_deref(), Some("Unprocessable Entity"));
assert!(err.source.is_none());
}
#[test]
fn with_status_leaves_title_absent_for_unknown_code() {
let err = with_status(StatusCode::from_u16(799).unwrap());
assert_eq!(err.status.as_deref(), Some("799"));
assert!(err.title.is_none());
}
#[test]
fn parameter_sets_source_parameter() {
let err = with_status(StatusCode::BAD_REQUEST).parameter("sort");
assert_eq!(
err.source.as_ref().unwrap().parameter.as_deref(),
Some("sort")
);
assert_eq!(err.source.unwrap().pointer, None);
}
#[test]
fn ext_setters_populate_expected_fields() {
let mut meta = Meta::new();
meta.insert("trace".into(), serde_json::json!("abc"));
let err = with_status(StatusCode::UNPROCESSABLE_ENTITY)
.pointer("/data/attributes/title")
.detail("must not be empty")
.code("blank")
.title("Blank title")
.id("err-1")
.meta(meta)
.about_link("https://example.com/errors/blank");
assert_eq!(err.status.as_deref(), Some("422"));
assert_eq!(err.title.as_deref(), Some("Blank title"));
assert_eq!(err.detail.as_deref(), Some("must not be empty"));
assert_eq!(err.code.as_deref(), Some("blank"));
assert_eq!(err.id.as_deref(), Some("err-1"));
assert_eq!(
err.source.as_ref().unwrap().pointer.as_deref(),
Some("/data/attributes/title")
);
assert_eq!(
err.meta.as_ref().unwrap()["trace"],
serde_json::json!("abc")
);
assert_eq!(
err.links.unwrap().about,
Some(Link::String("https://example.com/errors/blank".into()))
);
}
#[test]
fn api_errors_accumulates_and_aggregates_into_one_document() {
let mut errors = ApiErrors::new();
assert!(errors.is_empty());
for field in ["title", "body", "author"] {
errors.push(
with_status(StatusCode::UNPROCESSABLE_ENTITY)
.pointer(format!("/data/attributes/{field}"))
.detail(format!("{field} is required")),
);
}
assert_eq!(errors.len(), 3);
let response = error_response(errors);
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
let json: Value = serde_json::from_slice(response.body()).unwrap();
assert_eq!(json["errors"].as_array().unwrap().len(), 3);
assert_eq!(
json["errors"][2]["source"]["pointer"],
"/data/attributes/author"
);
}
#[test]
fn stamp_error_ids_fills_missing_ids_and_preserves_existing() {
let mut doc = serde_json::json!({
"errors": [
{ "status": "422", "detail": "a" },
{ "id": "kept", "status": "422", "detail": "b" }
]
});
assert!(stamp_error_ids(&mut doc, "req-1"));
assert_eq!(doc["errors"][0]["id"], "req-1");
assert_eq!(doc["errors"][1]["id"], "kept");
}
#[test]
fn stamp_error_ids_ignores_a_data_document() {
let mut doc = serde_json::json!({ "data": { "type": "articles", "id": "1" } });
assert!(!stamp_error_ids(&mut doc, "req-1"));
assert!(doc.get("errors").is_none());
assert_eq!(doc["data"]["id"], "1");
}
#[test]
fn stamp_error_ids_in_bytes_stamps_error_documents() {
let body = br#"{"errors":[{"status":"404"}]}"#;
let out = stamp_error_ids_in_bytes(body, "req-9");
let json: Value = serde_json::from_slice(&out).unwrap();
assert_eq!(json["errors"][0]["id"], "req-9");
}
#[test]
fn stamp_error_ids_in_bytes_is_a_noop_on_non_json_and_data_bodies() {
let plain = b"not json at all";
assert!(matches!(
stamp_error_ids_in_bytes(plain, "req-1"),
std::borrow::Cow::Borrowed(b) if b == plain
));
let data = br#"{"data":{"type":"articles","id":"1"}}"#;
assert!(matches!(
stamp_error_ids_in_bytes(data, "req-1"),
std::borrow::Cow::Borrowed(_)
));
}
}