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 from_status(status: StatusCode, message: impl Into<String>) -> Self {
let code = match status {
StatusCode::BAD_REQUEST => "bad_request",
StatusCode::UNAUTHORIZED => "unauthorized",
StatusCode::FORBIDDEN => "forbidden",
StatusCode::NOT_FOUND => "not_found",
StatusCode::METHOD_NOT_ALLOWED => "method_not_allowed",
StatusCode::CONFLICT => "conflict",
StatusCode::PAYLOAD_TOO_LARGE => "payload_too_large",
StatusCode::UNSUPPORTED_MEDIA_TYPE => "unsupported_media_type",
StatusCode::UNPROCESSABLE_ENTITY => "validation_failed",
StatusCode::TOO_MANY_REQUESTS => "rate_limited",
StatusCode::BAD_GATEWAY => "bad_gateway",
StatusCode::SERVICE_UNAVAILABLE => "service_unavailable",
s if s.is_server_error() => "internal_error",
_ => "error",
};
Self::new(status, code, message)
}
#[must_use]
pub fn logged(status: StatusCode, context: &str, message: impl std::fmt::Display) -> Self {
if status.is_server_error() {
Self::from_status(status, crate::error::server_error_body(context, &message))
} else {
Self::from_status(status, message.to_string())
}
}
#[must_use]
pub fn bad_request(message: impl Into<String>) -> Self {
Self::from_status(StatusCode::BAD_REQUEST, message)
}
#[must_use]
pub fn unauthorized(message: impl Into<String>) -> Self {
Self::from_status(StatusCode::UNAUTHORIZED, message)
}
#[must_use]
pub fn forbidden(message: impl Into<String>) -> Self {
Self::from_status(StatusCode::FORBIDDEN, message)
}
#[must_use]
pub fn not_found(message: impl Into<String>) -> Self {
Self::from_status(StatusCode::NOT_FOUND, message)
}
#[must_use]
pub fn conflict(message: impl Into<String>) -> Self {
Self::from_status(StatusCode::CONFLICT, message)
}
#[must_use]
pub fn validation(message: impl Into<String>) -> Self {
Self::from_status(StatusCode::UNPROCESSABLE_ENTITY, message)
}
#[must_use]
pub fn rate_limited(message: impl Into<String>) -> Self {
Self::from_status(StatusCode::TOO_MANY_REQUESTS, message)
}
#[must_use]
pub fn rate_limited_response(message: impl Into<String>, retry_after_secs: u64) -> Response {
let mut resp = Self::rate_limited(message)
.with_details(json!({ "retry_after": retry_after_secs }))
.into_response();
resp.headers_mut()
.insert(axum::http::header::RETRY_AFTER, retry_after_secs.into());
resp
}
#[must_use]
pub fn internal(message: impl Into<String>) -> Self {
Self::from_status(StatusCode::INTERNAL_SERVER_ERROR, message)
}
#[must_use]
pub fn service_unavailable(message: impl Into<String>) -> Self {
Self::from_status(StatusCode::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 logged_hides_a_server_errors_cause_but_not_a_client_errors() {
let _env = crate::error::test_env::lock();
crate::error::test_env::with("RUSTANGO_DISCLOSE_ERRORS", None, || {
let e = ApiError::logged(
StatusCode::INTERNAL_SERVER_ERROR,
"test",
"relation \"users\" missing",
);
assert_eq!(e.code, "internal_error");
assert!(!e.message.contains("users"), "{e}");
});
let e = ApiError::logged(StatusCode::BAD_REQUEST, "test", "invalid cursor");
assert_eq!(e.code, "bad_request");
assert_eq!(e.message, "invalid cursor");
}
#[test]
fn too_many_requests_sets_retry_after() {
let r = ApiError::rate_limited_response("slow down", 7);
assert_eq!(r.status(), StatusCode::TOO_MANY_REQUESTS);
assert_eq!(r.headers()[axum::http::header::RETRY_AFTER], "7");
}
#[test]
fn into_response_has_correct_status() {
let e = ApiError::forbidden("nope");
let r = e.into_response();
assert_eq!(r.status(), StatusCode::FORBIDDEN);
}
}