use std::error::Error;
use aro_core::error::RepoError;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use crate::fallback::json_error_response;
#[derive(Debug)]
pub enum AroError {
NotFound,
Conflict,
ValidationError(String),
BadRequest(String),
Unauthorized(String),
Forbidden(String),
Internal(Box<dyn Error + Send + Sync>),
}
impl AroError {
pub fn status_code(&self) -> StatusCode {
match self {
Self::NotFound => StatusCode::NOT_FOUND,
Self::Conflict => StatusCode::CONFLICT,
Self::ValidationError(_) => StatusCode::UNPROCESSABLE_ENTITY,
Self::BadRequest(_) => StatusCode::BAD_REQUEST,
Self::Unauthorized(_) => StatusCode::UNAUTHORIZED,
Self::Forbidden(_) => StatusCode::FORBIDDEN,
Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
pub fn message(&self) -> String {
match self {
Self::NotFound => "not found".to_string(),
Self::Conflict => "conflict".to_string(),
Self::ValidationError(msg)
| Self::BadRequest(msg)
| Self::Unauthorized(msg)
| Self::Forbidden(msg) => msg.clone(),
Self::Internal(_) => "internal server error".to_string(),
}
}
}
impl IntoResponse for AroError {
fn into_response(self) -> Response {
if let Self::Internal(ref err) = self {
tracing::error!(error = %err, "internal server error");
}
json_error_response(self.status_code(), self.message())
}
}
impl From<RepoError> for AroError {
fn from(err: RepoError) -> Self {
match err {
RepoError::NotFound => Self::NotFound,
RepoError::Conflict => Self::Conflict,
RepoError::ValidationError(msg) => Self::ValidationError(msg),
RepoError::Unknown(err) => Self::Internal(err),
}
}
}
impl std::fmt::Display for AroError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotFound => write!(f, "not found"),
Self::Conflict => write!(f, "conflict"),
Self::ValidationError(msg) => write!(f, "validation error: {msg}"),
Self::BadRequest(msg) => write!(f, "bad request: {msg}"),
Self::Unauthorized(msg) => write!(f, "unauthorized: {msg}"),
Self::Forbidden(msg) => write!(f, "forbidden: {msg}"),
Self::Internal(err) => write!(f, "internal error: {err}"),
}
}
}
impl Error for AroError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Internal(err) => Some(err.as_ref()),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use http_body_util::BodyExt;
use tracing_test::traced_test;
async fn response_json(resp: Response) -> serde_json::Value {
let body = resp.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
serde_json::from_slice(&bytes).unwrap()
}
#[tokio::test]
async fn not_found_produces_404_json() {
let resp = AroError::NotFound.into_response();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
assert_eq!(
resp.headers().get("content-type").unwrap(),
"application/json"
);
let json = response_json(resp).await;
assert_eq!(json["error"], "not found");
assert_eq!(json["status"], 404);
}
#[tokio::test]
async fn conflict_produces_409_json() {
let resp = AroError::Conflict.into_response();
assert_eq!(resp.status(), StatusCode::CONFLICT);
let json = response_json(resp).await;
assert_eq!(json["error"], "conflict");
assert_eq!(json["status"], 409);
}
#[tokio::test]
async fn validation_error_produces_422_json() {
let resp = AroError::ValidationError("name is required".into()).into_response();
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
let json = response_json(resp).await;
assert_eq!(json["error"], "name is required");
assert_eq!(json["status"], 422);
}
#[tokio::test]
async fn bad_request_produces_400_json() {
let resp = AroError::BadRequest("invalid id".into()).into_response();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let json = response_json(resp).await;
assert_eq!(json["error"], "invalid id");
assert_eq!(json["status"], 400);
}
#[tokio::test]
async fn internal_produces_500_json() {
let inner = std::io::Error::other("db crashed");
let resp = AroError::Internal(Box::new(inner)).into_response();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
let json = response_json(resp).await;
assert_eq!(json["error"], "internal server error");
assert_eq!(json["status"], 500);
}
#[tokio::test]
#[traced_test]
async fn internal_logs_error_before_response() {
let inner = std::io::Error::other("db crashed");
let resp = AroError::Internal(Box::new(inner)).into_response();
assert!(logs_contain("internal server error"));
assert!(logs_contain("db crashed"));
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[test]
fn repo_not_found_converts_to_aro_not_found() {
let aro_err: AroError = RepoError::NotFound.into();
assert!(matches!(aro_err, AroError::NotFound));
}
#[test]
fn repo_conflict_converts_to_aro_conflict() {
let aro_err: AroError = RepoError::Conflict.into();
assert!(matches!(aro_err, AroError::Conflict));
}
#[test]
fn repo_validation_converts_to_aro_validation() {
let aro_err: AroError = RepoError::ValidationError("bad".into()).into();
assert!(matches!(aro_err, AroError::ValidationError(msg) if msg == "bad"));
}
#[test]
fn repo_unknown_converts_to_aro_internal() {
let inner = std::io::Error::other("oops");
let aro_err: AroError = RepoError::Unknown(Box::new(inner)).into();
assert!(matches!(aro_err, AroError::Internal(_)));
}
#[tokio::test]
async fn unauthorized_produces_401_json() {
let resp = AroError::Unauthorized("invalid token".into()).into_response();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let json = response_json(resp).await;
assert_eq!(json["error"], "invalid token");
assert_eq!(json["status"], 401);
}
#[tokio::test]
async fn forbidden_produces_403_json() {
let resp = AroError::Forbidden("insufficient permissions".into()).into_response();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let json = response_json(resp).await;
assert_eq!(json["error"], "insufficient permissions");
assert_eq!(json["status"], 403);
}
#[test]
fn unauthorized_display() {
let err = AroError::Unauthorized("bad token".into());
assert_eq!(err.to_string(), "unauthorized: bad token");
}
#[test]
fn forbidden_display() {
let err = AroError::Forbidden("not allowed".into());
assert_eq!(err.to_string(), "forbidden: not allowed");
}
#[tokio::test]
async fn error_response_body_matches_expected_structure() {
let resp = AroError::NotFound.into_response();
let json = response_json(resp).await;
let obj = json.as_object().unwrap();
assert_eq!(obj.len(), 2);
assert!(obj.contains_key("error"));
assert!(obj.contains_key("status"));
}
}