1use 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
18pub const DEFAULT_PAGE_LIMIT: i64 = 50;
20
21pub const MAX_PAGE_LIMIT: i64 = 200;
23
24const CURSOR_VERSION: u8 = 1;
25const FILTER_HASH_BYTES: usize = 8;
26
27#[derive(Clone, Debug, Eq, PartialEq)]
32pub struct PageParams {
33 pub limit: i64,
35 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 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 pub fn limit_usize(&self) -> Result<usize, PaginationError> {
72 usize::try_from(self.limit).map_err(|_| PaginationError::InvalidLimit)
73 }
74
75 pub fn fetch_limit(&self) -> Result<i64, PaginationError> {
81 self.limit
82 .checked_add(1)
83 .ok_or(PaginationError::InvalidLimit)
84 }
85
86 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#[derive(Clone, Debug, Eq, PartialEq)]
112pub struct PageKey<T> {
113 pub value: T,
115 pub id: Uuid,
117}
118
119impl<T> PageKey<T> {
120 pub const fn new(value: T, id: Uuid) -> Self {
122 Self { value, id }
123 }
124}
125
126#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
130pub struct Page<T> {
131 pub items: Vec<T>,
133 pub next_cursor: Option<String>,
135}
136
137impl<T> Page<T> {
138 pub const fn new(items: Vec<T>, next_cursor: Option<String>) -> Self {
140 Self { items, next_cursor }
141 }
142
143 pub const fn empty() -> Self {
145 Self {
146 items: Vec::new(),
147 next_cursor: None,
148 }
149 }
150
151 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 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#[derive(Clone, Debug, Eq, PartialEq)]
206pub struct Cursor(CursorPayload);
207
208impl Cursor {
209 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 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 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 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#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
310pub enum PaginationError {
311 #[error("page limit must be between 1 and 200")]
313 InvalidLimit,
314 #[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, ¶ms, &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))], ¶ms, &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(), ¶ms, &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}