shared-framework 0.0.17

Reusable building blocks for HTTP services — Hyper routing, SeaORM data layer, validation, OpenAPI docs, jobs, queues, cache.
Documentation
//! Typed HTTP responses: success payloads, errors, and content types.
//!
//! Handlers return `Result<ServiceResult<T>, ErrorResult>`: [`ServiceResult`] is the
//! standard JSON envelope (`status`/`message`/`data`) plus status code and content type,
//! and [`ErrorResult`] is its fallible counterpart. [`TypedServiceResult`] is the
//! object-safe trait both implement, so handlers needing heterogeneous payloads can
//! return `Box<dyn TypedServiceResult>` (see [`ServiceResult::boxed`]). Use
//! [`build_response`] / [`error_response`] to render them into `http::Response<String>`
//! with correlation headers.
//! ```ignore
//! async fn get_user(ctx: CorrelationContext) -> Result<ServiceResult<User>, ErrorResult> {
//!     let user = ctx.body::<User>()?;
//!     Ok(ServiceResult::ok("Fetched", user))
//! }
//! ```

pub mod html;

use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::sync::Arc;

/// Content type used when rendering a result: selects the `Content-Type` header,
/// and `File` additionally sets `Content-Disposition: attachment`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ResponseType {
    /// `application/json` (default).
    Json,
    /// `text/plain`.
    Text,
    /// `text/html`.
    Html,
    /// `application/xml`.
    Xml,
    /// `application/javascript`.
    Javascript,
    /// `application/octet-stream` with an attachment filename.
    File,
}

impl Default for ResponseType {
    fn default() -> Self {
        Self::Json
    }
}

/// Renderable handler result: message, HTTP status, content type, and body serializer.
/// Implemented by [`ServiceResult`], [`GenericTypedResult`], and [`ErrorResult`], so
/// handlers can return them as `Box<dyn TypedServiceResult>` for mixed payload shapes.
pub trait TypedServiceResult: Send + Sync {
    /// Human-readable message carried by the result.
    fn message(&self) -> &str;
    /// HTTP status code used for the response.
    fn code(&self) -> u16;
    /// Content type used for the `Content-Type` header.
    fn response_type(&self) -> ResponseType;
    /// Renders the response body; I/O or shape errors surface as `serde_json::Error`.
    fn serialize(&self) -> Result<String, serde_json::Error>;
    /// Returns the payload as JSON when representable; defaults to `None`.
    fn data_json(&self) -> Option<Value> {
        None
    }
}

/// Standard success envelope: serializes as `{"status","message","data"}` unless
/// [`ServiceResult::stripped`] is used, in which case only `data` is serialized.
/// Type parameter `T` is the `data` payload. `code` and `response_type` control the
/// HTTP status and content type but are not serialized into the body.
#[derive(Debug, Clone, Serialize)]
pub struct ServiceResult<T: Serialize> {
    /// Envelope status label (e.g. `"success"`).
    pub status: String,
    /// Human-readable message.
    pub message: String,
    /// Optional payload serialized as `data`.
    pub data: Option<T>,
    #[serde(skip)]
    /// HTTP status code for the response.
    pub code: u16,
    #[serde(skip)]
    /// Content type used when rendering the response.
    pub response_type: ResponseType,
    #[serde(skip)]
    naked: bool,
}

impl<T: Serialize> ServiceResult<T> {
    /// Creates a result with an explicit status label, message, optional payload, and HTTP code.
    pub fn new(
        status: impl Into<String>,
        message: impl Into<String>,
        data: Option<T>,
        code: u16,
    ) -> Self {
        Self {
            status: status.into(),
            message: message.into(),
            data,
            code,
            response_type: ResponseType::Json,
            naked: false,
        }
    }
    /// Creates a 200 success result with status `"success"`, the given message, and `data`.
    pub fn ok(message: impl Into<String>, data: T) -> Self {
        Self::new("success", message, Some(data), 200)
    }
    /// Creates a 200 success result with status `"success"` and no payload.
    pub fn ok_empty(message: impl Into<String>) -> Self
    where
        T: Default,
    {
        Self::new("success", message, None, 200)
    }
    /// Serializes only the `data` field instead of the `status`/`message`/`data` envelope.
    pub fn stripped(mut self) -> Self {
        self.naked = true;
        self
    }
    /// Overrides the content type used when rendering this result.
    pub fn with_response_type(mut self, rt: ResponseType) -> Self {
        self.response_type = rt;
        self
    }
    /// Overrides the HTTP status code used when rendering this result.
    pub fn with_code(mut self, code: u16) -> Self {
        self.code = code;
        self
    }

    /// Boxes this result so one handler can return different `ServiceResult<T>` payload shapes.
    pub fn boxed(self) -> Box<dyn TypedServiceResult>
    where
        T: Send + Sync + 'static,
    {
        Box::new(self)
    }

    /// Serializes the result body directly; the trait `serialize` delegates to this.
    /// A stripped result serializes only `data`, otherwise the full envelope is used.
    pub fn serialize_inherent(&self) -> Result<String, serde_json::Error> {
        if self.naked {
            return serde_json::to_string(&self.data);
        }
        let wrapper = serde_json::json!({ "status": self.status, "message": self.message, "data": self.data });
        serde_json::to_string(&wrapper)
    }
}

impl<T: Serialize + Send + Sync> TypedServiceResult for ServiceResult<T> {
    fn message(&self) -> &str {
        &self.message
    }
    fn code(&self) -> u16 {
        self.code
    }
    fn response_type(&self) -> ResponseType {
        self.response_type
    }
    fn serialize(&self) -> Result<String, serde_json::Error> {
        self.serialize_inherent()
    }
    fn data_json(&self) -> Option<Value> {
        self.data
            .as_ref()
            .and_then(|d| serde_json::to_value(d).ok())
    }
}

/// Generic JSON-compatible result: serializes its `data` value directly, or `"null"` when absent.
/// Useful for endpoints whose payload is already a `serde_json::Value`.
pub struct GenericTypedResult {
    /// Human-readable message (used if serialization fails upstream).
    pub message: String,
    /// Payload serialized directly as the body.
    pub data: Option<Value>,
    /// HTTP status code for the response.
    pub code: u16,
    /// Content type used when rendering the response.
    pub response_type: ResponseType,
}

impl TypedServiceResult for GenericTypedResult {
    fn message(&self) -> &str {
        &self.message
    }
    fn code(&self) -> u16 {
        self.code
    }
    fn response_type(&self) -> ResponseType {
        self.response_type
    }
    fn serialize(&self) -> Result<String, serde_json::Error> {
        match &self.data {
            Some(v) => serde_json::to_string(v),
            None => Ok("null".to_string()),
        }
    }
}

/// Handler failure value: message, optional JSON payload, and HTTP status code.
/// Returned as `Err` from handlers and rendered as an `{"status":"error",...}` envelope.
#[derive(Debug, Clone, Serialize)]
pub struct ErrorResult {
    /// Human-readable error message.
    pub message: String,
    /// Optional extra error payload.
    pub data: Option<Value>,
    /// HTTP status code for the response.
    pub code: u16,
}

impl ErrorResult {
    /// Creates an error with an explicit message, optional data, and HTTP status `code`.
    pub fn new(message: impl Into<String>, data: Option<Value>, code: u16) -> Self {
        Self {
            message: message.into(),
            data,
            code,
        }
    }

    /// Creates a 403 error with the given message.
    pub fn forbidden(msg: impl Into<String>) -> Self {
        Self::new(msg, None, 403)
    }

    /// Creates a 503 error with the given message.
    pub fn service_unavailable(msg: impl Into<String>) -> Self {
        Self::new(msg, None, 503)
    }

    /// Creates a 400 error with the given message.
    pub fn bad_request(msg: impl Into<String>) -> Self {
        Self::new(msg, None, 400)
    }
    /// Creates a 404 error with the given message.
    pub fn not_found(msg: impl Into<String>) -> Self {
        Self::new(msg, None, 404)
    }
    /// Creates a 500 error with the given message.
    pub fn internal(msg: impl Into<String>) -> Self {
        Self::new(msg, None, 500)
    }
    /// Creates an error from a displayable value with an explicit HTTP status `code`.
    pub fn from_error<E: std::fmt::Display + ?Sized>(err: &E, code: u16) -> Self {
        Self::new(err.to_string(), None, code)
    }

    /// Maps an `anyhow` error to a 400 when its message suggests bad input or a missing
    /// resource (`validation`/`invalid`/`not found`), otherwise to a 500.
    pub fn of(err: &anyhow::Error) -> Self {
        let msg = err.to_string();
        let lower = msg.to_lowercase();
        if lower.contains("validation") || lower.contains("invalid") || lower.contains("not found")
        {
            Self::new(msg, None, 400)
        } else {
            Self::new(msg, None, 500)
        }
    }
    /// Converts this error into an `"error"`-status `ServiceResult` for rendering.
    pub fn to_service_result(&self) -> ServiceResult<Value> {
        ServiceResult::new("error", self.message.clone(), self.data.clone(), self.code)
    }
    /// Returns the HTTP status code for this error.
    pub fn code(&self) -> u16 {
        self.code
    }
}

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

impl From<sea_orm::DbErr> for ErrorResult {
    fn from(err: sea_orm::DbErr) -> Self {
        match err {
            sea_orm::DbErr::RecordNotFound(_) => Self::not_found("Could not find resource"),
            sea_orm::DbErr::Custom(e) => Self::new(e, None, 503),
            sea_orm::DbErr::RecordNotInserted => Self::service_unavailable("Record not inserted"),
            sea_orm::DbErr::RbacError(_) => Self::forbidden("Access denied"),
            sea_orm::DbErr::AccessDenied {
                permission,
                resource,
            } => Self::forbidden(format!("Access denied: {} on {}", permission, resource)),
            others => {
                tracing::error!(target: "framework", "Database error: {:?}", others);
                Self::internal("An unknown error has occurred. Please try again later")
            },
        }
    }
}

impl TypedServiceResult for ErrorResult {
    fn message(&self) -> &str {
        &self.message
    }
    fn code(&self) -> u16 {
        self.code
    }
    fn response_type(&self) -> ResponseType {
        ResponseType::Json
    }
    fn serialize(&self) -> Result<String, serde_json::Error> {
        self.to_service_result().serialize_inherent()
    }
}

/// Renders any `TypedServiceResult` as an `http::Response<String>` with correlation
/// headers (`X-Request-ID`, `X-Correlation-ID`, `X-Correlation-Flow`) and a content type
/// derived from the result. Serialization failures fall back to the result message as body.
pub fn build_response(
    result: &dyn TypedServiceResult,
    ctx: Arc<crate::logging::CorrelationContext>,
) -> http::Response<String> {
    let body = result
        .serialize()
        .unwrap_or_else(|_| result.message().to_string());
    let mut builder = http::Response::builder()
        .status(result.code())
        .header("X-Request-ID", ctx.request_id())
        .header("X-Correlation-ID", ctx.correlation_id())
        .header("X-Correlation-Flow", ctx.flow().to_string());
    let content_type = match result.response_type() {
        ResponseType::Json => "application/json",
        ResponseType::Html => "text/html",
        ResponseType::Xml => "application/xml",
        ResponseType::Javascript => "application/javascript",
        ResponseType::File => "application/octet-stream",
        ResponseType::Text => "text/plain",
    };
    builder = builder.header("Content-Type", content_type);
    if result.response_type() == ResponseType::File {
        builder = builder.header(
            "Content-Disposition",
            format!(
                "attachment; filename=\"{}\"",
                body.rsplit('/').next().unwrap_or("file")
            ),
        );
    }
    builder.body(body).unwrap()
}

/// Renders a `ServiceResult<T>` as an `http::Response<String>`; see [`build_response`].
pub fn build_response_service<T: Serialize + Send + Sync>(
    result: &ServiceResult<T>,
    ctx: Arc<crate::logging::CorrelationContext>,
) -> http::Response<String> {
    build_response(result as &dyn TypedServiceResult, ctx)
}

/// Renders an `ErrorResult` as an `http::Response<String>` with an `"error"` envelope.
pub fn error_response(
    err: &ErrorResult,
    ctx: Arc<crate::logging::CorrelationContext>,
) -> http::Response<String> {
    let sr = err.to_service_result();
    build_response(&sr, ctx)
}