Skip to main content

arcly_http/web/
pagination.rs

1//! Offset pagination primitives: a query extractor for the request side and a
2//! uniform envelope for the response side.
3//!
4//! ```ignore
5//! #[Get("/")]
6//! async fn list(&self, #[Query] page: PageParams, svc: Inject<UserService>)
7//!     -> Json<Page<User>>
8//! {
9//!     let total = svc.count().await;
10//!     let rows  = svc.list(page.limit(), page.offset()).await;
11//!     Json(Page::new(rows, page, total))
12//! }
13//! ```
14//!
15//! `PageParams` deserializes straight from the query string (`?page=2&per_page=50`)
16//! and is clamped on read, so a handler can never receive a zero page, a
17//! negative offset, or an unbounded `per_page` that lets a client ask for the
18//! whole table.
19
20use schemars::JsonSchema;
21use serde::{Deserialize, Serialize};
22
23/// Largest page size a client may request; larger values are clamped down so
24/// `per_page` can never be weaponised into a full-table scan.
25pub const MAX_PER_PAGE: u32 = 100;
26/// Default page size when the client omits `per_page`.
27pub const DEFAULT_PER_PAGE: u32 = 20;
28
29/// Query parameters for an offset-paginated list endpoint.
30///
31/// Both fields are optional on the wire; missing or out-of-range values fall
32/// back to safe defaults via [`PageParams::page`] / [`PageParams::per_page`].
33#[derive(Debug, Clone, Copy, Default, Deserialize, JsonSchema)]
34pub struct PageParams {
35    #[serde(default)]
36    page: Option<u32>,
37    #[serde(default)]
38    per_page: Option<u32>,
39}
40
41impl PageParams {
42    /// 1-based page number, never below 1.
43    #[inline]
44    pub fn page(&self) -> u32 {
45        self.page.unwrap_or(1).max(1)
46    }
47
48    /// Page size, clamped to `1..=MAX_PER_PAGE`.
49    #[inline]
50    pub fn per_page(&self) -> u32 {
51        self.per_page
52            .unwrap_or(DEFAULT_PER_PAGE)
53            .clamp(1, MAX_PER_PAGE)
54    }
55
56    /// `LIMIT` value for a SQL query — identical to [`per_page`](Self::per_page).
57    #[inline]
58    pub fn limit(&self) -> u32 {
59        self.per_page()
60    }
61
62    /// `OFFSET` value for a SQL query: `(page - 1) * per_page`, saturating.
63    #[inline]
64    pub fn offset(&self) -> u64 {
65        (self.page() as u64 - 1).saturating_mul(self.per_page() as u64)
66    }
67}
68
69/// A page of results plus the metadata a client needs to walk the rest.
70#[derive(Debug, Clone, Serialize, JsonSchema)]
71pub struct Page<T: JsonSchema> {
72    /// The items on this page.
73    pub items: Vec<T>,
74    /// 1-based page number these items came from.
75    pub page: u32,
76    /// Page size used for this query.
77    pub per_page: u32,
78    /// Total number of items across all pages.
79    pub total: u64,
80    /// Total number of pages given `total` and `per_page` (at least 1).
81    pub total_pages: u64,
82    /// Whether a next page exists.
83    pub has_next: bool,
84    /// Whether a previous page exists.
85    pub has_prev: bool,
86}
87
88impl<T: JsonSchema> Page<T> {
89    /// Build a response envelope from a fetched slice, the request's
90    /// [`PageParams`], and the total row count.
91    pub fn new(items: Vec<T>, params: PageParams, total: u64) -> Self {
92        let page = params.page();
93        let per_page = params.per_page();
94        let total_pages = total.div_ceil(per_page as u64).max(1);
95        Self {
96            items,
97            page,
98            per_page,
99            total,
100            total_pages,
101            has_next: (page as u64) < total_pages,
102            has_prev: page > 1,
103        }
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    fn params(page: Option<u32>, per_page: Option<u32>) -> PageParams {
112        PageParams { page, per_page }
113    }
114
115    #[test]
116    fn clamps_and_defaults() {
117        let p = params(None, None);
118        assert_eq!(p.page(), 1);
119        assert_eq!(p.per_page(), DEFAULT_PER_PAGE);
120        assert_eq!(p.offset(), 0);
121
122        let p = params(Some(0), Some(0));
123        assert_eq!(p.page(), 1, "page floors at 1");
124        assert_eq!(p.per_page(), 1, "per_page floors at 1");
125
126        let p = params(Some(3), Some(10_000));
127        assert_eq!(p.per_page(), MAX_PER_PAGE, "per_page capped");
128        assert_eq!(p.offset(), 2 * MAX_PER_PAGE as u64);
129    }
130
131    #[test]
132    fn deserializes_from_query() {
133        let p: PageParams = serde_urlencoded::from_str("page=2&per_page=50").unwrap();
134        assert_eq!(p.page(), 2);
135        assert_eq!(p.per_page(), 50);
136        assert_eq!(p.offset(), 50);
137
138        let empty: PageParams = serde_urlencoded::from_str("").unwrap();
139        assert_eq!(empty.page(), 1);
140    }
141
142    #[test]
143    fn envelope_metadata() {
144        let page = Page::new(vec![1, 2, 3], params(Some(2), Some(3)), 7);
145        assert_eq!(page.total_pages, 3);
146        assert!(page.has_next);
147        assert!(page.has_prev);
148
149        let last = Page::new(vec![7], params(Some(3), Some(3)), 7);
150        assert!(!last.has_next);
151        assert!(last.has_prev);
152
153        let empty = Page::new(Vec::<i32>::new(), params(Some(1), Some(20)), 0);
154        assert_eq!(empty.total_pages, 1, "always at least one page");
155        assert!(!empty.has_next);
156        assert!(!empty.has_prev);
157    }
158}