Skip to main content

modo/db/
page.rs

1use axum::extract::FromRequestParts;
2use http::request::Parts;
3use serde::{Deserialize, Serialize};
4
5use crate::error::Error;
6
7/// Pagination defaults applied by [`PageRequest`] and [`CursorRequest`]
8/// extractors.
9///
10/// Add an instance to request extensions (via middleware or a layer) to
11/// override the defaults. If absent, the extractors fall back to
12/// `default_per_page = 20` and `max_per_page = 100`.
13#[derive(Debug, Clone)]
14pub struct PaginationConfig {
15    /// Default number of items per page when `per_page` is not specified.
16    pub default_per_page: i64,
17    /// Maximum allowed value for `per_page`. Values above this are clamped.
18    pub max_per_page: i64,
19}
20
21impl Default for PaginationConfig {
22    fn default() -> Self {
23        Self {
24            default_per_page: 20,
25            max_per_page: 100,
26        }
27    }
28}
29
30/// Offset-based page response.
31///
32/// Contains the items for the current page plus metadata for navigating
33/// through the full result set. Pages are **1-based**.
34///
35/// Constructed manually via [`Page::new`].
36#[derive(Debug, Serialize)]
37pub struct Page<T: Serialize> {
38    /// The items for this page.
39    pub items: Vec<T>,
40    /// Total number of items across all pages.
41    pub total: i64,
42    /// Current page number (1-based).
43    pub page: i64,
44    /// Number of items per page.
45    pub per_page: i64,
46    /// Total number of pages.
47    pub total_pages: i64,
48    /// Whether a next page exists.
49    pub has_next: bool,
50    /// Whether a previous page exists.
51    pub has_prev: bool,
52}
53
54impl<T: Serialize> Page<T> {
55    /// Build a `Page` from items, total count, current page, and page size.
56    pub fn new(items: Vec<T>, total: i64, page: i64, per_page: i64) -> Self {
57        let total_pages = if total == 0 || per_page == 0 {
58            0
59        } else {
60            (total + per_page - 1) / per_page
61        };
62        Self {
63            items,
64            total,
65            page,
66            per_page,
67            total_pages,
68            has_next: page < total_pages,
69            has_prev: page > 1,
70        }
71    }
72}
73
74/// Cursor-based page response.
75///
76/// Uses keyset (cursor) pagination rather than offset-based. The
77/// `next_cursor` value should be passed back as the `after` parameter
78/// for the next page.
79#[derive(Debug, Serialize)]
80pub struct CursorPage<T: Serialize> {
81    /// The items for this page.
82    pub items: Vec<T>,
83    /// Cursor value for fetching the next page, or `None` if this is the last page.
84    pub next_cursor: Option<String>,
85    /// Whether more items exist beyond this page.
86    pub has_more: bool,
87    /// Number of items per page.
88    pub per_page: i64,
89}
90
91impl<T: Serialize> CursorPage<T> {
92    /// Build a `CursorPage` from items, an optional next-cursor, and page
93    /// size.
94    pub fn new(items: Vec<T>, next_cursor: Option<String>, per_page: i64) -> Self {
95        Self {
96            has_more: next_cursor.is_some(),
97            items,
98            next_cursor,
99            per_page,
100        }
101    }
102}
103
104/// Offset pagination request extracted from the query string.
105///
106/// Parsed from `?page=N&per_page=N`. Implements [`FromRequestParts`] so it
107/// can be used directly as a handler argument. Values are silently clamped
108/// using [`PaginationConfig`] from request extensions (or hardcoded defaults
109/// if no config is present).
110///
111/// Pages are **1-based**. A `page` below `1` is treated as `1`.
112#[derive(Debug, Clone, Deserialize)]
113pub struct PageRequest {
114    /// Page number (1-based). Defaults to `1`.
115    #[serde(default = "one")]
116    pub page: i64,
117    /// Items per page. Clamped by [`PaginationConfig`].
118    #[serde(default)]
119    pub per_page: i64,
120}
121
122impl PageRequest {
123    /// Clamp values using config.
124    pub fn clamp(&mut self, config: &PaginationConfig) {
125        if self.page < 1 {
126            self.page = 1;
127        }
128        if self.per_page < 1 {
129            self.per_page = config.default_per_page;
130        }
131        if self.per_page > config.max_per_page {
132            self.per_page = config.max_per_page;
133        }
134    }
135
136    /// Returns the SQL `OFFSET` value for this page.
137    pub fn offset(&self) -> i64 {
138        (self.page - 1) * self.per_page
139    }
140}
141
142/// Cursor pagination request extracted from the query string.
143///
144/// Parsed from `?after=<cursor>&per_page=N`. Implements
145/// [`FromRequestParts`] so it can be used directly as a handler argument.
146#[derive(Debug, Clone, Deserialize)]
147pub struct CursorRequest {
148    /// Cursor value from a previous [`CursorPage::next_cursor`]. `None` starts from the beginning.
149    #[serde(default)]
150    pub after: Option<String>,
151    /// Items per page. Clamped by [`PaginationConfig`].
152    #[serde(default)]
153    pub per_page: i64,
154}
155
156impl CursorRequest {
157    /// Clamp values using config.
158    pub fn clamp(&mut self, config: &PaginationConfig) {
159        if self.per_page < 1 {
160            self.per_page = config.default_per_page;
161        }
162        if self.per_page > config.max_per_page {
163            self.per_page = config.max_per_page;
164        }
165    }
166}
167
168fn one() -> i64 {
169    1
170}
171
172fn resolve_config(parts: &Parts) -> PaginationConfig {
173    parts
174        .extensions
175        .get::<PaginationConfig>()
176        .cloned()
177        .unwrap_or_default()
178}
179
180impl<S: Send + Sync> FromRequestParts<S> for PageRequest {
181    type Rejection = Error;
182
183    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
184        let config = resolve_config(parts);
185        let axum::extract::Query(mut req) =
186            axum::extract::Query::<PageRequest>::from_request_parts(parts, state)
187                .await
188                .map_err(|e| Error::bad_request(format!("invalid pagination params: {e}")))?;
189        req.clamp(&config);
190        Ok(req)
191    }
192}
193
194impl<S: Send + Sync> FromRequestParts<S> for CursorRequest {
195    type Rejection = Error;
196
197    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
198        let config = resolve_config(parts);
199        let axum::extract::Query(mut req) =
200            axum::extract::Query::<CursorRequest>::from_request_parts(parts, state)
201                .await
202                .map_err(|e| Error::bad_request(format!("invalid pagination params: {e}")))?;
203        req.clamp(&config);
204        Ok(req)
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    fn config() -> PaginationConfig {
213        PaginationConfig {
214            default_per_page: 20,
215            max_per_page: 100,
216        }
217    }
218
219    #[test]
220    fn page_request_defaults() {
221        let mut req: PageRequest = serde_urlencoded::from_str("").unwrap();
222        req.clamp(&config());
223        assert_eq!(req.page, 1);
224        assert_eq!(req.per_page, 20);
225    }
226
227    #[test]
228    fn page_request_zero_page_becomes_one() {
229        let mut req = PageRequest {
230            page: 0,
231            per_page: 10,
232        };
233        req.clamp(&config());
234        assert_eq!(req.page, 1);
235    }
236
237    #[test]
238    fn page_request_per_page_zero_uses_default() {
239        let mut req = PageRequest {
240            page: 1,
241            per_page: 0,
242        };
243        req.clamp(&config());
244        assert_eq!(req.per_page, 20);
245    }
246
247    #[test]
248    fn page_request_per_page_over_max_clamped() {
249        let mut req = PageRequest {
250            page: 1,
251            per_page: 999,
252        };
253        req.clamp(&config());
254        assert_eq!(req.per_page, 100);
255    }
256
257    #[test]
258    fn page_request_valid_values_unchanged() {
259        let mut req = PageRequest {
260            page: 3,
261            per_page: 50,
262        };
263        req.clamp(&config());
264        assert_eq!(req.page, 3);
265        assert_eq!(req.per_page, 50);
266    }
267
268    #[test]
269    fn page_request_offset_calculation() {
270        let req = PageRequest {
271            page: 3,
272            per_page: 10,
273        };
274        assert_eq!(req.offset(), 20);
275    }
276
277    #[test]
278    fn page_request_offset_first_page() {
279        let req = PageRequest {
280            page: 1,
281            per_page: 10,
282        };
283        assert_eq!(req.offset(), 0);
284    }
285
286    #[test]
287    fn cursor_request_defaults() {
288        let mut req: CursorRequest = serde_urlencoded::from_str("").unwrap();
289        req.clamp(&config());
290        assert!(req.after.is_none());
291        assert_eq!(req.per_page, 20);
292    }
293
294    #[test]
295    fn cursor_request_per_page_over_max_clamped() {
296        let mut req = CursorRequest {
297            after: None,
298            per_page: 500,
299        };
300        req.clamp(&config());
301        assert_eq!(req.per_page, 100);
302    }
303
304    #[test]
305    fn cursor_request_per_page_zero_becomes_default() {
306        let mut req = CursorRequest {
307            after: Some("abc".into()),
308            per_page: 0,
309        };
310        req.clamp(&config());
311        assert_eq!(req.per_page, 20);
312        assert_eq!(req.after.as_deref(), Some("abc"));
313    }
314
315    #[test]
316    fn page_new_calculates_fields() {
317        let page: Page<String> = Page::new(vec!["a".into(), "b".into()], 5, 2, 2);
318        assert_eq!(page.total_pages, 3);
319        assert!(page.has_next);
320        assert!(page.has_prev);
321    }
322
323    #[test]
324    fn page_new_first_page() {
325        let page: Page<String> = Page::new(vec!["a".into(), "b".into()], 10, 1, 2);
326        assert_eq!(page.total_pages, 5);
327        assert!(page.has_next);
328        assert!(!page.has_prev);
329    }
330
331    #[test]
332    fn page_new_last_page() {
333        let page: Page<String> = Page::new(vec!["e".into()], 5, 3, 2);
334        assert_eq!(page.total_pages, 3);
335        assert!(!page.has_next);
336        assert!(page.has_prev);
337    }
338
339    #[test]
340    fn page_new_empty() {
341        let page: Page<String> = Page::new(vec![], 0, 1, 20);
342        assert_eq!(page.total_pages, 0);
343        assert!(!page.has_next);
344        assert!(!page.has_prev);
345    }
346
347    #[test]
348    fn cursor_page_with_more() {
349        let page: CursorPage<String> =
350            CursorPage::new(vec!["a".into(), "b".into()], Some("id_b".into()), 2);
351        assert!(page.has_more);
352        assert_eq!(page.next_cursor.as_deref(), Some("id_b"));
353        assert_eq!(page.per_page, 2);
354    }
355
356    #[test]
357    fn cursor_page_last() {
358        let page: CursorPage<String> = CursorPage::new(vec!["a".into()], None, 20);
359        assert!(!page.has_more);
360        assert!(page.next_cursor.is_none());
361    }
362
363    #[test]
364    fn page_serializes_to_json() {
365        let page: Page<i32> = Page::new(vec![1, 2, 3], 10, 1, 3);
366        let json = serde_json::to_value(&page).unwrap();
367        assert_eq!(json["items"], serde_json::json!([1, 2, 3]));
368        assert_eq!(json["total"], 10);
369        assert_eq!(json["page"], 1);
370        assert_eq!(json["per_page"], 3);
371        assert_eq!(json["total_pages"], 4);
372        assert_eq!(json["has_next"], true);
373        assert_eq!(json["has_prev"], false);
374    }
375
376    #[test]
377    fn page_request_deserializes_from_query_string() {
378        let req: PageRequest = serde_urlencoded::from_str("page=2&per_page=30").unwrap();
379        assert_eq!(req.page, 2);
380        assert_eq!(req.per_page, 30);
381    }
382
383    #[test]
384    fn cursor_request_deserializes_from_query_string() {
385        let req: CursorRequest = serde_urlencoded::from_str("after=01ABC&per_page=10").unwrap();
386        assert_eq!(req.after.as_deref(), Some("01ABC"));
387        assert_eq!(req.per_page, 10);
388    }
389
390    #[test]
391    fn cursor_request_deserializes_without_after() {
392        let req: CursorRequest = serde_urlencoded::from_str("per_page=10").unwrap();
393        assert!(req.after.is_none());
394    }
395}