use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::{json, Value};
#[derive(Debug, Clone)]
pub struct ApiError {
pub status: StatusCode,
pub code: String,
pub message: String,
pub details: Option<Value>,
}
impl ApiError {
#[must_use]
pub fn new(status: StatusCode, code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
status,
code: code.into(),
message: message.into(),
details: None,
}
}
#[must_use]
pub fn with_details(mut self, details: Value) -> Self {
self.details = Some(details);
self
}
#[must_use]
pub fn with_field(self, field: impl Into<String>) -> Self {
self.with_details(json!({"field": field.into()}))
}
#[must_use]
pub fn bad_request(message: impl Into<String>) -> Self {
Self::new(StatusCode::BAD_REQUEST, "bad_request", message)
}
#[must_use]
pub fn unauthorized(message: impl Into<String>) -> Self {
Self::new(StatusCode::UNAUTHORIZED, "unauthorized", message)
}
#[must_use]
pub fn forbidden(message: impl Into<String>) -> Self {
Self::new(StatusCode::FORBIDDEN, "forbidden", message)
}
#[must_use]
pub fn not_found(message: impl Into<String>) -> Self {
Self::new(StatusCode::NOT_FOUND, "not_found", message)
}
#[must_use]
pub fn conflict(message: impl Into<String>) -> Self {
Self::new(StatusCode::CONFLICT, "conflict", message)
}
#[must_use]
pub fn validation(message: impl Into<String>) -> Self {
Self::new(StatusCode::UNPROCESSABLE_ENTITY, "validation_failed", message)
}
#[must_use]
pub fn rate_limited(message: impl Into<String>) -> Self {
Self::new(StatusCode::TOO_MANY_REQUESTS, "rate_limited", message)
}
#[must_use]
pub fn internal(message: impl Into<String>) -> Self {
Self::new(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", message)
}
#[must_use]
pub fn service_unavailable(message: impl Into<String>) -> Self {
Self::new(StatusCode::SERVICE_UNAVAILABLE, "service_unavailable", message)
}
#[must_use]
pub fn to_json(&self) -> Value {
let mut body = json!({
"error": self.code,
"message": self.message,
"status": self.status.as_u16(),
});
if let Some(details) = &self.details {
body.as_object_mut().unwrap().insert("details".into(), details.clone());
}
body
}
}
impl std::fmt::Display for ApiError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "[{}] {}: {}", self.status, self.code, self.message)
}
}
impl std::error::Error for ApiError {}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(self.status, Json(self.to_json())).into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn json_shape_includes_required_fields() {
let e = ApiError::not_found("post not found");
let j = e.to_json();
assert_eq!(j["error"], "not_found");
assert_eq!(j["message"], "post not found");
assert_eq!(j["status"], 404);
assert!(j.get("details").is_none());
}
#[test]
fn details_field_when_provided() {
let e = ApiError::validation("invalid").with_field("email");
let j = e.to_json();
assert_eq!(j["details"]["field"], "email");
}
#[test]
fn presets_carry_correct_status() {
assert_eq!(ApiError::bad_request("x").status, StatusCode::BAD_REQUEST);
assert_eq!(ApiError::unauthorized("x").status, StatusCode::UNAUTHORIZED);
assert_eq!(ApiError::forbidden("x").status, StatusCode::FORBIDDEN);
assert_eq!(ApiError::not_found("x").status, StatusCode::NOT_FOUND);
assert_eq!(ApiError::conflict("x").status, StatusCode::CONFLICT);
assert_eq!(ApiError::validation("x").status, StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(ApiError::rate_limited("x").status, StatusCode::TOO_MANY_REQUESTS);
assert_eq!(ApiError::internal("x").status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(ApiError::service_unavailable("x").status, StatusCode::SERVICE_UNAVAILABLE);
}
#[test]
fn presets_use_canonical_codes() {
assert_eq!(ApiError::bad_request("x").code, "bad_request");
assert_eq!(ApiError::not_found("x").code, "not_found");
assert_eq!(ApiError::validation("x").code, "validation_failed");
assert_eq!(ApiError::rate_limited("x").code, "rate_limited");
assert_eq!(ApiError::internal("x").code, "internal_error");
}
#[test]
fn display_format_is_human_readable() {
let e = ApiError::not_found("user 42 not found");
let s = format!("{e}");
assert!(s.contains("404"));
assert!(s.contains("not_found"));
assert!(s.contains("user 42 not found"));
}
#[test]
fn with_details_overrides_field() {
let e = ApiError::validation("invalid")
.with_field("title") .with_details(json!({"custom": true})); assert!(e.details.unwrap().get("custom").is_some());
}
#[test]
fn into_response_has_correct_status() {
let e = ApiError::forbidden("nope");
let r = e.into_response();
assert_eq!(r.status(), StatusCode::FORBIDDEN);
}
}