arcly-http 0.2.1

Enterprise-grade NestJS-inspired web framework on axum: zero-lock DI, declarative controllers, multi-tenant data routing, transactional outbox, ABAC, and a self-documenting OpenAPI surface
Documentation
//! HTTP error domain.
//!
//! `HttpError` is the open trait every domain error implements; `HttpException`
//! is the type-erased box that handlers actually return. The framework's stock
//! errors (`NotFound`, `Unauthorized`, `BadRequest`, `Validation`,
//! `ServiceUnavailable`, `Conflict`, `TooManyRequests`, `Internal`) all
//! implement `HttpError` and gain `From<…> for HttpException` automatically,
//! so handlers compose with `?` regardless of which concrete error variant
//! they construct.

use std::borrow::Cow;
use std::error::Error as StdError;
use std::fmt;

use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde::Serialize;

// ─── Field-level validation entry ────────────────────────────────────────
#[derive(Debug, Serialize, Clone)]
pub struct FieldError {
    pub field: String,
    pub code: String,
    pub message: String,
}

// ─── ProblemDetails (RFC 7807) ───────────────────────────────────────────
#[derive(Debug, Serialize, Clone)]
pub struct ProblemDetails {
    #[serde(rename = "type")]
    pub kind: Cow<'static, str>,
    pub title: Cow<'static, str>,
    pub status: u16,
    pub detail: Cow<'static, str>,
    #[serde(skip_serializing_if = "Vec::is_empty", default)]
    pub errors: Vec<FieldError>,
}

impl ProblemDetails {
    #[inline]
    pub fn new(
        status: u16,
        kind: &'static str,
        title: &'static str,
        detail: impl Into<Cow<'static, str>>,
    ) -> Self {
        Self {
            kind: Cow::Borrowed(kind),
            title: Cow::Borrowed(title),
            status,
            detail: detail.into(),
            errors: Vec::new(),
        }
    }
    #[inline]
    pub fn with_errors(mut self, errors: Vec<FieldError>) -> Self {
        self.errors = errors;
        self
    }
}

// ─── The open trait ──────────────────────────────────────────────────────
/// Every domain error type implements `HttpError`. The framework converts to
/// a `ProblemDetails` body and the appropriate status code.
pub trait HttpError: StdError + Send + Sync + 'static {
    fn problem(&self) -> ProblemDetails;
}

// ─── HttpException — the type-erased return shape ────────────────────────
/// Boxed `HttpError`. Handlers return `Result<T, HttpException>`; `?` works
/// across any user-defined error via the blanket `From<E: HttpError>` impl.
pub struct HttpException(pub Box<dyn HttpError>);

impl fmt::Debug for HttpException {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "HttpException({})", self.0)
    }
}

impl fmt::Display for HttpException {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        fmt::Display::fmt(&self.0, f)
    }
}

impl<E: HttpError + 'static> From<E> for HttpException {
    #[inline]
    fn from(e: E) -> Self {
        Self(Box::new(e))
    }
}

impl IntoResponse for HttpException {
    fn into_response(self) -> Response {
        let p = self.0.problem();
        let status = StatusCode::from_u16(p.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
        (status, axum::Json(p)).into_response()
    }
}

// ─── Stock errors ────────────────────────────────────────────────────────
macro_rules! stock_error {
    ($name:ident, $status:expr, $kind:expr, $title:expr, $default_detail:expr) => {
        #[derive(Debug, Clone)]
        pub struct $name {
            pub detail: Cow<'static, str>,
        }
        impl $name {
            #[inline]
            pub fn new(detail: impl Into<Cow<'static, str>>) -> Self {
                Self {
                    detail: detail.into(),
                }
            }
        }
        impl Default for $name {
            fn default() -> Self {
                Self {
                    detail: Cow::Borrowed($default_detail),
                }
            }
        }
        impl fmt::Display for $name {
            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
                write!(f, "{}: {}", $title, self.detail)
            }
        }
        impl StdError for $name {}
        impl HttpError for $name {
            fn problem(&self) -> ProblemDetails {
                ProblemDetails::new($status, $kind, $title, self.detail.clone())
            }
        }
    };
}

stock_error!(
    NotFound,
    404,
    "not-found",
    "Not Found",
    "resource not found"
);
stock_error!(
    Unauthorized,
    401,
    "unauthorized",
    "Unauthorized",
    "authentication required"
);
stock_error!(Forbidden, 403, "forbidden", "Forbidden", "access denied");
stock_error!(
    BadRequest,
    400,
    "bad-request",
    "Bad Request",
    "malformed request"
);
stock_error!(Conflict, 409, "conflict", "Conflict", "resource conflict");
stock_error!(
    TooManyRequests,
    429,
    "too-many-requests",
    "Too Many Requests",
    "rate limit exceeded"
);
stock_error!(
    ServiceUnavailable,
    503,
    "service-unavailable",
    "Service Unavailable",
    "downstream unavailable"
);
stock_error!(
    Internal,
    500,
    "internal",
    "Internal Server Error",
    "internal error"
);
stock_error!(
    GatewayTimeout,
    504,
    "gateway-timeout",
    "Gateway Timeout",
    "handler deadline exceeded"
);

// Validation gets its own type because it carries structured per-field errors.
#[derive(Debug, Clone)]
pub struct Validation {
    pub errors: Vec<FieldError>,
}
impl Validation {
    #[inline]
    pub fn new(errors: Vec<FieldError>) -> Self {
        Self { errors }
    }
}
impl fmt::Display for Validation {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "validation failed ({} error(s))", self.errors.len())
    }
}
impl StdError for Validation {}
impl HttpError for Validation {
    fn problem(&self) -> ProblemDetails {
        ProblemDetails::new(
            422,
            "validation",
            "Unprocessable Entity",
            "payload failed validation",
        )
        .with_errors(self.errors.clone())
    }
}

impl From<validator::ValidationErrors> for Validation {
    fn from(errs: validator::ValidationErrors) -> Self {
        let mut out = Vec::new();
        for (field, kinds) in errs.field_errors() {
            for k in kinds {
                let message = k
                    .message
                    .as_ref()
                    .map(|m| m.to_string())
                    .unwrap_or_else(|| format!("failed `{}` rule", k.code));
                out.push(FieldError {
                    field: field.to_string(),
                    code: k.code.to_string(),
                    message,
                });
            }
        }
        Self::new(out)
    }
}

// ─── Back-compat shim: the previous closed `Error` enum ──────────────────
//
// Kept so existing guards / extractors keep building. The variants
// trampoline to the new typed errors via `From<Error> for HttpException`.
#[derive(Debug)]
pub enum Error {
    NotFound,
    Unauthorized,
    Forbidden,
    TooManyRequests,
    ServiceUnavailable(&'static str),
    BadRequest(&'static str),
    Validation(Vec<FieldError>),
    Internal(&'static str),
}

impl fmt::Display for Error {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Error::NotFound => write!(f, "not found"),
            Error::Unauthorized => write!(f, "unauthorized"),
            Error::Forbidden => write!(f, "forbidden"),
            Error::TooManyRequests => write!(f, "rate limit exceeded"),
            Error::ServiceUnavailable(d) => write!(f, "service unavailable: {d}"),
            Error::BadRequest(d) => write!(f, "bad request: {d}"),
            Error::Validation(_) => write!(f, "validation failed"),
            Error::Internal(d) => write!(f, "internal: {d}"),
        }
    }
}
impl StdError for Error {}

impl HttpError for Error {
    fn problem(&self) -> ProblemDetails {
        match self {
            Error::NotFound => NotFound::default().problem(),
            Error::Unauthorized => Unauthorized::default().problem(),
            Error::Forbidden => Forbidden::default().problem(),
            Error::TooManyRequests => TooManyRequests::default().problem(),
            Error::ServiceUnavailable(d) => ServiceUnavailable::new(*d).problem(),
            Error::BadRequest(d) => BadRequest::new(*d).problem(),
            Error::Validation(v) => Validation::new(v.clone()).problem(),
            Error::Internal(d) => Internal::new(*d).problem(),
        }
    }
}

impl IntoResponse for Error {
    fn into_response(self) -> Response {
        HttpException::from(self).into_response()
    }
}

impl From<validator::ValidationErrors> for Error {
    fn from(errs: validator::ValidationErrors) -> Self {
        Error::Validation(Validation::from(errs).errors)
    }
}