aro-web 1.0.0

HTTP/ADR layer for the Aro web framework
Documentation
//! Error types and JSON error response mapping for the Aro web framework.
//!
//! [`AroError`] provides a unified error type that bridges domain errors
//! (from [`aro_core::error::RepoError`]) to HTTP responses with consistent
//! JSON error bodies and appropriate status codes.
//!
//! Actions can return `Result<Json<T>, AroError>` for ergonomic error handling:
//!
//! ```ignore
//! async fn get_user(Path(id): Path<u64>, repo: Dep<dyn UserRepo>) -> Result<Json<User>, AroError> {
//!     let user = repo.find_by_id(id).await?; // RepoError -> AroError via From
//!     Ok(Json(user))
//! }
//! ```

use std::error::Error;

use aro_core::error::RepoError;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};

use crate::fallback::json_error_response;

/// Unified error type for Aro web actions.
///
/// Each variant maps to an HTTP status code and produces a JSON error
/// response body of the form `{"error": "message", "status": <code>}`.
#[derive(Debug)]
pub enum AroError {
    /// The requested resource was not found (404).
    NotFound,
    /// A conflicting resource already exists (409).
    Conflict,
    /// A validation rule was violated (422).
    ValidationError(String),
    /// The request was malformed (400).
    BadRequest(String),
    /// The request lacks valid authentication credentials (401).
    Unauthorized(String),
    /// The authenticated user does not have permission (403).
    Forbidden(String),
    /// An unexpected internal error occurred (500).
    Internal(Box<dyn Error + Send + Sync>),
}

impl AroError {
    /// Returns the HTTP status code for this error.
    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,
        }
    }

    /// Returns the user-facing error message for this 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;
        // Verify only "error" and "status" keys exist
        let obj = json.as_object().unwrap();
        assert_eq!(obj.len(), 2);
        assert!(obj.contains_key("error"));
        assert!(obj.contains_key("status"));
    }
}