use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub(super) enum CursorKey {
Int(i64),
Text(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(super) struct Cursor {
pub(super) v: u8,
pub(super) sort: String,
pub(super) order: String,
pub(super) digest: String,
pub(super) key: CursorKey,
pub(super) id: String,
}
const CURSOR_VERSION: u8 = 1;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum CursorError {
NotHex,
NotJson,
UnknownVersion(u8),
SortMismatch { minted: String, requested: String },
OrderMismatch { minted: String, requested: String },
FilterMismatch,
}
impl CursorError {
pub(super) fn message(&self) -> String {
match self {
CursorError::NotHex | CursorError::NotJson => {
"Invalid cursor: pass back the `next_cursor` from the previous page unmodified"
.to_string()
}
CursorError::UnknownVersion(v) => {
format!("Invalid cursor: unsupported cursor version {v}")
}
CursorError::SortMismatch { minted, requested } => format!(
"Cursor was minted for sort={minted} but the request asks for sort={requested}; \
restart the walk from the first page"
),
CursorError::OrderMismatch { minted, requested } => format!(
"Cursor was minted for order={minted} but the request asks for order={requested}; \
restart the walk from the first page"
),
CursorError::FilterMismatch => "Cursor was minted for a different set of filters; \
restart the walk from the first page"
.to_string(),
}
}
}
pub(super) fn filter_digest(parts: &[&str]) -> String {
let mut hasher = Sha256::new();
for part in parts {
hasher.update(part.as_bytes());
hasher.update([0u8]);
}
hex::encode(hasher.finalize())
.chars()
.take(8)
.collect::<String>()
}
pub(super) fn encode(sort: &str, order: &str, digest: &str, key: CursorKey, id: &str) -> String {
let cursor = Cursor {
v: CURSOR_VERSION,
sort: sort.to_string(),
order: order.to_string(),
digest: digest.to_string(),
key,
id: id.to_string(),
};
hex::encode(serde_json::to_vec(&cursor).unwrap_or_default())
}
pub(super) fn decode(
raw: &str,
sort: &str,
order: &str,
digest: &str,
) -> Result<Cursor, CursorError> {
let bytes = hex::decode(raw).map_err(|_| CursorError::NotHex)?;
let cursor: Cursor = serde_json::from_slice(&bytes).map_err(|_| CursorError::NotJson)?;
if cursor.v != CURSOR_VERSION {
return Err(CursorError::UnknownVersion(cursor.v));
}
if cursor.sort != sort {
return Err(CursorError::SortMismatch {
minted: cursor.sort,
requested: sort.to_string(),
});
}
if cursor.order != order {
return Err(CursorError::OrderMismatch {
minted: cursor.order,
requested: order.to_string(),
});
}
if cursor.digest != digest {
return Err(CursorError::FilterMismatch);
}
Ok(cursor)
}
impl Cursor {
pub(super) fn precedes(&self, key: &CursorKey, id: &str, descending: bool) -> bool {
let here = (&self.key, self.id.as_str());
let there = (key, id);
if descending {
there < here
} else {
there > here
}
}
}
#[cfg(test)]
#[path = "cursor_tests.rs"]
mod tests;