rust
56 lines · 8 steps
Opaque pagination cursors in Rust
Encode a page's position into a base64 token and decode it back for keyset pagination.
Explained by
highlit
1use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
2use serde::{Deserialize, Serialize};
3use time::OffsetDateTime;
4use uuid::Uuid;
5
6#[derive(Debug, Serialize, Deserialize)]
7struct Cursor {
8 created_at: OffsetDateTime,
9 id: Uuid,
10}
11
12#[derive(Debug, thiserror::Error)]
13pub enum CursorError {
14 #[error("malformed cursor encoding")]
15 Decode(#[from] base64::DecodeError),
16 #[error("malformed cursor payload")]
17 Deserialize(#[from] serde_json::Error),
18}
19
20impl Cursor {
21 fn encode(&self) -> String {
22 let payload = serde_json::to_vec(self).expect("cursor is serializable");
23 URL_SAFE_NO_PAD.encode(payload)
24 }
25
26 fn decode(token: &str) -> Result<Self, CursorError> {
27 let bytes = URL_SAFE_NO_PAD.decode(token)?;
28 Ok(serde_json::from_slice(&bytes)?)
29 }
30}
31
32pub struct Page<T> {
33 pub items: Vec<T>,
34 pub next_cursor: Option<String>,
35}
36
37impl Page<Article> {
38 pub fn from_rows(mut rows: Vec<Article>, limit: usize) -> Self {
39 let has_more = rows.len() > limit;
40 rows.truncate(limit);
41
42 let next_cursor = has_more.then(|| {
43 let last = rows.last().expect("non-empty page has a last row");
44 Cursor { created_at: last.created_at, id: last.id }.encode()
45 });
46
47 Page { items: rows, next_cursor }
48 }
49}
50
51pub fn parse_cursor(token: Option<&str>) -> Result<Option<(OffsetDateTime, Uuid)>, CursorError> {
52 token
53 .map(Cursor::decode)
54 .transpose()
55 .map(|opt| opt.map(|c| (c.created_at, c.id)))
56}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1A cursor is just a serialized position turned into an opaque, URL-safe token.
- 2Fetching one row past the limit is a cheap way to detect whether more pages exist.
- 3Combining map, transpose, and map lets you thread an Option through a fallible step cleanly.
Related explainers
rust
use serde::Deserialize; #[derive(Debug, Deserialize)] #[serde(untagged)]
Parsing flexible JSON shapes with serde
deserialization
enums
json
Intermediate
6 steps
ruby
require "shellwords" require "open3" module Backup
Building safe shell commands in Ruby
shell-out
subprocess
command-injection
Intermediate
7 steps
rust
use std::cmp::{Ordering, Reverse}; use std::collections::BinaryHeap; use std::fs::File; use std::io::{self, BufRead, BufReader, BufWriter, Lines, Write};
K-way merge of sorted logs in Rust
binary-heap
k-way-merge
streaming-io
Intermediate
8 steps
rust
use axum::{ extract::{Path, State}, response::sse::{Event, KeepAlive, Sse}, };
Streaming import progress with SSE in Axum
server-sent-events
streams
watch-channel
Advanced
7 steps
rust
use std::f64::consts::PI; #[derive(Debug, Clone, Copy)] pub struct LatLng {
Building geographic bounding boxes in Rust
geospatial
value-types
option
Intermediate
7 steps
rust
use chrono::{Duration, NaiveDate}; #[derive(Debug)] pub struct DateRange {
Parsing and iterating date ranges in Rust
error-handling
iterators
parsing
Intermediate
7 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/opaque-pagination-cursors-in-rust-explained-rust-2816/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.