use crate::{hook::invoke_hook, middleware::EnrichmentContext};
use anyhow::Error;
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::Serialize;
use serde_json::Value;
use std::sync::atomic::{AtomicBool, Ordering};
static EXPOSE_ERRORS: AtomicBool = AtomicBool::new(false);
pub fn set_expose_errors(expose: bool) {
EXPOSE_ERRORS.store(expose, Ordering::Relaxed);
}
pub fn is_expose_errors_enabled() -> bool {
if EXPOSE_ERRORS.load(Ordering::Relaxed) {
return true;
}
std::env::var("AXUM_ANYHOW_EXPOSE_ERRORS")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false)
}
#[derive(Debug)]
pub struct ApiError {
status: StatusCode,
title: String,
detail: Option<String>,
meta: Option<Value>,
error: Option<Error>,
}
impl ApiError {
pub fn status(&self) -> StatusCode {
self.status
}
pub fn title(&self) -> &str {
&self.title
}
pub fn detail(&self) -> Option<&str> {
self.detail.as_deref()
}
pub fn meta(&self) -> Option<&Value> {
self.meta.as_ref()
}
pub fn error(&self) -> Option<&Error> {
self.error.as_ref()
}
pub fn builder() -> ApiErrorBuilder {
ApiErrorBuilder::default()
}
pub fn into_error(self) -> Error {
let msg = match self.detail {
Some(detail) => format!("{}: {}", self.title, detail),
None => self.title.clone(),
};
if let Some(error) = self.error {
error.context(msg)
} else {
anyhow::anyhow!("{}", msg)
}
}
}
impl Default for ApiError {
fn default() -> Self {
Self {
status: StatusCode::INTERNAL_SERVER_ERROR,
title: "Internal Error".to_string(),
detail: None,
meta: None,
error: None,
}
}
}
impl<E> From<E> for ApiError
where
E: Into<anyhow::Error>,
{
fn from(err: E) -> Self {
let error = err.into();
let should_expose = is_expose_errors_enabled();
let mut builder = ApiError::builder();
if should_expose {
builder = builder.detail(error.to_string());
}
builder.error(error).build()
}
}
#[derive(Serialize)]
struct ApiErrorResponse {
status: u16,
title: String,
#[serde(skip_serializing_if = "Option::is_none")]
detail: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
meta: Option<Value>,
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let body = Json(ApiErrorResponse {
status: self.status.as_u16(),
title: self.title,
detail: self.detail,
meta: self.meta,
});
(self.status, body).into_response()
}
}
#[derive(Default)]
pub struct ApiErrorBuilder {
status: Option<StatusCode>,
title: Option<String>,
detail: Option<String>,
meta: Option<Value>,
error: Option<Error>,
}
impl Clone for ApiErrorBuilder {
fn clone(&self) -> Self {
Self {
status: self.status,
title: self.title.clone(),
detail: self.detail.clone(),
meta: self.meta.clone(),
error: None,
}
}
}
impl ApiErrorBuilder {
pub fn status(mut self, status: StatusCode) -> Self {
self.status = Some(status);
self
}
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn detail(mut self, detail: impl Into<String>) -> Self {
self.detail = Some(detail.into());
self
}
pub fn error(mut self, error: impl Into<Error>) -> Self {
self.error = Some(error.into());
self
}
pub fn meta(mut self, meta: Value) -> Self {
self.meta = Some(meta);
self
}
pub fn build(mut self) -> ApiError {
self = EnrichmentContext::invoke(self);
let error = ApiError {
status: self.status.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
title: self.title.unwrap_or_else(|| "Internal Error".to_string()),
detail: self.detail,
meta: self.meta,
error: self.error,
};
invoke_hook(&error);
error
}
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::anyhow;
use http_body_util::BodyExt;
use serde_json::Value;
use serial_test::serial;
#[test]
fn test_into_api_error_from_anyhow() {
let anyhow_err = anyhow!("Something went wrong");
let api_err: ApiError = anyhow_err.into();
assert_eq!(api_err.status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(api_err.title, "Internal Error");
assert_eq!(api_err.detail, None);
}
#[test]
fn test_api_error_builder() {
let error = ApiError::builder()
.status(StatusCode::BAD_REQUEST)
.title("Validation Error")
.detail("Email is required")
.build();
assert_eq!(error.status, StatusCode::BAD_REQUEST);
assert_eq!(error.title, "Validation Error");
assert_eq!(error.detail, Some("Email is required".to_string()));
assert!(error.error.is_none());
}
#[test]
fn test_api_error_builder_with_error() {
let underlying_error = anyhow!("Database connection failed");
let error = ApiError::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.title("Database Error")
.detail("Could not connect to the database")
.error(underlying_error)
.build();
assert_eq!(error.status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(error.title, "Database Error");
assert_eq!(
error.detail,
Some("Could not connect to the database".to_string())
);
assert!(error.error.is_some());
}
#[test]
fn test_api_error_builder_with_string_conversions() {
let error = ApiError::builder()
.status(StatusCode::NOT_FOUND)
.title("Not Found".to_string())
.detail("Resource not found".to_string())
.build();
assert_eq!(error.status, StatusCode::NOT_FOUND);
assert_eq!(error.title, "Not Found");
assert_eq!(error.detail, Some("Resource not found".to_string()));
}
#[test]
fn test_api_error_builder_missing_status() {
let error = ApiError::builder()
.title("Error")
.detail("Something went wrong")
.build();
assert_eq!(error.status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(error.title, "Error");
assert_eq!(error.detail, Some("Something went wrong".to_string()));
}
#[test]
fn test_api_error_builder_missing_title() {
let error = ApiError::builder()
.status(StatusCode::BAD_REQUEST)
.detail("Something went wrong")
.build();
assert_eq!(error.status, StatusCode::BAD_REQUEST);
assert_eq!(error.title, "Internal Error");
assert_eq!(error.detail, Some("Something went wrong".to_string()));
}
#[test]
fn test_api_error_builder_missing_detail() {
let error = ApiError::builder()
.status(StatusCode::BAD_REQUEST)
.title("Error")
.build();
assert_eq!(error.status, StatusCode::BAD_REQUEST);
assert_eq!(error.title, "Error");
assert_eq!(error.detail, None);
}
#[test]
fn test_api_error_builder_all_defaults() {
let error = ApiError::builder().build();
assert_eq!(error.status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(error.title, "Internal Error");
assert_eq!(error.detail, None);
assert!(error.error.is_none());
}
#[test]
fn test_api_error_builder_fluent_interface() {
let error = ApiError::builder()
.status(StatusCode::CONFLICT)
.title("Conflict")
.detail("User already exists")
.error(anyhow!("Duplicate email"))
.build();
assert_eq!(error.status, StatusCode::CONFLICT);
assert_eq!(error.title, "Conflict");
assert_eq!(error.detail, Some("User already exists".to_string()));
assert!(error.error.is_some());
}
#[test]
fn test_api_error_default() {
let error = ApiError::default();
assert_eq!(error.status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(error.title, "Internal Error");
assert_eq!(error.detail, None);
assert!(error.error.is_none());
}
#[test]
fn test_anyhow_error_coerced_to_api_error_has_defaults() {
set_expose_errors(false);
let anyhow_err = anyhow!("Some error occurred");
let api_err: ApiError = anyhow_err.into();
assert_eq!(api_err.status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(api_err.title, "Internal Error");
assert_eq!(api_err.detail, None);
assert!(api_err.error.is_some());
}
#[test]
fn test_api_error_default_matches_builder_defaults() {
let from_default = ApiError::default();
let from_builder = ApiError::builder().build();
assert_eq!(from_default.status, from_builder.status);
assert_eq!(from_default.title, from_builder.title);
assert_eq!(from_default.detail, from_builder.detail);
assert!(from_default.error.is_none());
assert!(from_builder.error.is_none());
}
#[tokio::test]
async fn test_into_response_status() {
let api_err = ApiError::builder()
.status(StatusCode::BAD_REQUEST)
.title("Bad Request")
.detail("Invalid data")
.build();
let response = api_err.into_response();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_into_response_json_structure() {
let api_err = ApiError::builder()
.status(StatusCode::NOT_FOUND)
.title("Not Found")
.detail("Resource does not exist")
.build();
let response = api_err.into_response();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let json: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json["status"], 404);
assert_eq!(json["title"], "Not Found");
assert_eq!(json["detail"], "Resource does not exist");
}
#[test]
fn test_into_error_with_underlying_error() {
let underlying = anyhow!("Connection timeout");
let api_error = ApiError::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.title("Database Error")
.detail("Failed to connect")
.error(underlying)
.build();
let anyhow_error = api_error.into_error();
let error_msg = format!("{:#}", anyhow_error);
assert!(error_msg.contains("Database Error: Failed to connect"));
assert!(error_msg.contains("Connection timeout"));
}
#[test]
fn test_into_error_without_underlying_error() {
let api_error = ApiError::builder()
.status(StatusCode::BAD_REQUEST)
.title("Validation Error")
.detail("Email is required")
.build();
let anyhow_error = api_error.into_error();
let error_msg = anyhow_error.to_string();
assert_eq!(error_msg, "Validation Error: Email is required");
}
#[test]
#[serial]
fn test_expose_error_details_enabled() {
set_expose_errors(true);
let anyhow_err = anyhow!("Database connection failed");
let api_err: ApiError = anyhow_err.into();
assert_eq!(api_err.status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(api_err.title, "Internal Error");
assert_eq!(
api_err.detail,
Some("Database connection failed".to_string())
);
assert!(api_err.error.is_some());
set_expose_errors(false);
}
#[test]
#[serial]
fn test_expose_error_details_disabled() {
set_expose_errors(false);
let anyhow_err = anyhow!("Database connection failed");
let api_err: ApiError = anyhow_err.into();
assert_eq!(api_err.status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(api_err.title, "Internal Error");
assert_eq!(api_err.detail, None);
assert!(api_err.error.is_some());
}
#[test]
#[serial]
fn test_expose_error_details_with_true() {
set_expose_errors(true);
let anyhow_err = anyhow!("Connection timeout");
let api_err: ApiError = anyhow_err.into();
assert_eq!(api_err.detail, Some("Connection timeout".to_string()));
set_expose_errors(false);
}
#[test]
#[serial]
fn test_expose_error_via_env_var() {
set_expose_errors(false);
unsafe {
std::env::set_var("AXUM_ANYHOW_EXPOSE_ERRORS", "1");
}
let anyhow_err = anyhow!("Environment variable test");
let api_err: ApiError = anyhow_err.into();
assert_eq!(
api_err.detail,
Some("Environment variable test".to_string())
);
unsafe {
std::env::remove_var("AXUM_ANYHOW_EXPOSE_ERRORS");
}
}
#[test]
#[serial]
fn test_programmatic_setting_overrides_env_var() {
unsafe {
std::env::set_var("AXUM_ANYHOW_EXPOSE_ERRORS", "0");
}
set_expose_errors(true);
let anyhow_err = anyhow!("Programmatic override test");
let api_err: ApiError = anyhow_err.into();
assert_eq!(
api_err.detail,
Some("Programmatic override test".to_string())
);
set_expose_errors(false);
unsafe {
std::env::remove_var("AXUM_ANYHOW_EXPOSE_ERRORS");
}
}
#[test]
fn test_api_error_with_meta() {
use serde_json::json;
let error = ApiError::builder()
.status(StatusCode::NOT_FOUND)
.title("Not Found")
.detail("User not found")
.meta(json!({"request_id": "abc-123", "timestamp": 1234567890}))
.build();
assert_eq!(error.status, StatusCode::NOT_FOUND);
assert_eq!(error.title, "Not Found");
assert_eq!(error.detail, Some("User not found".to_string()));
assert!(error.meta.is_some());
let meta = error.meta.unwrap();
assert_eq!(meta["request_id"], "abc-123");
assert_eq!(meta["timestamp"], 1234567890);
}
#[test]
fn test_api_error_without_meta() {
let error = ApiError::builder()
.status(StatusCode::BAD_REQUEST)
.title("Bad Request")
.detail("Invalid input")
.build();
assert!(error.meta.is_none());
}
#[test]
fn test_api_error_default_has_none_meta() {
let error = ApiError::default();
assert!(error.meta.is_none());
}
#[tokio::test]
async fn test_into_response_with_meta() {
use serde_json::json;
let api_err = ApiError::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.title("Server Error")
.detail("Something went wrong")
.meta(json!({"trace_id": "xyz-789"}))
.build();
let response = api_err.into_response();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let json: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json["status"], 500);
assert_eq!(json["title"], "Server Error");
assert_eq!(json["detail"], "Something went wrong");
assert!(json["meta"].is_object());
assert_eq!(json["meta"]["trace_id"], "xyz-789");
}
#[tokio::test]
async fn test_into_response_without_meta() {
let api_err = ApiError::builder()
.status(StatusCode::NOT_FOUND)
.title("Not Found")
.detail("Resource not found")
.build();
let response = api_err.into_response();
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let json: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json["status"], 404);
assert_eq!(json["title"], "Not Found");
assert_eq!(json["detail"], "Resource not found");
assert!(json.get("meta").is_none());
}
#[test]
fn test_api_error_builder_fluent_with_meta() {
use serde_json::json;
let error = ApiError::builder()
.status(StatusCode::CONFLICT)
.title("Conflict")
.detail("Resource already exists")
.meta(json!({"duplicate_field": "email", "value": "test@example.com"}))
.error(anyhow!("Unique constraint violation"))
.build();
assert_eq!(error.status, StatusCode::CONFLICT);
assert_eq!(error.title, "Conflict");
assert_eq!(error.detail, Some("Resource already exists".to_string()));
assert!(error.error.is_some());
assert!(error.meta.is_some());
let meta = error.meta.unwrap();
assert_eq!(meta["duplicate_field"], "email");
assert_eq!(meta["value"], "test@example.com");
}
}