Skip to main content

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;