use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::Serialize;
use thiserror::Error;
pub type ApiResult<T> = Result<T, ApiError>;
#[derive(Debug, Clone, Serialize, Error)]
pub struct ApiError {
#[serde(skip)]
pub status: StatusCode,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub code: Option<String>,
}
impl ApiError {
pub fn new(status: StatusCode, message: impl Into<String>) -> Self {
Self {
status,
message: message.into(),
code: None,
}
}
pub fn with_code(
status: StatusCode,
message: impl Into<String>,
code: impl Into<String>,
) -> Self {
Self {
status,
message: message.into(),
code: Some(code.into()),
}
}
pub fn bad_request(message: impl Into<String>) -> Self {
Self::new(StatusCode::BAD_REQUEST, message)
}
pub fn not_found(resource: impl Into<String>) -> Self {
Self::with_code(
StatusCode::NOT_FOUND,
format!("Resource not found: {}", resource.into()),
"NOT_FOUND",
)
}
pub fn validation(message: impl Into<String>) -> Self {
Self::with_code(
StatusCode::UNPROCESSABLE_ENTITY,
message,
"VALIDATION_ERROR",
)
}
pub fn internal(message: impl Into<String>) -> Self {
Self::with_code(StatusCode::INTERNAL_SERVER_ERROR, message, "INTERNAL_ERROR")
}
pub fn not_implemented(feature: impl Into<String>) -> Self {
Self::with_code(
StatusCode::NOT_IMPLEMENTED,
format!("Feature not implemented: {}", feature.into()),
"NOT_IMPLEMENTED",
)
}
pub fn unavailable(message: impl Into<String>) -> Self {
Self::with_code(
StatusCode::SERVICE_UNAVAILABLE,
message,
"SERVICE_UNAVAILABLE",
)
}
}
impl std::fmt::Display for ApiError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.code {
Some(code) => write!(f, "[{:?}] [{}] {}", self.status, code, self.message),
None => write!(f, "[{:?}] {}", self.status, self.message),
}
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let body = Json(serde_json::json!({
"success": false,
"error": self.message,
"code": self.code,
}));
(self.status, body).into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_api_error_bad_request() {
let error = ApiError::bad_request("Invalid input");
assert_eq!(error.status, StatusCode::BAD_REQUEST);
assert!(error.message.contains("Invalid input"));
}
#[test]
fn test_api_error_not_found() {
let error = ApiError::not_found("project_123");
assert_eq!(error.status, StatusCode::NOT_FOUND);
assert!(error.message.contains("project_123"));
assert_eq!(error.code, Some("NOT_FOUND".to_string()));
}
#[test]
fn test_api_error_validation() {
let error = ApiError::validation("Invalid query parameter");
assert_eq!(error.status, StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(error.code, Some("VALIDATION_ERROR".to_string()));
}
#[test]
fn test_api_error_internal() {
let error = ApiError::internal("Something went wrong");
assert_eq!(error.status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(error.code, Some("INTERNAL_ERROR".to_string()));
}
#[test]
fn test_api_error_not_implemented() {
let error = ApiError::not_implemented("edit endpoint");
assert_eq!(error.status, StatusCode::NOT_IMPLEMENTED);
assert!(error.message.contains("edit endpoint"));
assert_eq!(error.code, Some("NOT_IMPLEMENTED".to_string()));
}
#[test]
fn test_api_error_unavailable() {
let error = ApiError::unavailable("Service temporarily down");
assert_eq!(error.status, StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(error.code, Some("SERVICE_UNAVAILABLE".to_string()));
}
#[test]
fn test_api_error_display() {
let error = ApiError::not_found("test");
let display = format!("{}", error);
assert!(display.contains("NOT_FOUND"));
assert!(display.contains("test"));
}
#[test]
fn test_api_error_into_response() {
let error = ApiError::bad_request("test error");
let response = error.into_response();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
}