Skip to main content

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