actix-cloud 0.6.3

Actix Cloud is an all-in-one web framework based on Actix Web.
Documentation
//! Provide useful response types.
//!
//! With the `i18n` feature, response messages are translated automatically through the
//! request locale; with the `response-json` feature, [`JsonResponse`] serializes the
//! body as `{"code": ..., "message": ..., "data": ...}`. To define response codes in
//! YAML files and generate the corresponding enums, see
//! [`response_build`](crate::response_build).
use std::fmt::{self, Display};

use actix_web::{
    http::{
        header::{self, ContentDisposition, DispositionParam, DispositionType},
        StatusCode,
    },
    HttpResponse, HttpResponseBuilder,
};
use futures::{future, stream::once};

/// Alias for a `Result` with [`ResponseError`] as its error type.
pub type RspResult<T> = Result<T, ResponseError>;

/// Error type for handlers returning [`RspResult`].
///
/// Wraps an `anyhow::Error`; the HTTP response is always an opaque 500 without
/// leaking the error details to the client.
#[derive(Debug)]
pub struct ResponseError(anyhow::Error);

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

impl actix_web::ResponseError for ResponseError {
    fn status_code(&self) -> StatusCode {
        StatusCode::INTERNAL_SERVER_ERROR
    }

    fn error_response(&self) -> HttpResponse {
        HttpResponse::build(self.status_code()).finish()
    }
}

impl<T> From<T> for ResponseError
where
    T: Into<anyhow::Error>,
{
    fn from(t: T) -> Self {
        Self(t.into())
    }
}

/// Business code and message carried by a [`Response`].
///
/// Implemented by the enums generated by
/// [`generate_response`](crate::response_build::generate_response), or by hand.
pub trait ResponseCodeTrait {
    /// Business code in the response body.
    fn code(&self) -> i64;
    /// Message in the response body (an i18n key when the `i18n` feature is enabled).
    fn message(&self) -> &'static str;
}

pub type ResponseBuilderFn = Box<dyn Fn(&mut HttpResponseBuilder)>;

/// Uniform response type with business code/message/data and i18n support.
///
/// Build one from a [`ResponseCodeTrait`] (via [`Self::new`]) or straight from an HTTP
/// [`StatusCode`] (via [`Self::new_code`]); finalize it through the builder-style methods
/// ([`message`](Self::message), [`data`](Self::data), [`builder`](Self::builder), ...).
/// `Response<serde_json::Value>` (aka [`JsonResponse`], feature `response-json`)
/// implements [`Responder`](actix_web::Responder) and serializes the body as JSON.
pub struct Response<T> {
    pub http_code: StatusCode,
    pub code: i64,
    pub message: String,
    pub data: Option<T>,
    pub builder: Vec<ResponseBuilderFn>,
    #[cfg(feature = "i18n")]
    pub translate: bool,
}

impl<T> Response<T> {
    /// Create a 200 response from a [`ResponseCodeTrait`], with i18n translation enabled
    /// for the message (feature `i18n`).
    pub fn new<C>(r: C) -> Self
    where
        C: ResponseCodeTrait,
    {
        Self {
            http_code: StatusCode::OK,
            code: r.code(),
            message: r.message().to_owned(),
            data: None,
            builder: Vec::new(),
            #[cfg(feature = "i18n")]
            translate: true,
        }
    }

    /// Create a response from a raw HTTP status code, with an empty message and
    /// i18n translation disabled.
    pub fn new_code(code: StatusCode) -> Self {
        Self {
            http_code: code,
            code: 0,
            message: String::new(),
            data: None,
            builder: Vec::new(),
            #[cfg(feature = "i18n")]
            translate: false,
        }
    }

    /// Create a 201 Created response with a `Location` header pointing to `location`.
    pub fn created<C, S>(r: C, location: S) -> Self
    where
        C: ResponseCodeTrait,
        S: Into<String>,
    {
        let mut ret = Self::new(r);
        ret.http_code = StatusCode::CREATED;
        let location: String = location.into();
        ret.builder(move |r| {
            r.insert_header((header::LOCATION, location.clone()));
        })
    }

    /// Create a 204 No Content response (empty body, no JSON headers).
    pub fn no_content() -> Self {
        Self::new_code(StatusCode::NO_CONTENT)
    }

    /// Create a 400 Bad Request response with `s` as the message.
    pub fn bad_request<S: Into<String>>(s: S) -> Self {
        Self::new_code(StatusCode::BAD_REQUEST).message(s)
    }

    /// Create a 403 Forbidden response.
    pub fn forbidden() -> Self {
        Self::new_code(StatusCode::FORBIDDEN)
    }

    /// Create a 404 Not Found response.
    pub fn not_found() -> Self {
        Self::new_code(StatusCode::NOT_FOUND)
    }

    /// Create a redirect response (`code` should be a 3xx status) with a
    /// `Location` header pointing to `s`.
    pub fn redirect<S: Into<String>>(code: StatusCode, s: S) -> Self {
        let s: String = s.into();
        Self::new_code(code).builder(move |r| {
            r.insert_header((header::LOCATION, s.clone()));
        })
    }

    /// Add a callback to mutate the `HttpResponseBuilder` before the response is built
    /// (e.g. to insert extra headers). Multiple callbacks are applied in call order.
    pub fn builder<F>(mut self, f: F) -> Self
    where
        F: Fn(&mut HttpResponseBuilder) + 'static,
    {
        self.builder.push(Box::new(f));
        self
    }

    /// Override the response message. With feature `i18n` the message is treated as a
    /// translation key (see [`Self::translate`]).
    pub fn message<S: Into<String>>(mut self, s: S) -> Self {
        self.message = s.into();
        self
    }

    /// Attach the response payload.
    pub fn data(mut self, data: T) -> Self {
        self.data = Some(data);
        self
    }

    /// Build a file-download response: `data` is sent as an attachment named `name`
    /// (`application/octet-stream`).
    pub fn file(name: String, data: Vec<u8>) -> HttpResponse {
        let body = once(future::ok::<_, actix_web::Error>(data.into()));
        let header = ContentDisposition {
            disposition: DispositionType::Attachment,
            parameters: vec![DispositionParam::Filename(name)],
        };
        HttpResponse::Ok()
            .insert_header(("Content-Disposition", header))
            .content_type("application/octet-stream")
            .streaming(body)
    }

    #[cfg(feature = "i18n")]
    /// Enable i18n translation of the message (feature `i18n`).
    ///
    /// Enabled by default for [`Self::new`], disabled for [`Self::new_code`].
    pub fn translate(mut self) -> Self {
        self.translate = true;
        self
    }

    #[cfg(feature = "i18n")]
    /// Translate the message with the request locale (from [`crate::request::Extension`],
    /// falling back to `locale.default` in `GlobalState`).
    ///
    /// Returns the message as-is when translation is disabled, when `GlobalState` is not
    /// registered, or when the request extension is missing.
    pub fn i18n_message(&self, req: &actix_web::HttpRequest) -> String {
        use actix_web::HttpMessage as _;

        if self.translate {
            req.app_data::<actix_web::web::Data<crate::state::GlobalState>>()
                .map_or_else(
                    || self.message.clone(),
                    |state| {
                        if let Some(ext) = req
                            .extensions()
                            .get::<std::sync::Arc<crate::request::Extension>>()
                        {
                            crate::t!(state.locale, &self.message, &ext.lang)
                        } else {
                            self.message.clone()
                        }
                    },
                )
        } else {
            self.message.clone()
        }
    }
}

#[cfg(feature = "response-json")]
pub type JsonResponse = Response<serde_json::Value>;

#[cfg(feature = "response-json")]
impl JsonResponse {
    /// Attach `data`, serialized to JSON, as the response payload.
    pub fn json<T: serde::Serialize>(mut self, data: T) -> Self {
        self.data = Some(serde_json::json!(data));
        self
    }
}

#[cfg(feature = "response-json")]
impl actix_web::Responder for JsonResponse {
    type Body = actix_web::body::EitherBody<String>;

    fn respond_to(
        self,
        #[allow(unused_variables)] req: &actix_web::HttpRequest,
    ) -> HttpResponse<Self::Body> {
        if self.http_code.is_success() {
            #[cfg(feature = "i18n")]
            let message = self.i18n_message(req);
            #[cfg(not(feature = "i18n"))]
            let message = self.message;
            let mut body = serde_json::json!({
                "code": self.code,
                "message": message,
            });
            if let Some(data) = self.data {
                body.as_object_mut()
                    .unwrap()
                    .insert(String::from("data"), data);
            }
            let body = body.to_string();
            let mut rsp = HttpResponse::build(self.http_code);
            if self.http_code != StatusCode::NO_CONTENT {
                rsp.content_type(actix_web::http::header::ContentType::json());
            }
            for builder in self.builder {
                builder(&mut rsp);
            }
            if self.http_code == StatusCode::NO_CONTENT {
                rsp.finish().map_into_right_body()
            } else {
                rsp.message_body(body).unwrap().map_into_left_body()
            }
        } else {
            let mut rsp = HttpResponse::build(self.http_code);
            for builder in self.builder {
                builder(&mut rsp);
            }
            if self.message.is_empty() {
                rsp.finish().map_into_right_body()
            } else {
                rsp.message_body(self.message).unwrap().map_into_left_body()
            }
        }
    }
}