Skip to main content

baukit_http/
pagination.rs

1//! Keyset pagination with opaque cursors bound to the request filters.
2//!
3//! A [`Cursor`] carries a keyset position (a sort value plus a tie-breaking
4//! [`Uuid`]) and a short hash of the normalized filters the page was produced
5//! from. [`Cursor::decode`] recomputes that hash and rejects any cursor whose
6//! bytes were edited, whose version is unknown, or that is replayed against
7//! different filters. The cursor is not a secret and is not authenticated: it
8//! prevents accidental misuse and inconsistent result sets, not a determined
9//! forger who can also send an equivalent plain query.
10
11use std::str::FromStr;
12
13use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
14use ring::digest;
15use serde::{Deserialize, Serialize};
16use uuid::Uuid;
17
18/// The page size used when a request omits `limit`.
19pub const DEFAULT_PAGE_LIMIT: i64 = 50;
20
21/// The largest page size a request may ask for.
22pub const MAX_PAGE_LIMIT: i64 = 200;
23
24const CURSOR_VERSION: u8 = 1;
25const FILTER_HASH_BYTES: usize = 8;
26
27/// Validated `limit` and `cursor` query parameters.
28///
29/// Build this from the raw query with [`PageParams::new`] so the limit bounds
30/// are enforced at the HTTP boundary rather than in the repository.
31#[derive(Clone, Debug, Eq, PartialEq)]
32pub struct PageParams {
33    /// The requested page size, between 1 and [`MAX_PAGE_LIMIT`].
34    pub limit: i64,
35    /// The opaque cursor sent by the client, still encoded.
36    pub cursor: Option<String>,
37}
38
39impl Default for PageParams {
40    fn default() -> Self {
41        Self {
42            limit: DEFAULT_PAGE_LIMIT,
43            cursor: None,
44        }
45    }
46}
47
48impl PageParams {
49    /// Validates raw query parameters.
50    ///
51    /// A missing limit falls back to [`DEFAULT_PAGE_LIMIT`].
52    ///
53    /// # Errors
54    ///
55    /// Returns [`PaginationError::InvalidLimit`] when the limit is outside
56    /// `1..=`[`MAX_PAGE_LIMIT`].
57    pub fn new(limit: Option<i64>, cursor: Option<String>) -> Result<Self, PaginationError> {
58        let limit = limit.unwrap_or(DEFAULT_PAGE_LIMIT);
59        if !(1..=MAX_PAGE_LIMIT).contains(&limit) {
60            return Err(PaginationError::InvalidLimit);
61        }
62        Ok(Self { limit, cursor })
63    }
64
65    /// Returns the limit as a `usize` for slicing and truncation.
66    ///
67    /// # Errors
68    ///
69    /// Returns [`PaginationError::InvalidLimit`] on platforms where the limit
70    /// does not fit a `usize`.
71    pub fn limit_usize(&self) -> Result<usize, PaginationError> {
72        usize::try_from(self.limit).map_err(|_| PaginationError::InvalidLimit)
73    }
74
75    /// Returns the limit plus one, the row count to fetch to detect a next page.
76    ///
77    /// # Errors
78    ///
79    /// Returns [`PaginationError::InvalidLimit`] if the increment overflows.
80    pub fn fetch_limit(&self) -> Result<i64, PaginationError> {
81        self.limit
82            .checked_add(1)
83            .ok_or(PaginationError::InvalidLimit)
84    }
85
86    /// Decodes the cursor against the normalized filters of this request.
87    ///
88    /// Returns `Ok(None)` for a first page.
89    ///
90    /// # Errors
91    ///
92    /// Returns [`PaginationError::InvalidCursor`] when the cursor is malformed,
93    /// carries an unsupported version, or was issued for other filters.
94    pub fn decode_cursor<F>(
95        &self,
96        normalized_filters: &F,
97    ) -> Result<Option<Cursor>, PaginationError>
98    where
99        F: Serialize + ?Sized,
100    {
101        self.cursor
102            .as_deref()
103            .map(|encoded| Cursor::decode(encoded, normalized_filters))
104            .transpose()
105    }
106}
107
108/// One keyset position: the sort value plus the row ID that breaks ties.
109///
110/// `T` is the type of the ordered column, for example a timestamp or a name.
111#[derive(Clone, Debug, Eq, PartialEq)]
112pub struct PageKey<T> {
113    /// The value of the ordered column for the last row of the page.
114    pub value: T,
115    /// The ID of that row, used as a stable tie-breaker.
116    pub id: Uuid,
117}
118
119impl<T> PageKey<T> {
120    /// Creates a keyset position.
121    pub const fn new(value: T, id: Uuid) -> Self {
122        Self { value, id }
123    }
124}
125
126/// One page of items plus the cursor that fetches the following page.
127///
128/// A `next_cursor` of `None` means the caller has reached the end.
129#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
130pub struct Page<T> {
131    /// The rows of this page, already truncated to the requested limit.
132    pub items: Vec<T>,
133    /// The encoded cursor for the next page, or `None` at the end.
134    pub next_cursor: Option<String>,
135}
136
137impl<T> Page<T> {
138    /// Creates a page from already truncated items.
139    pub const fn new(items: Vec<T>, next_cursor: Option<String>) -> Self {
140        Self { items, next_cursor }
141    }
142
143    /// Creates an empty final page.
144    pub const fn empty() -> Self {
145        Self {
146            items: Vec::new(),
147            next_cursor: None,
148        }
149    }
150
151    /// Truncates an over-fetched row set and issues the next cursor.
152    ///
153    /// Fetch [`PageParams::fetch_limit`] rows, then hand them here. When more
154    /// rows than the limit came back, the extra row is dropped and `key_of` is
155    /// called on the last kept row to build the next cursor.
156    ///
157    /// # Errors
158    ///
159    /// Returns [`PaginationError::InvalidLimit`] if the limit does not fit a
160    /// `usize`, or [`PaginationError::InvalidCursor`] if the filters cannot be
161    /// serialized.
162    pub fn from_rows<F>(
163        mut rows: Vec<T>,
164        params: &PageParams,
165        normalized_filters: &F,
166        key_of: impl FnOnce(&T) -> PageKey<String>,
167    ) -> Result<Self, PaginationError>
168    where
169        F: Serialize + ?Sized,
170    {
171        let limit = params.limit_usize()?;
172        let has_more = rows.len() > limit;
173        rows.truncate(limit);
174        let next_cursor = if has_more {
175            rows.last()
176                .map(|row| Cursor::from_page_key(&key_of(row), normalized_filters)?.encode())
177                .transpose()?
178        } else {
179            None
180        };
181        Ok(Self::new(rows, next_cursor))
182    }
183
184    /// Maps every item while keeping the cursor, for domain to DTO conversion.
185    pub fn map<U>(self, map: impl FnMut(T) -> U) -> Page<U> {
186        Page {
187            items: self.items.into_iter().map(map).collect(),
188            next_cursor: self.next_cursor,
189        }
190    }
191}
192
193#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
194struct CursorPayload {
195    v: u8,
196    k: Vec<String>,
197    f: String,
198}
199
200/// An opaque keyset cursor bound to a version and to the request filters.
201///
202/// Encode with [`Cursor::encode`] and read the position back with
203/// [`Cursor::page_key`]. The wire form is base64url without padding and must be
204/// treated as opaque by clients.
205#[derive(Clone, Debug, Eq, PartialEq)]
206pub struct Cursor(CursorPayload);
207
208impl Cursor {
209    /// Builds a cursor for a keyset position and the filters it belongs to.
210    ///
211    /// `normalized_filters` must serialize deterministically. Use a struct with
212    /// a fixed field order or a `BTreeMap`, and normalize values (case, default
213    /// ranges) before hashing so an equivalent request keeps its cursor valid.
214    ///
215    /// # Errors
216    ///
217    /// Returns [`PaginationError::InvalidCursor`] if the filters cannot be
218    /// serialized to JSON.
219    pub fn from_page_key<F>(
220        key: &PageKey<String>,
221        normalized_filters: &F,
222    ) -> Result<Self, PaginationError>
223    where
224        F: Serialize + ?Sized,
225    {
226        Ok(Self(CursorPayload {
227            v: CURSOR_VERSION,
228            k: vec![key.value.clone(), key.id.to_string()],
229            f: filter_hash(normalized_filters)?,
230        }))
231    }
232
233    /// Decodes a client cursor and checks it against the current filters.
234    ///
235    /// # Errors
236    ///
237    /// Returns [`PaginationError::InvalidCursor`] when the input is not
238    /// base64url, is not the expected payload, carries a version this build
239    /// does not understand, or was issued for a different filter set.
240    pub fn decode<F>(encoded: &str, normalized_filters: &F) -> Result<Self, PaginationError>
241    where
242        F: Serialize + ?Sized,
243    {
244        let bytes = URL_SAFE_NO_PAD
245            .decode(encoded)
246            .map_err(|_| PaginationError::InvalidCursor)?;
247        let payload: CursorPayload =
248            serde_json::from_slice(&bytes).map_err(|_| PaginationError::InvalidCursor)?;
249        if payload.v != CURSOR_VERSION
250            || payload.k.len() != 2
251            || payload.f != filter_hash(normalized_filters)?
252        {
253            return Err(PaginationError::InvalidCursor);
254        }
255        Ok(Self(payload))
256    }
257
258    /// Renders the cursor as the base64url string sent to clients.
259    ///
260    /// # Errors
261    ///
262    /// Returns [`PaginationError::InvalidCursor`] if the payload cannot be
263    /// serialized.
264    pub fn encode(&self) -> Result<String, PaginationError> {
265        let bytes = serde_json::to_vec(&self.0).map_err(|_| PaginationError::InvalidCursor)?;
266        Ok(URL_SAFE_NO_PAD.encode(bytes))
267    }
268
269    /// Parses the keyset position back into the ordered column type.
270    ///
271    /// # Errors
272    ///
273    /// Returns [`PaginationError::InvalidCursor`] when the stored value does
274    /// not parse as `T` or the tie-breaker is not a UUID.
275    pub fn page_key<T>(&self) -> Result<PageKey<T>, PaginationError>
276    where
277        T: FromStr,
278    {
279        let (value, id) = match self.0.k.as_slice() {
280            [value, id] => (value, id),
281            _ => return Err(PaginationError::InvalidCursor),
282        };
283        let value = value
284            .parse::<T>()
285            .map_err(|_| PaginationError::InvalidCursor)?;
286        let id = Uuid::parse_str(id).map_err(|_| PaginationError::InvalidCursor)?;
287        Ok(PageKey { value, id })
288    }
289}
290
291fn filter_hash<F>(normalized_filters: &F) -> Result<String, PaginationError>
292where
293    F: Serialize + ?Sized,
294{
295    let bytes =
296        serde_json::to_vec(normalized_filters).map_err(|_| PaginationError::InvalidCursor)?;
297    let hash = digest::digest(&digest::SHA256, &bytes);
298    Ok(hash.as_ref()[..FILTER_HASH_BYTES].iter().fold(
299        String::with_capacity(FILTER_HASH_BYTES * 2),
300        |mut out, byte| {
301            use std::fmt::Write as _;
302            let _ = write!(out, "{byte:02x}");
303            out
304        },
305    ))
306}
307
308/// Why a paginated request could not be served.
309#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
310pub enum PaginationError {
311    /// The requested page size is outside `1..=`[`MAX_PAGE_LIMIT`].
312    #[error("page limit must be between 1 and 200")]
313    InvalidLimit,
314    /// The cursor is malformed, unsupported, or does not match the filters.
315    #[error("cursor is malformed, unsupported, or does not match the request filters")]
316    InvalidCursor,
317}
318
319impl From<PaginationError> for crate::ApiError {
320    fn from(error: PaginationError) -> Self {
321        match error {
322            PaginationError::InvalidLimit => Self::validation_field("limit", error.to_string()),
323            PaginationError::InvalidCursor => Self::validation_field("cursor", error.to_string()),
324        }
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use serde::Serialize;
331
332    use super::*;
333
334    #[derive(Serialize)]
335    struct Filters<'a> {
336        from: Option<&'a str>,
337        to: Option<&'a str>,
338    }
339
340    fn no_filters() -> Filters<'static> {
341        Filters {
342            from: None,
343            to: None,
344        }
345    }
346
347    fn id(last: u8) -> Uuid {
348        Uuid::from_bytes([
349            0x01, 0x98, 0xaa, 0xbb, 0xcc, 0xdd, 0x7e, 0xef, 0x80, 0, 0, 0, 0, 0, 0, last,
350        ])
351    }
352
353    fn encode(key: &PageKey<String>, filters: &Filters<'_>) -> String {
354        Cursor::from_page_key(key, filters)
355            .and_then(|cursor| cursor.encode())
356            .expect("cursor should encode")
357    }
358
359    #[test]
360    fn cursor_round_trips_the_keyset_position() {
361        let filters = Filters {
362            from: Some("2026-07-01T00:00:00Z"),
363            to: None,
364        };
365        let key = PageKey::new("2026-07-30T18:22:11Z".to_owned(), id(1));
366        let decoded = Cursor::decode(&encode(&key, &filters), &filters)
367            .and_then(|cursor| cursor.page_key::<String>())
368            .expect("cursor should decode");
369        assert_eq!(decoded, key);
370    }
371
372    #[test]
373    fn tampered_cursor_is_rejected() {
374        let filters = no_filters();
375        let key = PageKey::new("Bench press".to_owned(), id(2));
376        let bytes = URL_SAFE_NO_PAD
377            .decode(encode(&key, &filters))
378            .expect("cursor should be base64url");
379        let mut payload: CursorPayload =
380            serde_json::from_slice(&bytes).expect("cursor should be JSON");
381        payload.f = "0000000000000000".to_owned();
382        let tampered =
383            URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).expect("payload should serialize"));
384        assert_eq!(
385            Cursor::decode(&tampered, &filters),
386            Err(PaginationError::InvalidCursor)
387        );
388    }
389
390    #[test]
391    fn cursor_replayed_with_different_filters_is_rejected() {
392        let original = Filters {
393            from: Some("2026-07-01T00:00:00Z"),
394            to: None,
395        };
396        let altered = Filters {
397            from: Some("2026-07-02T00:00:00Z"),
398            to: None,
399        };
400        let key = PageKey::new("Bench press".to_owned(), id(3));
401        assert_eq!(
402            Cursor::decode(&encode(&key, &original), &altered),
403            Err(PaginationError::InvalidCursor)
404        );
405    }
406
407    #[test]
408    fn cursor_from_another_version_is_rejected() {
409        let filters = no_filters();
410        let payload = CursorPayload {
411            v: CURSOR_VERSION + 1,
412            k: vec!["Bench press".to_owned(), id(4).to_string()],
413            f: filter_hash(&filters).expect("filters should hash"),
414        };
415        let encoded =
416            URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).expect("payload should serialize"));
417        assert_eq!(
418            Cursor::decode(&encoded, &filters),
419            Err(PaginationError::InvalidCursor)
420        );
421    }
422
423    #[test]
424    fn cursor_with_a_truncated_keyset_is_rejected() {
425        let filters = no_filters();
426        let payload = CursorPayload {
427            v: CURSOR_VERSION,
428            k: vec!["Bench press".to_owned()],
429            f: filter_hash(&filters).expect("filters should hash"),
430        };
431        let encoded =
432            URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).expect("payload should serialize"));
433        assert_eq!(
434            Cursor::decode(&encoded, &filters),
435            Err(PaginationError::InvalidCursor)
436        );
437    }
438
439    #[test]
440    fn non_base64_and_non_payload_cursors_are_rejected() {
441        let filters = no_filters();
442        assert_eq!(
443            Cursor::decode("not a cursor!!", &filters),
444            Err(PaginationError::InvalidCursor)
445        );
446        let garbage = URL_SAFE_NO_PAD.encode(b"{\"nope\":true}");
447        assert_eq!(
448            Cursor::decode(&garbage, &filters),
449            Err(PaginationError::InvalidCursor)
450        );
451    }
452
453    #[test]
454    fn page_key_rejects_an_unparseable_value_or_id() {
455        let filters = no_filters();
456        let key = PageKey::new("not-a-number".to_owned(), id(5));
457        let cursor = Cursor::decode(&encode(&key, &filters), &filters).expect("cursor decodes");
458        assert_eq!(
459            cursor.page_key::<u64>().map(|key| key.value),
460            Err(PaginationError::InvalidCursor)
461        );
462
463        let payload = CursorPayload {
464            v: CURSOR_VERSION,
465            k: vec!["7".to_owned(), "not-a-uuid".to_owned()],
466            f: filter_hash(&filters).expect("filters should hash"),
467        };
468        let encoded =
469            URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).expect("payload should serialize"));
470        let cursor = Cursor::decode(&encoded, &filters).expect("cursor decodes");
471        assert_eq!(
472            cursor.page_key::<u64>().map(|key| key.value),
473            Err(PaginationError::InvalidCursor)
474        );
475    }
476
477    #[test]
478    fn page_limit_defaults_and_enforces_bounds() {
479        assert_eq!(
480            PageParams::new(None, None)
481                .expect("default should validate")
482                .limit,
483            DEFAULT_PAGE_LIMIT
484        );
485        assert!(PageParams::new(Some(1), None).is_ok());
486        assert!(PageParams::new(Some(MAX_PAGE_LIMIT), None).is_ok());
487        assert_eq!(
488            PageParams::new(Some(0), None),
489            Err(PaginationError::InvalidLimit)
490        );
491        assert_eq!(
492            PageParams::new(Some(MAX_PAGE_LIMIT + 1), None),
493            Err(PaginationError::InvalidLimit)
494        );
495        assert_eq!(
496            PageParams::new(Some(-1), None),
497            Err(PaginationError::InvalidLimit)
498        );
499    }
500
501    #[test]
502    fn page_params_decode_cursor_passes_through_none() {
503        let filters = no_filters();
504        let params = PageParams::new(Some(10), None).expect("params validate");
505        assert_eq!(params.decode_cursor(&filters), Ok(None));
506        assert_eq!(params.fetch_limit(), Ok(11));
507        assert_eq!(params.limit_usize(), Ok(10));
508
509        let key = PageKey::new("Bench press".to_owned(), id(6));
510        let params = PageParams::new(Some(10), Some(encode(&key, &filters))).expect("params");
511        let decoded = params
512            .decode_cursor(&filters)
513            .expect("cursor decodes")
514            .expect("cursor is present");
515        assert_eq!(decoded.page_key::<String>(), Ok(key));
516    }
517
518    #[test]
519    fn from_rows_truncates_and_issues_a_next_cursor() {
520        let filters = no_filters();
521        let params = PageParams::new(Some(2), None).expect("params validate");
522        let rows = vec![(1_u64, id(1)), (2, id(2)), (3, id(3))];
523        let page = Page::from_rows(rows, &params, &filters, |row| {
524            PageKey::new(row.0.to_string(), row.1)
525        })
526        .expect("page builds");
527        assert_eq!(page.items, vec![(1, id(1)), (2, id(2))]);
528        let next = page.next_cursor.expect("a third row means another page");
529        let key = Cursor::decode(&next, &filters)
530            .and_then(|cursor| cursor.page_key::<u64>())
531            .expect("next cursor decodes");
532        assert_eq!(key, PageKey::new(2, id(2)));
533    }
534
535    #[test]
536    fn from_rows_ends_the_page_when_no_extra_row_came_back() {
537        let filters = no_filters();
538        let params = PageParams::new(Some(5), None).expect("params validate");
539        let page = Page::from_rows(vec![(1_u64, id(1))], &params, &filters, |row| {
540            PageKey::new(row.0.to_string(), row.1)
541        })
542        .expect("page builds");
543        assert_eq!(page.next_cursor, None);
544        assert_eq!(page.items.len(), 1);
545
546        let empty = Page::from_rows(Vec::<(u64, Uuid)>::new(), &params, &filters, |row| {
547            PageKey::new(row.0.to_string(), row.1)
548        })
549        .expect("page builds");
550        assert_eq!(empty, Page::empty());
551    }
552
553    #[test]
554    fn page_maps_items_and_keeps_the_cursor() {
555        let page = Page::new(vec![1_u64, 2], Some("cursor".to_owned()));
556        let mapped = page.map(|value| value.to_string());
557        assert_eq!(mapped.items, vec!["1".to_owned(), "2".to_owned()]);
558        assert_eq!(mapped.next_cursor.as_deref(), Some("cursor"));
559    }
560
561    #[test]
562    fn page_serializes_with_a_null_cursor_at_the_end() {
563        let json = serde_json::to_value(Page::new(vec![1_u64], None)).expect("page serializes");
564        assert_eq!(json, serde_json::json!({"items": [1], "next_cursor": null}));
565        let restored: Page<u64> = serde_json::from_value(json).expect("page deserializes");
566        assert_eq!(restored, Page::new(vec![1], None));
567    }
568
569    #[test]
570    fn pagination_errors_map_to_field_level_api_errors() {
571        let limit = crate::ApiError::from(PaginationError::InvalidLimit);
572        assert_eq!(limit.code(), "validation_failed");
573        let cursor = crate::ApiError::from(PaginationError::InvalidCursor);
574        assert_eq!(cursor.code(), "validation_failed");
575    }
576}