noema-actix-webapi 0.1.0

Actix-web backend runtime on Noema (modules, sqlx, UoW, swagger, WebSocket dispatch)
use actix_web::http::StatusCode;
use actix_web::{HttpResponse, ResponseError};
use serde::Serialize;
use serde_json::{Value, json};
use utoipa::ToSchema;

use crate::validation::ValidationError;

pub const CODE_INTERNAL: &str = "internal";
pub const CODE_VALIDATION: &str = "validation.failed";
pub const CODE_DATABASE: &str = "infrastructure.database";
pub const CODE_UNAVAILABLE: &str = "infrastructure.unavailable";
pub const CODE_UNAUTHORIZED: &str = "unauthorized";

/// JSON error payload for HTTP and WebSocket.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct ErrorBody {
    pub code: String,
    pub message: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    #[schema(value_type = Option<Object>)]
    pub details: Option<Value>,
}

/// HTTP status plus [`ErrorBody`]. **Presentation only** — domain handlers return their own
/// `std::error::Error` types. Module routes map those into [`HttpError`] / [`MappedError`].
#[derive(Debug, Clone)]
pub struct MappedError {
    status: StatusCode,
    body: ErrorBody,
}

impl MappedError {
    pub fn new(status: StatusCode, code: impl Into<String>, message: impl Into<String>) -> Self {
        Self {
            status,
            body: ErrorBody {
                code: code.into(),
                message: message.into(),
                details: None,
            },
        }
    }

    pub fn with_details(mut self, details: Value) -> Self {
        self.body.details = Some(details);
        self
    }

    pub fn not_found(message: impl Into<String>) -> Self {
        Self::new(StatusCode::NOT_FOUND, "not_found", message)
    }

    pub fn conflict(message: impl Into<String>) -> Self {
        Self::new(StatusCode::CONFLICT, "conflict", message)
    }

    pub fn bad_request(message: impl Into<String>) -> Self {
        Self::new(StatusCode::BAD_REQUEST, "bad_request", message)
    }

    pub fn unauthorized(message: impl Into<String>) -> Self {
        Self::new(StatusCode::UNAUTHORIZED, CODE_UNAUTHORIZED, message)
    }

    pub fn status(&self) -> StatusCode {
        self.status
    }

    pub fn body(&self) -> &ErrorBody {
        &self.body
    }

    pub fn into_response(self) -> HttpResponse {
        HttpResponse::build(self.status).json(self.body)
    }
}

impl std::fmt::Display for MappedError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}: {}", self.body.code, self.body.message)
    }
}

impl std::error::Error for MappedError {}

/// Actix handler error. Use with [`HttpResult`] so `send().await?` and `page.validate()?` work.
#[derive(Debug, Clone)]
pub struct HttpError(MappedError);

/// `Result<T, HttpError>`. Routes typically return [`HttpResult`] (`T` = `HttpResponse`).
pub type HttpResult<T = HttpResponse> = Result<T, HttpError>;

impl HttpError {
    pub fn inner(&self) -> &MappedError {
        &self.0
    }

    pub fn into_mapped(self) -> MappedError {
        self.0
    }
}

impl From<MappedError> for HttpError {
    fn from(value: MappedError) -> Self {
        Self(value)
    }
}

impl From<Box<dyn std::error::Error + Send + Sync>> for HttpError {
    fn from(err: Box<dyn std::error::Error + Send + Sync>) -> Self {
        Self(map_error(&*err))
    }
}

impl From<ValidationError> for HttpError {
    fn from(err: ValidationError) -> Self {
        Self(map_error(&err))
    }
}

impl From<sqlx::Error> for HttpError {
    fn from(err: sqlx::Error) -> Self {
        Self(map_sqlx(&err))
    }
}

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

impl std::error::Error for HttpError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        Some(&self.0)
    }
}

impl ResponseError for HttpError {
    fn status_code(&self) -> StatusCode {
        self.0.status()
    }

    fn error_response(&self) -> HttpResponse {
        self.0.clone().into_response()
    }
}

/// Map a boxed error to HTTP/WS payload (presentation). Domain types stay unknown here
/// except [`ValidationError`] and `sqlx::Error`; apps convert their errors to [`MappedError`]
/// in the module presentation (`From<AppError> for HttpError`).
pub fn map_error(err: &(dyn std::error::Error + Send + Sync + 'static)) -> MappedError {
    if let Some(http) = err.downcast_ref::<HttpError>() {
        return http.0.clone();
    }
    if let Some(mapped) = err.downcast_ref::<MappedError>() {
        return mapped.clone();
    }
    if let Some(validation) = err.downcast_ref::<ValidationError>() {
        return MappedError::new(
            StatusCode::BAD_REQUEST,
            CODE_VALIDATION,
            "Validation failed",
        )
        .with_details(json!({ "fields": validation.fields() }));
    }
    if let Some(sqlx_err) = err.downcast_ref::<sqlx::Error>() {
        return map_sqlx(sqlx_err);
    }
    MappedError::new(
        StatusCode::INTERNAL_SERVER_ERROR,
        CODE_INTERNAL,
        "Internal server error",
    )
}

pub fn error_response(err: &(dyn std::error::Error + Send + Sync + 'static)) -> HttpResponse {
    map_error(err).into_response()
}

/// WebSocket text envelope: `{ "name": "error", "data": ErrorBody }`.
/// Used by the default [`crate::WsConnection::on_dispatch_error`].
pub fn error_ws_envelope(err: &(dyn std::error::Error + Send + Sync + 'static)) -> String {
    #[derive(Serialize)]
    struct Envelope<'a> {
        name: &'a str,
        data: &'a ErrorBody,
    }
    let mapped = map_error(err);
    serde_json::to_string(&Envelope {
        name: "error",
        data: mapped.body(),
    })
    .expect("error envelope")
}

fn map_sqlx(err: &sqlx::Error) -> MappedError {
    match err {
        sqlx::Error::RowNotFound => MappedError::not_found("Not found"),
        sqlx::Error::PoolTimedOut | sqlx::Error::PoolClosed | sqlx::Error::WorkerCrashed => {
            MappedError::new(
                StatusCode::SERVICE_UNAVAILABLE,
                CODE_UNAVAILABLE,
                "Database unavailable",
            )
        }
        _ => {
            let mapped = MappedError::new(StatusCode::BAD_GATEWAY, CODE_DATABASE, "Database error");
            if crate::config::Environment::current().exposes_infra_errors() {
                mapped.with_details(json!({ "reason": err.to_string() }))
            } else {
                mapped
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::validation::ValidationError;

    #[test]
    fn mapped_error_is_preserved() {
        let src = MappedError::not_found("user missing");
        let boxed: Box<dyn std::error::Error + Send + Sync> = Box::new(src.clone());
        let got = map_error(&*boxed);
        assert_eq!(got.status(), StatusCode::NOT_FOUND);
        assert_eq!(got.body().code, "not_found");
        assert_eq!(got.body().message, "user missing");
    }

    #[test]
    fn sqlx_protocol_is_bad_gateway() {
        let err = sqlx::Error::Protocol("boom".into());
        let got = map_error(&err);
        assert_eq!(got.status(), StatusCode::BAD_GATEWAY);
        assert_eq!(got.body().code, CODE_DATABASE);
        assert!(got.body().details.is_none());
    }

    #[test]
    fn sqlx_reason_only_in_development() {
        crate::config::with_environment(crate::config::Environment::Development, || {
            let got = map_error(&sqlx::Error::Protocol("boom".into()));
            let reason = got.body().details.as_ref().unwrap()["reason"]
                .as_str()
                .unwrap();
            assert!(reason.contains("boom"), "{reason}");
        });
        let prod = map_error(&sqlx::Error::Protocol("boom".into()));
        assert!(prod.body().details.is_none());
    }

    #[test]
    fn sqlx_row_not_found_is_not_found() {
        let got = map_error(&sqlx::Error::RowNotFound);
        assert_eq!(got.status(), StatusCode::NOT_FOUND);
        assert_eq!(got.body().code, "not_found");
        assert!(got.body().details.is_none());
    }

    #[test]
    fn sqlx_pool_timeout_is_unavailable() {
        let got = map_error(&sqlx::Error::PoolTimedOut);
        assert_eq!(got.status(), StatusCode::SERVICE_UNAVAILABLE);
        assert_eq!(got.body().code, CODE_UNAVAILABLE);
    }

    #[test]
    fn validation_is_bad_request() {
        let mut err = ValidationError::new();
        err.add("email", "must not be empty");
        let got = map_error(&err);
        assert_eq!(got.status(), StatusCode::BAD_REQUEST);
        assert_eq!(got.body().code, CODE_VALIDATION);
        assert!(got.body().details.is_some());
    }

    #[test]
    fn unknown_is_internal() {
        let err = std::io::Error::other("nope");
        let got = map_error(&err);
        assert_eq!(got.status(), StatusCode::INTERNAL_SERVER_ERROR);
        assert_eq!(got.body().code, CODE_INTERNAL);
    }

    #[test]
    fn question_mark_maps_send_error() {
        fn send_like() -> Result<u8, Box<dyn std::error::Error + Send + Sync>> {
            Err(Box::new(sqlx::Error::PoolTimedOut))
        }
        fn route() -> HttpResult<u8> {
            Ok(send_like()?)
        }
        let err = route().unwrap_err();
        assert_eq!(err.inner().status(), StatusCode::SERVICE_UNAVAILABLE);
        assert_eq!(err.inner().body().code, CODE_UNAVAILABLE);
    }

    #[test]
    fn validation_question_mark() {
        fn check() -> HttpResult<()> {
            let req = crate::page::PageRequest {
                page: 0,
                page_size: 20,
            };
            req.validate()?;
            Ok(())
        }
        let err = check().unwrap_err();
        assert_eq!(err.inner().status(), StatusCode::BAD_REQUEST);
    }

    #[actix_web::test]
    async fn handler_can_return_http_error() {
        use actix_web::{App, test, web};
        async fn boom() -> HttpResult {
            Err(MappedError::not_found("missing").into())
        }
        let srv = test::init_service(App::new().route("/", web::get().to(boom))).await;
        let resp = test::call_service(&srv, test::TestRequest::get().uri("/").to_request()).await;
        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
    }
}