hyperlite 0.1.0

Lightweight HTTP framework built on hyper, tokio, and tower
Documentation
//! Response builders for consistent JSON API envelopes.
//!
//! Hyperlite wraps outgoing JSON payloads in a lightweight envelope that keeps
//! responses predictable for clients and observability tooling. The helpers in
//! this module produce [`hyper::Response`] values that follow a common format
//! while still letting handlers choose the appropriate HTTP status codes.
//!
//! # Envelope Format
//! All JSON responses share the same structure:
//!
//! ```json
//! {
//!   "success": true,
//!   "data": { /* your payload */ },
//!   "message": "optional human-friendly message",
//!   "errors": [ { "code": "", "message": "" } ],
//!   "meta": {
//!     "timestamp": "2024-01-15T10:30:00Z",
//!     "correlationId": "optional-request-id"
//!   }
//! }
//! ```
//!
//! The envelope gives front-ends and API consumers a stable parsing contract:
//! success flags, typed error codes, and metadata are always present in the same
//! locations regardless of the route that produced them.
//!
//! # Design Highlights
//! - **Consistent parsing** – Clients can treat `success`, `errors`, and `meta`
//!   uniformly across endpoints.
//! - **Structured errors** – Use [`ApiError`] codes for localisation or
//!   programmatic branching.
//! - **Timestamped metadata** – Every response carries an RFC3339 UTC timestamp
//!   that aids debugging and log correlation.
//! - **Correlation IDs** – Optional request identifiers flow through the envelope
//!   *and* the `x-request-id` header when supplied.
//! - **Helper ergonomics** – Choose [`success`], [`failure`], [`not_found`], or
//!   [`empty`] based on the scenario; use [`with_correlation_id`] for advanced
//!   control.
//!
//! # Examples
//! ```rust,no_run
//! use hyper::{Response, StatusCode};
//! use hyperlite::{failure, not_found, success, ApiError, ResponseBody};
//! use serde::Serialize;
//!
//! #[derive(Serialize)]
//! struct Greeting { message: String }
//!
//! fn ok_example() -> Response<ResponseBody> {
//!     success(StatusCode::OK, Greeting { message: "hi".into() })
//! }
//!
//! fn not_found_example() -> Response<ResponseBody> {
//!     not_found("/api/users/123".to_owned())
//! }
//!
//! fn validation_example() -> Response<ResponseBody> {
//!     let errors = vec![ApiError::new("VALIDATION_ERROR", "Email is required")];
//!     failure(StatusCode::BAD_REQUEST, errors)
//! }
//! ```
//!
//! All helpers emit `application/json` responses unless the envelope fails to
//! serialise, in which case a plain-text fallback is returned.
use bytes::Bytes;
use chrono::{DateTime, Utc};
use http_body_util::Full;
use hyper::header::{self, HeaderName, HeaderValue};
use hyper::{Response, StatusCode};
use serde::Serialize;

/// Concrete response body type returned by Hyperlite helpers.
pub type ResponseBody = Full<Bytes>;

/// Structured API error used inside response envelopes.
#[derive(Debug, Clone, Serialize)]
pub struct ApiError {
    pub code: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub message: Option<String>,
}

impl ApiError {
    /// Creates a new error with both code and message.
    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
        Self {
            code: code.into(),
            message: Some(message.into()),
        }
    }

    /// Creates a new error with only a code.
    pub fn with_code(code: impl Into<String>) -> Self {
        Self {
            code: code.into(),
            message: None,
        }
    }

    /// Creates a new error with an optional message.
    pub fn with_optional_message(code: impl Into<String>, message: Option<String>) -> Self {
        Self {
            code: code.into(),
            message,
        }
    }

    /// Creates a new error with an optional message (alias for `with_optional_message`).
    pub fn with_message(code: impl Into<String>, message: Option<String>) -> Self {
        Self::with_optional_message(code, message)
    }
}

#[derive(Serialize)]
struct Envelope<T: Serialize> {
    success: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    data: Option<T>,
    #[serde(skip_serializing_if = "Option::is_none")]
    message: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    errors: Option<Vec<ApiError>>,
    meta: Meta,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Meta {
    timestamp: DateTime<Utc>,
    #[serde(skip_serializing_if = "Option::is_none")]
    correlation_id: Option<String>,
}

/// Creates a successful JSON response that wraps the provided payload in the
/// standard Hyperlite envelope.
///
/// The response sets `success: true`, embeds the serialised `data`, and fills
/// the `meta` section with the current UTC timestamp. Use this helper for any
/// 2xx response that returns a body.
///
/// # Arguments
/// - `status`: The HTTP status code (commonly `StatusCode::OK` or
///   `StatusCode::CREATED`).
/// - `data`: A serialisable payload that will be placed in the envelope's
///   `data` field.
///
/// # Examples
/// ```rust
/// use hyper::{Response, StatusCode};
/// use hyperlite::{success, ResponseBody};
/// use serde::Serialize;
///
/// #[derive(Serialize)]
/// struct User { id: u64, name: String }
///
/// fn user_response() -> Response<ResponseBody> {
///     let user = User { id: 1, name: "Alice".into() };
///     success(StatusCode::OK, user)
/// }
/// ```
pub fn success<T>(status: StatusCode, data: T) -> Response<ResponseBody>
where
    T: Serialize,
{
    build(status, true, Some(data), None, None, None)
}

/// Creates an error response populated with one or more structured [`ApiError`]s.
///
/// The response sets `success: false`, includes each error in the `errors`
/// collection, and retains the envelope metadata. Use this helper for
/// validation failures, authorisation issues, or any client-facing error where
/// you want to return multiple issues at once.
///
/// # Arguments
/// - `status`: The HTTP status code (for example `StatusCode::BAD_REQUEST`,
///   `StatusCode::UNAUTHORIZED`, or `StatusCode::UNPROCESSABLE_ENTITY`).
/// - `errors`: Vector of [`ApiError`] values describing what went wrong.
///
/// # Examples
/// ```rust
/// use hyper::{Response, StatusCode};
/// use hyperlite::{failure, ApiError, ResponseBody};
///
/// fn validation_failure() -> Response<ResponseBody> {
///     let errors = vec![
///         ApiError::new("VALIDATION_ERROR", "Email is required"),
///         ApiError::new("VALIDATION_ERROR", "Password too short"),
///     ];
///     failure(StatusCode::BAD_REQUEST, errors)
/// }
/// ```
pub fn failure(status: StatusCode, errors: Vec<ApiError>) -> Response<ResponseBody> {
    build::<()>(status, false, None, None, Some(errors), None)
}

/// Returns a `404 Not Found` response that records which path was requested.
///
/// The payload contains the missing `path` and the errors section includes a
/// standard `NOT_FOUND` code so clients can branch on the failure reason.
///
/// # Arguments
/// - `path`: The resource path (e.g. `"/api/users/42"`) that was not found.
///
/// # Examples
/// ```rust
/// use hyper::{Response, StatusCode};
/// use hyperlite::{not_found, ResponseBody};
///
/// fn missing_user(path: &str) -> Response<ResponseBody> {
///     let response = not_found(path.to_owned());
///     assert_eq!(response.status(), StatusCode::NOT_FOUND);
///     response
/// }
/// ```
pub fn not_found(path: String) -> Response<ResponseBody> {
    #[derive(Serialize)]
    struct NotFoundData {
        path: String,
    }

    let payload = NotFoundData { path };
    let errors = vec![ApiError::new(
        "NOT_FOUND",
        "The requested resource was not found",
    )];

    build(
        StatusCode::NOT_FOUND,
        false,
        Some(payload),
        Some("Resource not found".to_owned()),
        Some(errors),
        None,
    )
}

/// Produces a status-only response with an empty body.
///
/// Use this helper for operations that intentionally return no payload, such as
/// `DELETE` handlers or endpoints that trigger asynchronous work.
///
/// # Arguments
/// - `status`: The HTTP status code to return (commonly
///   `StatusCode::NO_CONTENT`).
///
/// # Examples
/// ```rust
/// use hyper::{Response, StatusCode};
/// use hyperlite::{empty, ResponseBody};
///
/// fn delete_user() -> Response<ResponseBody> {
///     // perform deletion...
///     empty(StatusCode::NO_CONTENT)
/// }
/// ```
pub fn empty(status: StatusCode) -> Response<ResponseBody> {
    let mut response = Response::new(Full::new(Bytes::new()));
    *response.status_mut() = status;
    response
}

/// Lower-level builder that lets callers customise the full envelope including
/// correlation identifiers.
///
/// This is useful when middleware generates request IDs that must flow through
/// both the response headers and the JSON metadata. Most handlers should prefer
/// [`success`] or [`failure`], but observability layers can call this helper
/// directly.
///
/// # Arguments
/// - `status`: HTTP status code to emit.
/// - `success`: Whether the request completed successfully.
/// - `data`: Optional JSON payload.
/// - `message`: Optional human-readable summary.
/// - `errors`: Optional structured errors.
/// - `correlation_id`: Optional request identifier that will be copied to the
///   `meta.correlationId` field and the `x-request-id` header.
///
/// # Examples
/// ```rust
/// use hyper::{Response, StatusCode};
/// use hyperlite::{with_correlation_id, ResponseBody};
/// use serde_json::json;
///
/// fn respond_with_id() -> Response<ResponseBody> {
///     with_correlation_id(
///         StatusCode::OK,
///         true,
///         Some(json!({"status": "ready"})),
///         None,
///         None,
///         Some("req-123".into()),
///     )
/// }
/// ```
pub fn with_correlation_id<T>(
    status: StatusCode,
    success: bool,
    data: Option<T>,
    message: Option<String>,
    errors: Option<Vec<ApiError>>,
    correlation_id: Option<String>,
) -> Response<ResponseBody>
where
    T: Serialize,
{
    build(status, success, data, message, errors, correlation_id)
}

fn build<T>(
    status: StatusCode,
    success: bool,
    data: Option<T>,
    message: Option<String>,
    errors: Option<Vec<ApiError>>,
    correlation_id: Option<String>,
) -> Response<ResponseBody>
where
    T: Serialize,
{
    let header_correlation_id = correlation_id.clone();

    let envelope = Envelope {
        success,
        data,
        message,
        errors,
        meta: Meta {
            timestamp: Utc::now(),
            correlation_id,
        },
    };

    let body = match serde_json::to_vec(&envelope) {
        Ok(bytes) => bytes,
        Err(err) => {
            #[cfg(feature = "tracing")]
            tracing::error!(error = ?err, "failed to serialize response envelope");
            #[cfg(not(feature = "tracing"))]
            let _ = &err;
            return fallback_text_response(StatusCode::INTERNAL_SERVER_ERROR);
        }
    };

    let mut builder = Response::builder().status(status);
    builder = builder.header(header::CONTENT_TYPE, "application/json");

    if let Some(id) = header_correlation_id.as_ref() {
        let header_value = match HeaderValue::from_str(id) {
            Ok(value) => value,
            Err(err) => {
                #[cfg(feature = "tracing")]
                tracing::error!(error = ?err, "invalid x-request-id header value");
                #[cfg(not(feature = "tracing"))]
                let _ = &err;
                return fallback_text_response(StatusCode::INTERNAL_SERVER_ERROR);
            }
        };

        builder = builder.header(HeaderName::from_static("x-request-id"), header_value);
    }

    match builder.body(Full::from(Bytes::from(body))) {
        Ok(response) => response,
        Err(err) => {
            #[cfg(feature = "tracing")]
            tracing::error!(error = ?err, "failed to build HTTP response");
            #[cfg(not(feature = "tracing"))]
            let _ = &err;
            fallback_text_response(StatusCode::INTERNAL_SERVER_ERROR)
        }
    }
}

fn fallback_text_response(status: StatusCode) -> Response<ResponseBody> {
    Response::builder()
        .status(status)
        .header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
        .body(Full::from(Bytes::from_static(b"internal server error")))
        .expect("static response")
}