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}