use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErrorResponse {
pub error: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<serde_json::Value>,
}
impl ErrorResponse {
pub fn new(error: impl Into<String>, message: impl Into<String>) -> Self {
Self {
error: error.into(),
message: message.into(),
details: None,
}
}
pub fn with_details(mut self, details: serde_json::Value) -> Self {
self.details = Some(details);
self
}
}
#[derive(Debug, Error)]
pub enum HttpError {
#[error("Bad request: {0}")]
BadRequest(String),
#[error("Unauthorized: {0}")]
Unauthorized(String),
#[error("Forbidden: {0}")]
Forbidden(String),
#[error("Not found: {0}")]
NotFound(String),
#[error("Conflict: {0}")]
Conflict(String),
#[error("Validation failed: {0}")]
ValidationError(String),
#[error("Internal server error: {0}")]
InternalServer(String),
#[error("Service unavailable: {0}")]
ServiceUnavailable(String),
#[error("Database error: {0}")]
Database(String),
#[error("External service error: {0}")]
ExternalService(String),
#[error("{message}")]
Custom {
status: StatusCode,
error_code: String,
message: String,
},
}
impl HttpError {
pub fn status_code(&self) -> StatusCode {
match self {
HttpError::BadRequest(_) => StatusCode::BAD_REQUEST,
HttpError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
HttpError::Forbidden(_) => StatusCode::FORBIDDEN,
HttpError::NotFound(_) => StatusCode::NOT_FOUND,
HttpError::Conflict(_) => StatusCode::CONFLICT,
HttpError::ValidationError(_) => StatusCode::UNPROCESSABLE_ENTITY,
HttpError::InternalServer(_) => StatusCode::INTERNAL_SERVER_ERROR,
HttpError::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
HttpError::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
HttpError::ExternalService(_) => StatusCode::BAD_GATEWAY,
HttpError::Custom { status, .. } => *status,
}
}
pub fn error_code(&self) -> String {
match self {
HttpError::BadRequest(_) => "BAD_REQUEST".to_string(),
HttpError::Unauthorized(_) => "UNAUTHORIZED".to_string(),
HttpError::Forbidden(_) => "FORBIDDEN".to_string(),
HttpError::NotFound(_) => "NOT_FOUND".to_string(),
HttpError::Conflict(_) => "CONFLICT".to_string(),
HttpError::ValidationError(_) => "VALIDATION_ERROR".to_string(),
HttpError::InternalServer(_) => "INTERNAL_SERVER_ERROR".to_string(),
HttpError::ServiceUnavailable(_) => "SERVICE_UNAVAILABLE".to_string(),
HttpError::Database(_) => "DATABASE_ERROR".to_string(),
HttpError::ExternalService(_) => "EXTERNAL_SERVICE_ERROR".to_string(),
HttpError::Custom { error_code, .. } => error_code.clone(),
}
}
pub fn custom(
status: StatusCode,
error_code: impl Into<String>,
message: impl Into<String>,
) -> Self {
HttpError::Custom {
status,
error_code: error_code.into(),
message: message.into(),
}
}
}
impl IntoResponse for HttpError {
fn into_response(self) -> Response {
let status = self.status_code();
let error_code = self.error_code();
let message = self.to_string();
let body = ErrorResponse::new(error_code, message);
(status, Json(body)).into_response()
}
}
pub type HttpResult<T> = Result<T, HttpError>;
#[cfg(feature = "validation")]
impl From<validator::ValidationErrors> for HttpError {
fn from(err: validator::ValidationErrors) -> Self {
use std::fmt::Write;
let mut message = String::new();
let mut first = true;
for (field, field_errors) in err.field_errors() {
for error in field_errors {
if !first {
write!(&mut message, "; ").unwrap();
}
first = false;
write!(&mut message, "{}: ", field).unwrap();
if let Some(msg) = &error.message {
write!(&mut message, "{}", msg).unwrap();
} else {
write!(&mut message, "validation failed ({})", error.code).unwrap();
}
}
}
if message.is_empty() {
HttpError::ValidationError("Validation failed".to_string())
} else {
HttpError::ValidationError(message)
}
}
}
#[macro_export]
macro_rules! app_error {
($name:ident { $($variant:ident($ty:ty)),* $(,)? }) => {
#[derive(Debug, thiserror::Error)]
pub enum $name {
#[error(transparent)]
Http(#[from] $crate::error::HttpError),
$(
#[error("{0}")]
$variant($ty),
)*
}
impl axum::response::IntoResponse for $name {
fn into_response(self) -> axum::response::Response {
match self {
Self::Http(e) => e.into_response(),
_ => {
$crate::error::HttpError::InternalServer(self.to_string()).into_response()
}
}
}
}
};
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_error_status_codes() {
assert_eq!(
HttpError::BadRequest("test".into()).status_code(),
StatusCode::BAD_REQUEST
);
assert_eq!(
HttpError::Unauthorized("test".into()).status_code(),
StatusCode::UNAUTHORIZED
);
assert_eq!(
HttpError::NotFound("test".into()).status_code(),
StatusCode::NOT_FOUND
);
}
#[test]
fn test_custom_error() {
let err = HttpError::custom(StatusCode::IM_A_TEAPOT, "TEAPOT", "I'm a teapot");
assert_eq!(err.status_code(), StatusCode::IM_A_TEAPOT);
assert_eq!(err.error_code(), "TEAPOT");
}
}