//! Cursor-based pagination for listing endpoints. //! //! Uses keyset pagination (not OFFSET/LIMIT): each page ends with an opaque //! cursor encoding the sort key tuple of the last item on that page. The //! cursor is a base64url-encoded JSON object mapping the sort-field names to //! the values of the last item; it is opaque to callers. use std::collections::HashMap; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine; use serde_json::{Map, Value}; pub const DEFAULT_LIMIT: usize = 20; pub const MAX_LIMIT: usize = 100; pub const MIN_LIMIT: usize = 1; pub const CURSOR_PARAM: &str = "cursor"; pub const LIMIT_PARAM: &str = "limit"; /// Raised when the `cursor`/`limit` query params cannot be decoded; the /// message is sent verbatim in the 400 response. #[derive(Debug, Clone, thiserror::Error)] pub enum CursorDecodeError { #[error("invalid cursor")] InvalidCursor, #[error("invalid limit")] InvalidLimit, } #[derive(Debug, Clone)] pub struct Cursor { pub limit: usize, pub after: Option>, } /// Encode sort-key values as an opaque cursor. pub fn encode_cursor(values: &Map) -> String { let raw = serde_json::to_vec(values).unwrap_or_default(); URL_SAFE_NO_PAD.encode(raw) } /// Decode an opaque cursor; tolerates missing base64 padding. pub fn decode_cursor(value: Option<&str>) -> Result>, CursorDecodeError> { let Some(value) = value else { return Ok(None); }; if value.is_empty() { return Ok(None); } // Tolerate both padded and unpadded base64url input. let unpadded = value.trim_end_matches('='); let raw = URL_SAFE_NO_PAD .decode(unpadded) .map_err(|_| CursorDecodeError::InvalidCursor)?; let parsed: Value = serde_json::from_slice(&raw).map_err(|_| CursorDecodeError::InvalidCursor)?; match parsed { Value::Object(map) => Ok(Some(map)), _ => Err(CursorDecodeError::InvalidCursor), } } /// Parse the `limit` and `cursor` query parameters. pub fn parse_cursor_params( params: &HashMap>, ) -> Result { let limit = match params.get(LIMIT_PARAM).and_then(|values| values.first()) { Some(value) => value .parse::() .map_err(|_| CursorDecodeError::InvalidLimit)?, None => DEFAULT_LIMIT as i64, }; let limit = limit.clamp(MIN_LIMIT as i64, MAX_LIMIT as i64) as usize; let after = decode_cursor( params .get(CURSOR_PARAM) .and_then(|values| values.first()) .map(String::as_str), )?; Ok(Cursor { limit, after }) } #[cfg(test)] mod tests { use super::*; use serde_json::json; #[test] fn cursor_round_trip() { let mut values = Map::new(); values.insert("finished_at".into(), json!("2026-09-19T06:59:17+00:00")); values.insert("id".into(), json!("7c9e6679-7425-40de-944b-e07fc1f90ae7")); let encoded = encode_cursor(&values); assert!(!encoded.contains('=')); let decoded = decode_cursor(Some(&encoded)).unwrap().unwrap(); assert_eq!(decoded, values); // Padded input is tolerated too. let padded = format!("{encoded}=="); let decoded = decode_cursor(Some(&padded)).unwrap().unwrap(); assert_eq!(decoded, values); } #[test] fn parse_defaults_and_clamps() { let params = HashMap::new(); let cursor = parse_cursor_params(¶ms).unwrap(); assert_eq!(cursor.limit, 20); assert!(cursor.after.is_none()); let mut params = HashMap::new(); params.insert("limit".into(), vec!["1000".into()]); assert_eq!(parse_cursor_params(¶ms).unwrap().limit, 100); let mut params = HashMap::new(); params.insert("limit".into(), vec!["zero".into()]); assert!(matches!( parse_cursor_params(¶ms), Err(CursorDecodeError::InvalidLimit) )); } }