180 lines
5.9 KiB
Rust
180 lines
5.9 KiB
Rust
use axum::http::StatusCode;
|
|
use axum::response::{IntoResponse, Response};
|
|
use serde::Serialize;
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct ErrorEnvelope {
|
|
error: ErrorBody,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct ErrorBody {
|
|
code: String,
|
|
message: String,
|
|
status: u16,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
#[allow(dead_code)]
|
|
pub enum AppError {
|
|
InternalError(String),
|
|
|
|
NotFound(String),
|
|
|
|
Unauthorized(String),
|
|
|
|
BadRequest(String),
|
|
|
|
Forbidden(String),
|
|
|
|
ServiceUnavailable(String),
|
|
|
|
PayloadTooLarge(String),
|
|
}
|
|
|
|
impl AppError {
|
|
fn status_code(&self) -> StatusCode {
|
|
match self {
|
|
AppError::InternalError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
|
AppError::NotFound(_) => StatusCode::NOT_FOUND,
|
|
AppError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
|
|
AppError::BadRequest(_) => StatusCode::BAD_REQUEST,
|
|
AppError::Forbidden(_) => StatusCode::FORBIDDEN,
|
|
AppError::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
|
|
AppError::PayloadTooLarge(_) => StatusCode::PAYLOAD_TOO_LARGE,
|
|
}
|
|
}
|
|
|
|
fn error_code(&self) -> &'static str {
|
|
match self {
|
|
AppError::InternalError(_) => "INTERNAL_ERROR",
|
|
AppError::NotFound(_) => "NOT_FOUND",
|
|
AppError::Unauthorized(_) => "UNAUTHORIZED",
|
|
AppError::BadRequest(_) => "BAD_REQUEST",
|
|
AppError::Forbidden(_) => "FORBIDDEN",
|
|
AppError::ServiceUnavailable(_) => "SERVICE_UNAVAILABLE",
|
|
AppError::PayloadTooLarge(_) => "PAYLOAD_TOO_LARGE",
|
|
}
|
|
}
|
|
|
|
fn message(&self) -> &str {
|
|
match self {
|
|
AppError::InternalError(msg)
|
|
| AppError::NotFound(msg)
|
|
| AppError::Unauthorized(msg)
|
|
| AppError::BadRequest(msg)
|
|
| AppError::Forbidden(msg)
|
|
| AppError::ServiceUnavailable(msg)
|
|
| AppError::PayloadTooLarge(msg) => msg,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl IntoResponse for AppError {
|
|
fn into_response(self) -> Response {
|
|
let status = self.status_code();
|
|
let envelope = ErrorEnvelope {
|
|
error: ErrorBody {
|
|
code: self.error_code().to_string(),
|
|
message: self.message().to_string(),
|
|
status: status.as_u16(),
|
|
},
|
|
};
|
|
|
|
let body = serde_json::to_string(&envelope).unwrap_or_else(|_| {
|
|
r#"{"error":{"code":"INTERNAL_ERROR","message":"Failed to serialize error","status":500}}"#
|
|
.to_string()
|
|
});
|
|
|
|
(status, [("content-type", "application/json")], body).into_response()
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for AppError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "[{}] {}", self.error_code(), self.message())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use http_body_util::BodyExt;
|
|
|
|
async fn error_to_json(error: AppError) -> (StatusCode, serde_json::Value) {
|
|
let response = error.into_response();
|
|
let status = response.status();
|
|
let body = response
|
|
.into_body()
|
|
.collect()
|
|
.await
|
|
.expect("body should collect")
|
|
.to_bytes();
|
|
let json: serde_json::Value =
|
|
serde_json::from_slice(&body).expect("body should be valid JSON");
|
|
(status, json)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn internal_error_returns_500() {
|
|
let (status, json) = error_to_json(AppError::InternalError("something broke".into())).await;
|
|
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
|
|
assert_eq!(json["error"]["code"], "INTERNAL_ERROR");
|
|
assert_eq!(json["error"]["message"], "something broke");
|
|
assert_eq!(json["error"]["status"], 500);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn not_found_returns_404() {
|
|
let (status, json) = error_to_json(AppError::NotFound("mailbox missing".into())).await;
|
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
|
assert_eq!(json["error"]["code"], "NOT_FOUND");
|
|
assert_eq!(json["error"]["message"], "mailbox missing");
|
|
assert_eq!(json["error"]["status"], 404);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unauthorized_returns_401() {
|
|
let (status, json) = error_to_json(AppError::Unauthorized("bad token".into())).await;
|
|
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
|
assert_eq!(json["error"]["code"], "UNAUTHORIZED");
|
|
assert_eq!(json["error"]["message"], "bad token");
|
|
assert_eq!(json["error"]["status"], 401);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn bad_request_returns_400() {
|
|
let (status, json) = error_to_json(AppError::BadRequest("invalid input".into())).await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
|
assert_eq!(json["error"]["code"], "BAD_REQUEST");
|
|
assert_eq!(json["error"]["message"], "invalid input");
|
|
assert_eq!(json["error"]["status"], 400);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn forbidden_returns_403() {
|
|
let (status, json) = error_to_json(AppError::Forbidden("blocked".into())).await;
|
|
assert_eq!(status, StatusCode::FORBIDDEN);
|
|
assert_eq!(json["error"]["code"], "FORBIDDEN");
|
|
assert_eq!(json["error"]["status"], 403);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn payload_too_large_returns_413() {
|
|
let (status, json) =
|
|
error_to_json(AppError::PayloadTooLarge("message too large".into())).await;
|
|
assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE);
|
|
assert_eq!(json["error"]["code"], "PAYLOAD_TOO_LARGE");
|
|
assert_eq!(json["error"]["status"], 413);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn service_unavailable_returns_503() {
|
|
let (status, json) = error_to_json(AppError::ServiceUnavailable("IMAP down".into())).await;
|
|
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
|
|
assert_eq!(json["error"]["code"], "SERVICE_UNAVAILABLE");
|
|
assert_eq!(json["error"]["message"], "IMAP down");
|
|
assert_eq!(json["error"]["status"], 503);
|
|
}
|
|
}
|