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