hyperlite 0.1.0

Lightweight HTTP framework built on hyper, tokio, and tower
Documentation
//! Request extraction helpers for Hyperlite services.
//!
//! This module provides reusable helpers for parsing incoming [`Request`] values
//! in a framework-agnostic way. Each extractor returns `Result<T, BoxError>` so
//! handlers can map errors into meaningful HTTP responses while keeping their
//! business logic focused on Happy Path behaviour.
//!
//! # Available extractors
//! - [`parse_json_body`] for JSON payloads with automatic `Content-Type`
//!   validation and size limits.
//! - [`query_params`] for deserialising URL query strings into strongly typed
//!   structs.
//! - [`path_params`] and [`path_param`] for working with dynamic path segments
//!   captured by the router.
//! - [`get_extension`] for retrieving request-scoped data inserted by
//!   middleware or the router (such as application state, authentication
//!   context, or tracing metadata).
//!
//! # Usage
//! ```rust,ignore
//! use bytes::Bytes;
//! use hyper::{Request, Response, StatusCode};
//! use hyperlite::{parse_json_body, path_param, path_params, query_params, BoxBody, BoxError};
//! use http_body_util::Full;
//! use serde::Deserialize;
//! use std::collections::HashMap;
//! use std::sync::Arc;
//! use uuid::Uuid;
//!
//! #[derive(Clone)]
//! struct AppState;
//!
//! #[derive(Deserialize)]
//! struct CreateUser {
//!     email: String,
//!     username: String,
//! }
//!
//! #[derive(Deserialize)]
//! struct SearchParams {
//!     q: String,
//!     #[serde(default)]
//!     limit: Option<u32>,
//! }
//!
//! async fn create_user(
//!     req: Request<BoxBody>,
//!     _state: Arc<AppState>,
//! ) -> Result<Response<Full<Bytes>>, BoxError> {
//!     let payload = parse_json_body::<CreateUser>(req).await?;
//!     Ok(hyperlite::success(StatusCode::CREATED, payload.username))
//! }
//!
//! async fn search(
//!     req: Request<BoxBody>,
//!     _state: Arc<AppState>,
//! ) -> Result<Response<Full<Bytes>>, BoxError> {
//!     let params = query_params::<SearchParams>(&req)?;
//!     Ok(hyperlite::success(StatusCode::OK, params.q))
//! }
//!
//! async fn show(
//!     req: Request<BoxBody>,
//!     _state: Arc<AppState>,
//! ) -> Result<Response<Full<Bytes>>, BoxError> {
//!     let params: HashMap<String, String> = path_params(&req)?;
//!     let id: Uuid = path_param(&req, "id")?;
//!     Ok(hyperlite::success(StatusCode::OK, format!("{}:{}", params.len(), id)))
//! }
//! ```

use std::any::type_name;
use std::collections::HashMap;
use std::fmt;
use std::io;
use std::str::FromStr;

use bytes::Bytes;
use http::header;
use http_body_util::{BodyExt, Limited};
use hyper::Request;
use serde::Deserialize;

use crate::{router::PathParams, BoxBody, BoxError};

/// Maximum number of bytes read when parsing a JSON body (1 MiB).
///
/// Limiting body size protects services from malicious or accidental large
/// payloads that could exhaust memory or slow down request processing.
const MAX_JSON_BODY_BYTES: usize = 1_048_576;

/// Parse a JSON body into the requested type `T` with header validation and a
/// strict size limit.
///
/// The request must advertise an `application/json` (or `+json`) content type.
/// Bodies larger than 1 MiB are rejected to guard against oversized payloads.
///
/// # Errors
/// - Returns an error if the content type is missing or not JSON.
/// - Returns an error when the body exceeds the configured size limit.
/// - Returns an error if reading the body fails or the payload is invalid JSON.
///
/// # Examples
/// ```rust,no_run
/// use bytes::Bytes;
/// use hyper::{Request, Response, StatusCode};
/// use hyperlite::{parse_json_body, BoxBody, BoxError};
/// use http_body_util::Full;
/// use serde::Deserialize;
///
/// #[derive(Deserialize)]
/// struct CreateGreeting { name: String }
///
/// async fn handler(req: Request<BoxBody>) -> Result<Response<Full<Bytes>>, BoxError> {
///     let payload = parse_json_body::<CreateGreeting>(req).await?;
///     Ok(hyperlite::success(StatusCode::CREATED, payload.name))
/// }
/// ```
pub async fn parse_json_body<T>(req: Request<BoxBody>) -> Result<T, BoxError>
where
    T: for<'de> Deserialize<'de>,
{
    let (parts, body) = req.into_parts();

    let content_type = parts
        .headers
        .get(header::CONTENT_TYPE)
        .and_then(|value| value.to_str().ok())
        .map(|value| {
            value
                .split(';')
                .next()
                .unwrap_or("")
                .trim()
                .to_ascii_lowercase()
        });

    let is_json = content_type
        .as_deref()
        .map(|mime| {
            mime == "application/json"
                || (mime.starts_with("application/") && mime.ends_with("+json"))
        })
        .unwrap_or(false);

    if !is_json {
        return Err(boxed_io_error(
            io::ErrorKind::InvalidInput,
            "Content-Type must be application/json",
        ));
    }

    if let Some(content_length) = parts.headers.get(header::CONTENT_LENGTH) {
        let length_str = content_length.to_str().map_err(|err| {
            boxed_io_error(
                io::ErrorKind::InvalidInput,
                format!("Invalid Content-Length header: {err}"),
            )
        })?;

        let declared_length = length_str.parse::<usize>().map_err(|err| {
            boxed_io_error(
                io::ErrorKind::InvalidInput,
                format!("Invalid Content-Length header value: {err}"),
            )
        })?;

        if declared_length > MAX_JSON_BODY_BYTES {
            return Err(boxed_io_error(
                io::ErrorKind::InvalidData,
                "Request body exceeds 1MB limit",
            ));
        }
    }

    let limited = Limited::new(body, MAX_JSON_BODY_BYTES);

    let collected = limited.collect().await.map_err(|err| {
        boxed_io_error(
            io::ErrorKind::InvalidData,
            format!("Failed to read request body: {err}"),
        )
    })?;
    let bytes: Bytes = collected.to_bytes();

    serde_json::from_slice(&bytes).map_err(|err| {
        boxed_io_error(
            io::ErrorKind::InvalidData,
            format!("Invalid JSON payload: {err}"),
        )
    })
}

/// Parse the query string portion of a request URI into the desired type `T`.
///
/// # Errors
/// - Returns an error if the request has no query string.
/// - Returns an error when deserialisation fails (missing or invalid fields).
///
/// # Examples
/// ```rust
/// use bytes::Bytes;
/// use http_body_util::{BodyExt, Empty};
/// use hyper::{Method, Request, Uri};
/// use hyperlite::{query_params, BoxBody, BoxError};
/// use serde::Deserialize;
///
/// #[derive(Deserialize)]
/// struct SearchParams { q: String, #[serde(default)] limit: Option<u32> }
///
/// fn handler(req: Request<BoxBody>) -> Result<String, BoxError> {
///     let params = query_params::<SearchParams>(&req)?;
///     Ok(params.q)
/// }
///
/// let req = Request::builder()
///     .method(Method::GET)
///     .uri(Uri::from_static("/search?q=recipe"))
///     .body(Empty::<Bytes>::new().map_err(|err| match err {}).boxed())
///     .unwrap();
/// assert_eq!(handler(req).unwrap(), "recipe");
/// ```
pub fn query_params<T>(req: &Request<BoxBody>) -> Result<T, BoxError>
where
    T: for<'de> Deserialize<'de>,
{
    let query = req
        .uri()
        .query()
        .ok_or_else(|| boxed_io_error(io::ErrorKind::InvalidInput, "No query parameters found"))?;

    serde_urlencoded::from_str(query).map_err(|err| {
        boxed_io_error(
            io::ErrorKind::InvalidInput,
            format!("Invalid query parameters: {err}"),
        )
    })
}

/// Retrieve all path parameters captured for the current request.
///
/// When a route does not include dynamic segments, this returns an empty map.
///
/// # Examples
/// ```rust
/// use bytes::Bytes;
/// use http_body_util::{BodyExt, Empty};
/// use hyper::{Request, Uri};
/// use hyperlite::{path_params, BoxBody, PathParams};
/// use std::collections::HashMap;
///
/// let mut req = Request::builder()
///     .uri(Uri::from_static("/users/123"))
///     .body(Empty::<Bytes>::new().map_err(|err| match err {}).boxed())
///     .unwrap();
/// let mut params = HashMap::new();
/// params.insert("id".to_string(), "123".to_string());
/// req.extensions_mut().insert(PathParams(params.clone()));
///
/// assert_eq!(path_params(&req).unwrap(), params);
/// ```
pub fn path_params(req: &Request<BoxBody>) -> Result<HashMap<String, String>, BoxError> {
    Ok(req
        .extensions()
        .get::<PathParams>()
        .map(|params| params.0.clone())
        .unwrap_or_default())
}

/// Retrieve a single path parameter and parse it into the requested type `T`.
///
/// # Errors
/// - Returns an error when the parameter is missing from the route match.
/// - Returns an error if parsing the parameter into `T` fails.
///
/// # Examples
/// ```rust,ignore
/// use bytes::Bytes;
/// use hyper::{Request, Uri};
/// use hyperlite::{path_param, BoxBody, BoxError, PathParams};
/// use std::collections::HashMap;
/// use http_body_util::{BodyExt, Empty};
/// use uuid::Uuid;
///
/// fn handler(req: Request<BoxBody>) -> Result<Uuid, BoxError> {
///     path_param(&req, "id")
/// }
///
/// let mut req = Request::builder()
///     .uri(Uri::from_static("/users/6f9619ff-8b86-d011-b42d-00cf4fc964ff"))
///     .body(Empty::<Bytes>::new().map_err(|err| match err {}).boxed())
///     .unwrap();
/// let mut params = HashMap::new();
/// params.insert(
///     "id".to_string(),
///     "6f9619ff-8b86-d011-b42d-00cf4fc964ff".to_string(),
/// );
/// req.extensions_mut().insert(PathParams(params));
///
/// assert!(handler(req).is_ok());
/// ```
pub fn path_param<T>(req: &Request<BoxBody>, key: &str) -> Result<T, BoxError>
where
    T: FromStr,
    T::Err: fmt::Display,
{
    let params = path_params(req)?;
    let value = params.get(key).ok_or_else(|| {
        boxed_io_error(
            io::ErrorKind::InvalidInput,
            format!("Path parameter '{key}' not found"),
        )
    })?;

    value.parse::<T>().map_err(|err| {
        boxed_io_error(
            io::ErrorKind::InvalidInput,
            format!("Invalid path parameter '{key}': {err}"),
        )
    })
}

/// Extract a typed extension inserted by middleware or the router.
///
/// # Errors
/// - Returns an error if the requested extension type is not present.
///
/// # Examples
/// ```rust,ignore
/// use bytes::Bytes;
/// use hyper::{Request, Uri};
/// use hyperlite::{get_extension, BoxBody, BoxError};
/// use http_body_util::{BodyExt, Empty};
/// use uuid::Uuid;
///
/// fn handler(req: Request<BoxBody>) -> Result<Uuid, BoxError> {
///     get_extension::<Uuid>(&req)
/// }
///
/// let mut req = Request::builder()
///     .uri(Uri::from_static("/"))
///     .body(Empty::<Bytes>::new().map_err(|err| match err {}).boxed())
///     .unwrap();
/// let user_id = Uuid::nil();
/// req.extensions_mut().insert(user_id);
/// assert_eq!(handler(req).unwrap(), Uuid::nil());
/// ```
pub fn get_extension<T>(req: &Request<BoxBody>) -> Result<T, BoxError>
where
    T: Clone + Send + Sync + 'static,
{
    req.extensions().get::<T>().cloned().ok_or_else(|| {
        boxed_io_error(
            io::ErrorKind::NotFound,
            format!("Extension of type {} not found", type_name::<T>()),
        )
    })
}

fn boxed_io_error(kind: io::ErrorKind, message: impl Into<String>) -> BoxError {
    Box::new(io::Error::new(kind, message.into()))
}