paginate_core/cursor/mod.rs
1//! Cursor value encoding/decoding for keyset pagination.
2//!
3//! A faithful port of pypaginate's `engine/cursor_codec.py`. The wire format is
4//! **byte-identical** to the Python implementation so a cursor minted by either
5//! side decodes in the other:
6//!
7//! 1. each ordering value is serialised to a JSON-safe form (typed scalars get a
8//! `{"__type__": "...", "v": "..."}` tag);
9//! 2. the list is rendered as **compact** JSON (`separators=(",", ":")`) with
10//! **`ensure_ascii=True`** (every non-ASCII char escaped as `\uXXXX`),
11//! exactly like `json.dumps`;
12//! 3. the payload is URL-safe base64 with trailing `=` padding stripped.
13//!
14//! [`encode`] owns step 1-2 (serialisation); [`decode`] owns the reverse.
15
16mod decode;
17mod encode;
18
19use base64::Engine as _;
20
21use crate::error::{CoreError, Result};
22use crate::value::Value;
23
24const URL_SAFE_NO_PAD: base64::engine::GeneralPurpose =
25 base64::engine::general_purpose::URL_SAFE_NO_PAD;
26const URL_SAFE: base64::engine::GeneralPurpose = base64::engine::general_purpose::URL_SAFE;
27
28/// Encode ordering values into a URL-safe cursor string.
29///
30/// # Errors
31/// Returns [`CoreError::InvalidCursor`] if any value is a non-finite float
32/// (`NaN`/`±Inf`): those are neither valid JSON nor valid ordering keys, and
33/// encoding a placeholder would silently corrupt the seek position.
34pub fn encode_cursor(values: &[Value]) -> Result<String> {
35 for value in values {
36 check_finite(value)?;
37 }
38 let mut payload = String::new();
39 encode::write_array(&mut payload, values);
40 Ok(URL_SAFE_NO_PAD.encode(payload.as_bytes()))
41}
42
43/// Reject non-finite floats anywhere in an ordering value, recursing into
44/// nested lists/maps so no `NaN`/`±Inf` ever reaches the writer.
45fn check_finite(value: &Value) -> Result<()> {
46 match value {
47 Value::Float(f) if !f.is_finite() => Err(invalid("non-finite ordering key")),
48 Value::List(items) => items.iter().try_for_each(check_finite),
49 Value::Map(map) => map.values().try_for_each(check_finite),
50 _ => Ok(()),
51 }
52}
53
54/// Decode a cursor string back into its ordering values.
55///
56/// # Errors
57/// Returns [`CoreError::InvalidCursor`] if the cursor is not valid base64, not
58/// valid UTF-8, not a JSON list, or carries an unknown type tag.
59pub fn decode_cursor(cursor: &str) -> Result<Vec<Value>> {
60 let bytes = decode::decode_base64(cursor)?;
61 let text = std::str::from_utf8(&bytes).map_err(|_| invalid("utf8"))?;
62 let parsed: serde_json::Value = serde_json::from_str(text).map_err(|_| invalid("json"))?;
63 let array = parsed.as_array().ok_or_else(|| invalid("not a list"))?;
64 array.iter().map(decode::json_to_value).collect()
65}
66
67/// Construct an [`CoreError::InvalidCursor`] with a machine-readable `reason`.
68fn invalid(reason: &str) -> CoreError {
69 CoreError::InvalidCursor {
70 reason: reason.to_owned(),
71 }
72}
73
74#[cfg(test)]
75mod tests;