use axum::body::Body;
use axum::response::{IntoResponse, Response};
use bytes::Bytes;
use http::StatusCode;
use jsonapi_core::{ApiError, Error};
use jsonapi_http::{
ApiErrorExt, ApiErrors, error_response, error_response_for, error_response_for_status,
id_conflict, with_status,
};
#[derive(Debug, Clone)]
pub struct JsonApiError {
response: Box<http::Response<Bytes>>,
}
impl JsonApiError {
#[must_use]
pub fn from_core(error: &Error) -> Self {
Self {
response: Box::new(error_response_for(error)),
}
}
#[must_use]
pub fn from_api_errors(errors: impl IntoIterator<Item = ApiError>) -> Self {
Self {
response: Box::new(error_response(errors)),
}
}
#[must_use]
pub fn from_api_error(error: ApiError) -> Self {
Self::from_api_errors(std::iter::once(error))
}
#[must_use]
pub fn from_status(status: StatusCode, detail: Option<String>) -> Self {
Self {
response: Box::new(error_response_for_status(status, detail)),
}
}
#[must_use]
pub fn conflict(detail: impl Into<String>) -> Self {
Self::from_api_error(id_conflict(detail))
}
#[must_use]
pub fn not_found(detail: impl Into<String>) -> Self {
Self::from_api_error(with_status(StatusCode::NOT_FOUND).detail(detail))
}
#[must_use]
pub fn forbidden(detail: impl Into<String>) -> Self {
Self::from_api_error(with_status(StatusCode::FORBIDDEN).detail(detail))
}
#[must_use]
pub fn unprocessable(detail: impl Into<String>) -> Self {
Self::from_api_error(with_status(StatusCode::UNPROCESSABLE_ENTITY).detail(detail))
}
#[must_use]
pub fn unprocessable_with_pointer(
pointer: impl Into<String>,
detail: impl Into<String>,
) -> Self {
Self::from_api_error(
with_status(StatusCode::UNPROCESSABLE_ENTITY)
.pointer(pointer)
.detail(detail),
)
}
#[must_use]
pub fn internal(detail: impl Into<String>) -> Self {
let _detail = detail.into();
let error = with_status(StatusCode::INTERNAL_SERVER_ERROR);
#[cfg(feature = "debug-errors")]
let error = error.detail(_detail);
Self::from_api_error(error)
}
}
pub trait IntoJsonApiError {
fn into_json_api_error(self) -> JsonApiError;
}
pub trait ResultExt<T> {
fn or_json_api(self) -> Result<T, JsonApiError>;
}
impl<T, E: IntoJsonApiError> ResultExt<T> for Result<T, E> {
fn or_json_api(self) -> Result<T, JsonApiError> {
self.map_err(IntoJsonApiError::into_json_api_error)
}
}
#[cfg(feature = "anyhow")]
#[cfg_attr(docsrs, doc(cfg(feature = "anyhow")))]
impl From<anyhow::Error> for JsonApiError {
fn from(error: anyhow::Error) -> Self {
Self::internal(error.to_string())
}
}
#[cfg(feature = "sqlx")]
#[cfg_attr(docsrs, doc(cfg(feature = "sqlx")))]
impl From<sqlx::Error> for JsonApiError {
fn from(error: sqlx::Error) -> Self {
match error {
sqlx::Error::RowNotFound => Self::not_found("resource not found"),
other => Self::internal(other.to_string()),
}
}
}
impl IntoResponse for JsonApiError {
fn into_response(self) -> Response {
(*self.response).map(Body::from)
}
}
impl From<Error> for JsonApiError {
fn from(error: Error) -> Self {
Self::from_core(&error)
}
}
impl From<Box<ApiError>> for JsonApiError {
fn from(error: Box<ApiError>) -> Self {
Self::from_api_error(*error)
}
}
impl From<ApiErrors> for JsonApiError {
fn from(errors: ApiErrors) -> Self {
Self::from_api_errors(errors)
}
}
#[cfg(test)]
mod tests {
use super::*;
use http::StatusCode;
use serde_json::Value;
fn read(response: Response) -> (StatusCode, Value) {
let status = response.status();
let bytes =
pollster::block_on(axum::body::to_bytes(response.into_body(), usize::MAX)).unwrap();
(status, serde_json::from_slice(&bytes).unwrap())
}
#[test]
fn into_response_uses_mapped_status_and_body() {
let response = JsonApiError::from_core(&Error::NoAcceptableMediaType).into_response();
assert_eq!(
response
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some("application/vnd.api+json")
);
let (status, json) = read(response);
assert_eq!(status, StatusCode::NOT_ACCEPTABLE);
assert_eq!(json["errors"][0]["status"], "406");
}
#[test]
fn from_boxed_api_error_preserves_status_and_body() {
let api = ApiError {
status: Some("422".to_string()),
..Default::default()
};
let (status, json) = read(JsonApiError::from(Box::new(api)).into_response());
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(json["errors"][0]["status"], "422");
}
#[test]
fn from_api_errors_aggregates_multiple_into_one_document() {
let errors = [
ApiError {
status: Some("422".to_string()),
detail: Some("title is required".to_string()),
..Default::default()
},
ApiError {
status: Some("422".to_string()),
detail: Some("body is required".to_string()),
..Default::default()
},
];
let (status, json) = read(JsonApiError::from_api_errors(errors).into_response());
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(json["errors"].as_array().unwrap().len(), 2);
assert_eq!(json["errors"][0]["detail"], "title is required");
assert_eq!(json["errors"][1]["detail"], "body is required");
}
#[test]
fn from_status_builds_error_document_with_status_and_detail() {
let (status, json) = read(
JsonApiError::from_status(StatusCode::PAYLOAD_TOO_LARGE, Some("too big".into()))
.into_response(),
);
assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE);
assert_eq!(json["errors"][0]["status"], "413");
assert_eq!(json["errors"][0]["detail"], "too big");
}
#[test]
fn from_core_conversion_builds_error_document() {
let response: JsonApiError = Error::NoAcceptableMediaType.into();
let (status, json) = read(response.into_response());
assert_eq!(status, StatusCode::NOT_ACCEPTABLE);
assert_eq!(json["errors"][0]["status"], "406");
}
#[cfg(not(feature = "debug-errors"))]
#[test]
fn internal_does_not_leak_raw_message() {
let (status, json) =
read(JsonApiError::internal("db url: postgres://user:hunter2@host/db").into_response());
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(json["errors"][0]["status"], "500");
let body = json.to_string();
assert!(
!body.contains("hunter2"),
"raw internal message leaked: {body}"
);
assert!(json["errors"][0]["detail"].is_null());
}
#[cfg(feature = "debug-errors")]
#[test]
fn internal_includes_raw_message_when_debug_errors_enabled() {
let (status, json) =
read(JsonApiError::internal("db url: postgres://user:hunter2@host/db").into_response());
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(
json["errors"][0]["detail"],
"db url: postgres://user:hunter2@host/db"
);
}
#[test]
fn or_json_api_maps_domain_error_to_chosen_status() {
struct Forbidden;
impl IntoJsonApiError for Forbidden {
fn into_json_api_error(self) -> JsonApiError {
JsonApiError::forbidden("not your resource")
}
}
fn handler() -> Result<(), JsonApiError> {
Err(Forbidden).or_json_api()?;
Ok(())
}
let (status, json) = read(handler().unwrap_err().into_response());
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(json["errors"][0]["status"], "403");
assert_eq!(json["errors"][0]["detail"], "not your resource");
}
#[test]
fn not_found_builds_404_with_detail() {
let (status, json) = read(JsonApiError::not_found("article 99 missing").into_response());
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(json["errors"][0]["detail"], "article 99 missing");
}
#[test]
fn unprocessable_builds_422_with_detail() {
let (status, json) =
read(JsonApiError::unprocessable("title must not be empty").into_response());
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(json["errors"][0]["detail"], "title must not be empty");
}
#[test]
fn unprocessable_with_pointer_builds_422_with_pointer_and_detail() {
let (status, json) = read(
JsonApiError::unprocessable_with_pointer(
"/data/relationships/author",
"author is required",
)
.into_response(),
);
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(json["errors"][0]["detail"], "author is required");
assert_eq!(
json["errors"][0]["source"]["pointer"],
"/data/relationships/author"
);
}
#[cfg(feature = "anyhow")]
#[test]
fn anyhow_error_maps_to_500() {
let err: JsonApiError = anyhow::anyhow!("boom").into();
let (status, json) = read(err.into_response());
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(json["errors"][0]["status"], "500");
}
#[cfg(feature = "sqlx")]
#[test]
fn sqlx_row_not_found_maps_to_404_others_500() {
let (status, _) = read(JsonApiError::from(sqlx::Error::RowNotFound).into_response());
assert_eq!(status, StatusCode::NOT_FOUND);
let (status, _) =
read(JsonApiError::from(sqlx::Error::Protocol("bad packet".into())).into_response());
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
}
}