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}