Skip to main content

webx_core/
pagination.rs

1//! Pagination types for list endpoints.
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Deserialize)]
6pub struct PagedRequest {
7    #[serde(default = "default_page")]
8    pub page: u32,
9    #[serde(default = "default_page_size")]
10    pub page_size: u32,
11}
12
13fn default_page() -> u32 {
14    1
15}
16fn default_page_size() -> u32 {
17    20
18}
19
20impl Default for PagedRequest {
21    fn default() -> Self {
22        Self {
23            page: 1,
24            page_size: 20,
25        }
26    }
27}
28
29impl PagedRequest {
30    pub fn new(page: u32, page_size: u32) -> Self {
31        Self { page, page_size }
32    }
33
34    /// Build a request from the two optional query fields of a list endpoint.
35    ///
36    /// `None` falls back to the framework defaults (page `1`, page size `20`)
37    /// and the result is [`clamped`](Self::clamped), so the binding layer may
38    /// hand over `Option<u32>` without every handler repeating the defaults:
39    ///
40    /// ```
41    /// # use webx_core::pagination::PagedRequest;
42    /// let defaults = PagedRequest::from_parts(None, None);
43    /// assert_eq!((defaults.page, defaults.page_size), (1, 20));
44    ///
45    /// // Out-of-range values are clamped instead of rejected.
46    /// let zero = PagedRequest::from_parts(Some(0), Some(0));
47    /// assert_eq!((zero.page, zero.page_size), (1, 1));
48    /// let wide = PagedRequest::from_parts(Some(3), Some(1_000));
49    /// assert_eq!((wide.page, wide.page_size), (3, 100));
50    /// ```
51    ///
52    /// Equivalent to `PagedRequest::new(page.unwrap_or(1), page_size.unwrap_or(20)).clamped()`.
53    pub fn from_parts(page: Option<u32>, page_size: Option<u32>) -> Self {
54        Self::new(page.unwrap_or(1), page_size.unwrap_or(20)).clamped()
55    }
56
57    pub fn offset(&self) -> u32 {
58        (self.page.saturating_sub(1)).saturating_mul(self.page_size)
59    }
60
61    pub fn clamped(mut self) -> Self {
62        self.page = self.page.max(1);
63        self.page_size = self.page_size.clamp(1, 100);
64        self
65    }
66}
67
68#[derive(Debug, Clone, Serialize)]
69pub struct PagedResult<T: Serialize> {
70    pub items: Vec<T>,
71    pub total: u64,
72    pub page: u32,
73    pub page_size: u32,
74    pub total_pages: u32,
75}
76
77impl<T: Serialize> PagedResult<T> {
78    pub fn new(items: Vec<T>, total: u64, page: u32, page_size: u32) -> Self {
79        let total_pages = if page_size > 0 {
80            total.div_ceil(page_size as u64) as u32
81        } else {
82            0
83        };
84        Self {
85            items,
86            total,
87            page,
88            page_size,
89            total_pages,
90        }
91    }
92
93    pub fn from_request(items: Vec<T>, total: u64, req: &PagedRequest) -> Self {
94        Self::new(items, total, req.page, req.page_size)
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    fn parts(page: Option<u32>, page_size: Option<u32>) -> (u32, u32) {
103        let request = PagedRequest::from_parts(page, page_size);
104        (request.page, request.page_size)
105    }
106
107    #[test]
108    fn from_parts_defaults_when_both_are_absent() {
109        assert_eq!(parts(None, None), (1, 20));
110    }
111
112    #[test]
113    fn from_parts_fills_only_the_missing_half() {
114        assert_eq!(parts(Some(4), None), (4, 20));
115        assert_eq!(parts(None, Some(50)), (1, 50));
116    }
117
118    #[test]
119    fn from_parts_clamps_zero_and_over_the_limit() {
120        // Zero is not addressable: page 0 would underflow an offset and page
121        // size 0 would divide by zero in `total_pages`.
122        assert_eq!(parts(Some(0), Some(0)), (1, 1));
123        assert_eq!(parts(Some(0), Some(1_000)), (1, 100));
124        assert_eq!(parts(Some(7), Some(1_000)), (7, 100));
125        assert_eq!(parts(Some(u32::MAX), Some(u32::MAX)), (u32::MAX, 100));
126    }
127
128    #[test]
129    fn from_parts_keeps_in_range_values_untouched() {
130        assert_eq!(parts(Some(1), Some(1)), (1, 1));
131        assert_eq!(parts(Some(3), Some(100)), (3, 100));
132    }
133
134    #[test]
135    fn from_parts_is_new_plus_clamped() {
136        for (page, page_size) in [
137            (None, None),
138            (Some(0), None),
139            (None, Some(0)),
140            (Some(0), Some(101)),
141            (Some(2), Some(25)),
142            (Some(u32::MAX), Some(u32::MAX)),
143        ] {
144            let from_parts = PagedRequest::from_parts(page, page_size);
145            let expected = PagedRequest::new(page.unwrap_or(1), page_size.unwrap_or(20)).clamped();
146            assert_eq!(
147                (from_parts.page, from_parts.page_size),
148                (expected.page, expected.page_size),
149                "from_parts must equal new(..).clamped() for {page:?}/{page_size:?}"
150            );
151        }
152    }
153
154    #[test]
155    fn from_parts_clamps_before_offset_is_computed() {
156        // The clamped page size, not the raw one, feeds `offset`.
157        assert_eq!(PagedRequest::from_parts(Some(3), None).offset(), 40);
158        assert_eq!(
159            PagedRequest::from_parts(Some(3), Some(0)).offset(),
160            2,
161            "a zero page size is clamped to 1, so it cannot zero the offset"
162        );
163    }
164
165    #[test]
166    fn negative_query_values_are_rejected_by_deserialization() {
167        // `u32` is the contract: a negative page never reaches `from_parts`,
168        // the binding layer answers 400 before the handler runs.
169        assert!(serde_json::from_str::<PagedRequest>(r#"{ "page": -1 }"#).is_err());
170        assert!(serde_json::from_str::<PagedRequest>(r#"{ "page_size": -20 }"#).is_err());
171    }
172}