Skip to main content

rust_web_server/pagination/
mod.rs

1//! Pagination result types (`Page<T>`, `CursorPage<T>`) and an RFC 8288
2//! `Link` header builder.
3//!
4//! Not tied to the model layer or any feature flag — build one by hand if
5//! your data source isn't [`crate::model::QueryBuilder`] (an external API, an
6//! in-memory `Vec`, ...). `QueryBuilder::paginate()` / `::paginate_after()`
7//! (require a `model-*` feature) are the batteries-included way to get one
8//! directly from a database query.
9//!
10//! # Offset pagination
11//!
12//! ```rust
13//! use rust_web_server::pagination::Page;
14//!
15//! let page = Page::new(vec!["a", "b", "c"], 1, 10, 25);
16//! assert_eq!(3, page.total_pages);
17//! assert!(page.has_next());
18//! assert!(!page.has_prev());
19//!
20//! let link = page.link_header("https://api.example.com/items").unwrap();
21//! assert!(link.contains(r#"<https://api.example.com/items?page=2&per_page=10>; rel="next""#));
22//! assert!(link.contains(r#"rel="last""#));
23//! ```
24//!
25//! # Cursor (keyset) pagination
26//!
27//! ```rust
28//! use rust_web_server::pagination::CursorPage;
29//!
30//! let page = CursorPage { items: vec!["a", "b"], next_cursor: Some("42".to_string()) };
31//! assert!(page.has_next());
32//! let link = page.link_header("https://api.example.com/items", "cursor").unwrap();
33//! assert_eq!(r#"<https://api.example.com/items?cursor=42>; rel="next""#, link);
34//! ```
35
36#[cfg(test)]
37mod tests;
38
39/// A single page of offset-paginated results (`LIMIT`/`OFFSET`-style).
40#[derive(Debug, Clone, PartialEq)]
41pub struct Page<T> {
42    pub items: Vec<T>,
43    /// 1-based page number.
44    pub page: u64,
45    pub per_page: u64,
46    pub total_items: u64,
47    /// `0` when `total_items` is `0`; otherwise `ceil(total_items / per_page)`.
48    pub total_pages: u64,
49}
50
51impl<T> Page<T> {
52    /// Builds a page, computing `total_pages` from `total_items` and `per_page`.
53    /// `page` and `per_page` are clamped to a minimum of `1`.
54    pub fn new(items: Vec<T>, page: u64, per_page: u64, total_items: u64) -> Self {
55        let page = page.max(1);
56        let per_page = per_page.max(1);
57        let total_pages = if total_items == 0 { 0 } else { (total_items + per_page - 1) / per_page };
58        Page { items, page, per_page, total_items, total_pages }
59    }
60
61    /// `true` if `page` is before `total_pages`.
62    pub fn has_next(&self) -> bool {
63        self.page < self.total_pages
64    }
65
66    /// `true` if `page` is after `1`.
67    pub fn has_prev(&self) -> bool {
68        self.page > 1 && self.total_pages > 0
69    }
70
71    pub fn next_page(&self) -> Option<u64> {
72        self.has_next().then_some(self.page + 1)
73    }
74
75    pub fn prev_page(&self) -> Option<u64> {
76        self.has_prev().then_some(self.page - 1)
77    }
78
79    /// Maps `items` through `f`, leaving all pagination metadata unchanged —
80    /// e.g. to turn a `Page<UserRow>` into a `Page<UserDto>` before serializing.
81    pub fn map<U>(self, mut f: impl FnMut(T) -> U) -> Page<U> {
82        Page {
83            items: self.items.into_iter().map(|item| f(item)).collect(),
84            page: self.page,
85            per_page: self.per_page,
86            total_items: self.total_items,
87            total_pages: self.total_pages,
88        }
89    }
90
91    /// Builds an RFC 8288 `Link` header value with `rel="first"`, `"prev"`,
92    /// `"next"`, and `"last"` entries as applicable (a first page omits
93    /// `first`/`prev`; a last page omits `next`/`last`). `page`/`per_page`
94    /// query parameters are added to (or overwritten on) `base_url`, and any
95    /// other existing query parameters are preserved.
96    ///
97    /// Returns `None` if `base_url` fails to parse, or if there is nothing to
98    /// link to (a single page with no prev/next).
99    pub fn link_header(&self, base_url: &str) -> Option<String> {
100        let mut links = Vec::new();
101
102        if self.has_prev() {
103            if let Some(url) = with_query_params(base_url, &[("page", "1"), ("per_page", &self.per_page.to_string())]) {
104                links.push(format!(r#"<{}>; rel="first""#, url));
105            }
106            if let Some(url) = with_query_params(base_url, &[("page", &(self.page - 1).to_string()), ("per_page", &self.per_page.to_string())]) {
107                links.push(format!(r#"<{}>; rel="prev""#, url));
108            }
109        }
110        if self.has_next() {
111            if let Some(url) = with_query_params(base_url, &[("page", &(self.page + 1).to_string()), ("per_page", &self.per_page.to_string())]) {
112                links.push(format!(r#"<{}>; rel="next""#, url));
113            }
114            if let Some(url) = with_query_params(base_url, &[("page", &self.total_pages.to_string()), ("per_page", &self.per_page.to_string())]) {
115                links.push(format!(r#"<{}>; rel="last""#, url));
116            }
117        }
118
119        if links.is_empty() { None } else { Some(links.join(", ")) }
120    }
121}
122
123/// A single page of cursor (keyset) paginated results.
124///
125/// Unlike [`Page`], there is no `total_items`/`total_pages` — computing those
126/// would require a separate `COUNT(*)` query, which is exactly what keyset
127/// pagination avoids. All you get (and all you need to fetch the next page)
128/// is `next_cursor`.
129#[derive(Debug, Clone, PartialEq)]
130pub struct CursorPage<T> {
131    pub items: Vec<T>,
132    /// Opaque cursor to pass back for the next page. `None` means this is the last page.
133    pub next_cursor: Option<String>,
134}
135
136impl<T> CursorPage<T> {
137    pub fn has_next(&self) -> bool {
138        self.next_cursor.is_some()
139    }
140
141    /// Maps `items` through `f`, leaving `next_cursor` unchanged.
142    pub fn map<U>(self, mut f: impl FnMut(T) -> U) -> CursorPage<U> {
143        CursorPage {
144            items: self.items.into_iter().map(|item| f(item)).collect(),
145            next_cursor: self.next_cursor,
146        }
147    }
148
149    /// Builds a `Link` header with a single `rel="next"` entry, adding (or
150    /// overwriting) `cursor_param` as a query parameter on `base_url`.
151    /// Returns `None` if there is no next page, or if `base_url` fails to parse.
152    pub fn link_header(&self, base_url: &str, cursor_param: &str) -> Option<String> {
153        let cursor = self.next_cursor.as_ref()?;
154        let url = with_query_params(base_url, &[(cursor_param, cursor.as_str())])?;
155        Some(format!(r#"<{}>; rel="next""#, url))
156    }
157}
158
159fn with_query_params(base_url: &str, params: &[(&str, &str)]) -> Option<String> {
160    let mut components = crate::url::URL::parse(base_url).ok()?;
161    let mut query = components.query.take().unwrap_or_default();
162    for (key, value) in params {
163        query.insert(key.to_string(), value.to_string());
164    }
165    components.query = Some(query);
166    crate::url::URL::build(components).ok()
167}