arcly-http 0.4.0

Enterprise-grade NestJS-inspired web framework on axum: zero-lock DI, declarative controllers, multi-tenant data routing, transactional outbox, ABAC, and a self-documenting OpenAPI surface
Documentation
//! Offset pagination primitives: a query extractor for the request side and a
//! uniform envelope for the response side.
//!
//! ```ignore
//! #[Get("/")]
//! async fn list(&self, #[Query] page: PageParams, svc: Inject<UserService>)
//!     -> Json<Page<User>>
//! {
//!     let total = svc.count().await;
//!     let rows  = svc.list(page.limit(), page.offset()).await;
//!     Json(Page::new(rows, page, total))
//! }
//! ```
//!
//! `PageParams` deserializes straight from the query string (`?page=2&per_page=50`)
//! and is clamped on read, so a handler can never receive a zero page, a
//! negative offset, or an unbounded `per_page` that lets a client ask for the
//! whole table.

use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

/// Largest page size a client may request; larger values are clamped down so
/// `per_page` can never be weaponised into a full-table scan.
pub const MAX_PER_PAGE: u32 = 100;
/// Default page size when the client omits `per_page`.
pub const DEFAULT_PER_PAGE: u32 = 20;

/// Query parameters for an offset-paginated list endpoint.
///
/// Both fields are optional on the wire; missing or out-of-range values fall
/// back to safe defaults via [`PageParams::page`] / [`PageParams::per_page`].
#[derive(Debug, Clone, Copy, Default, Deserialize, JsonSchema)]
pub struct PageParams {
    #[serde(default)]
    page: Option<u32>,
    #[serde(default)]
    per_page: Option<u32>,
}

impl PageParams {
    /// 1-based page number, never below 1.
    #[inline]
    pub fn page(&self) -> u32 {
        self.page.unwrap_or(1).max(1)
    }

    /// Page size, clamped to `1..=MAX_PER_PAGE`.
    #[inline]
    pub fn per_page(&self) -> u32 {
        self.per_page
            .unwrap_or(DEFAULT_PER_PAGE)
            .clamp(1, MAX_PER_PAGE)
    }

    /// `LIMIT` value for a SQL query — identical to [`per_page`](Self::per_page).
    #[inline]
    pub fn limit(&self) -> u32 {
        self.per_page()
    }

    /// `OFFSET` value for a SQL query: `(page - 1) * per_page`, saturating.
    #[inline]
    pub fn offset(&self) -> u64 {
        (self.page() as u64 - 1).saturating_mul(self.per_page() as u64)
    }
}

/// A page of results plus the metadata a client needs to walk the rest.
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct Page<T: JsonSchema> {
    /// The items on this page.
    pub items: Vec<T>,
    /// 1-based page number these items came from.
    pub page: u32,
    /// Page size used for this query.
    pub per_page: u32,
    /// Total number of items across all pages.
    pub total: u64,
    /// Total number of pages given `total` and `per_page` (at least 1).
    pub total_pages: u64,
    /// Whether a next page exists.
    pub has_next: bool,
    /// Whether a previous page exists.
    pub has_prev: bool,
}

impl<T: JsonSchema> Page<T> {
    /// Build a response envelope from a fetched slice, the request's
    /// [`PageParams`], and the total row count.
    pub fn new(items: Vec<T>, params: PageParams, total: u64) -> Self {
        let page = params.page();
        let per_page = params.per_page();
        let total_pages = total.div_ceil(per_page as u64).max(1);
        Self {
            items,
            page,
            per_page,
            total,
            total_pages,
            has_next: (page as u64) < total_pages,
            has_prev: page > 1,
        }
    }
}

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

    fn params(page: Option<u32>, per_page: Option<u32>) -> PageParams {
        PageParams { page, per_page }
    }

    #[test]
    fn clamps_and_defaults() {
        let p = params(None, None);
        assert_eq!(p.page(), 1);
        assert_eq!(p.per_page(), DEFAULT_PER_PAGE);
        assert_eq!(p.offset(), 0);

        let p = params(Some(0), Some(0));
        assert_eq!(p.page(), 1, "page floors at 1");
        assert_eq!(p.per_page(), 1, "per_page floors at 1");

        let p = params(Some(3), Some(10_000));
        assert_eq!(p.per_page(), MAX_PER_PAGE, "per_page capped");
        assert_eq!(p.offset(), 2 * MAX_PER_PAGE as u64);
    }

    #[test]
    fn deserializes_from_query() {
        let p: PageParams = serde_urlencoded::from_str("page=2&per_page=50").unwrap();
        assert_eq!(p.page(), 2);
        assert_eq!(p.per_page(), 50);
        assert_eq!(p.offset(), 50);

        let empty: PageParams = serde_urlencoded::from_str("").unwrap();
        assert_eq!(empty.page(), 1);
    }

    #[test]
    fn envelope_metadata() {
        let page = Page::new(vec![1, 2, 3], params(Some(2), Some(3)), 7);
        assert_eq!(page.total_pages, 3);
        assert!(page.has_next);
        assert!(page.has_prev);

        let last = Page::new(vec![7], params(Some(3), Some(3)), 7);
        assert!(!last.has_next);
        assert!(last.has_prev);

        let empty = Page::new(Vec::<i32>::new(), params(Some(1), Some(20)), 0);
        assert_eq!(empty.total_pages, 1, "always at least one page");
        assert!(!empty.has_next);
        assert!(!empty.has_prev);
    }
}