mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-04 17:38:10 +00:00
cursor
This commit is contained in:
1 parent
f6b2f8d93c
commit
f0b85893b3
3 files changed
+85
-16
No files matched your search
@@ -33,8 +33,11 @@ impl OpaqueCursorEncoderV2 {
|
||||
}
|
||||
|
||||
/// Encode a last-id cursor into an opaque string.
|
||||
pub fn encode_last_id_cursor(&self, id: u64) -> Result<String, OpaqueCursorErrorV2> {
|
||||
let cursor = OpaqueCursorV2 { last_id: Some(id) };
|
||||
pub fn encode_last_id_cursor(&self, name: &str, id: u64) -> Result<String, OpaqueCursorErrorV2> {
|
||||
let cursor = OpaqueCursorV2 {
|
||||
name: Some(name.to_string()),
|
||||
last_id: Some(id),
|
||||
};
|
||||
self.encode_cursor(&cursor)
|
||||
}
|
||||
|
||||
@@ -52,6 +55,26 @@ impl OpaqueCursorEncoderV2 {
|
||||
Ok(engine.encode(&writer))
|
||||
}
|
||||
|
||||
/// Decode an opaque cursor string and verify the name matches `expected_name`.
|
||||
pub fn decode_cursor_expecting_name(
|
||||
&self,
|
||||
expected_name: &str,
|
||||
cursor: &str,
|
||||
) -> Result<OpaqueCursorV2, OpaqueCursorErrorV2> {
|
||||
let decoded = self.decode_cursor(cursor)?;
|
||||
|
||||
match &decoded.name {
|
||||
None => Err(OpaqueCursorErrorV2::DecodedNameNotPresent),
|
||||
Some(actual) if actual != expected_name => {
|
||||
Err(OpaqueCursorErrorV2::DecodedNameMismatch {
|
||||
expected: expected_name.to_string(),
|
||||
actual: actual.clone(),
|
||||
})
|
||||
}
|
||||
_ => Ok(decoded),
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode an opaque cursor string back into a cursor payload.
|
||||
pub fn decode_cursor(&self, cursor: &str) -> Result<OpaqueCursorV2, OpaqueCursorErrorV2> {
|
||||
let engine = GeneralPurpose::new(&self.alphabet, self.base64_config);
|
||||
@@ -75,32 +98,35 @@ mod tests {
|
||||
#[test]
|
||||
fn roundtrip_last_id() {
|
||||
let encoder = OpaqueCursorEncoderV2::new(SECRET);
|
||||
let encoded = encoder.encode_last_id_cursor(42).unwrap();
|
||||
let encoded = encoder.encode_last_id_cursor("test", 42).unwrap();
|
||||
let decoded = encoder.decode_cursor(&encoded).unwrap();
|
||||
assert_eq!(decoded.name, Some("test".to_string()));
|
||||
assert_eq!(decoded.last_id, Some(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_none_last_id() {
|
||||
fn roundtrip_none_fields() {
|
||||
let encoder = OpaqueCursorEncoderV2::new(SECRET);
|
||||
let cursor = OpaqueCursorV2 { last_id: None };
|
||||
let cursor = OpaqueCursorV2 { name: None, last_id: None };
|
||||
let encoded = encoder.encode_cursor(&cursor).unwrap();
|
||||
let decoded = encoder.decode_cursor(&encoded).unwrap();
|
||||
assert_eq!(decoded.name, None);
|
||||
assert_eq!(decoded.last_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_zero() {
|
||||
let encoder = OpaqueCursorEncoderV2::new(SECRET);
|
||||
let encoded = encoder.encode_last_id_cursor(0).unwrap();
|
||||
let encoded = encoder.encode_last_id_cursor("zero", 0).unwrap();
|
||||
let decoded = encoder.decode_cursor(&encoded).unwrap();
|
||||
assert_eq!(decoded.name, Some("zero".to_string()));
|
||||
assert_eq!(decoded.last_id, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_large_id() {
|
||||
let encoder = OpaqueCursorEncoderV2::new(SECRET);
|
||||
let encoded = encoder.encode_last_id_cursor(u64::MAX).unwrap();
|
||||
let encoded = encoder.encode_last_id_cursor("big", u64::MAX).unwrap();
|
||||
let decoded = encoder.decode_cursor(&encoded).unwrap();
|
||||
assert_eq!(decoded.last_id, Some(u64::MAX));
|
||||
}
|
||||
@@ -109,19 +135,29 @@ mod tests {
|
||||
fn roundtrip_many_ids() {
|
||||
let encoder = OpaqueCursorEncoderV2::new(SECRET);
|
||||
for id in [0, 1, 100, 999, 100_000, 1_000_000_000, u64::MAX - 1] {
|
||||
let encoded = encoder.encode_last_id_cursor(id).unwrap();
|
||||
let encoded = encoder.encode_last_id_cursor("batch", id).unwrap();
|
||||
let decoded = encoder.decode_cursor(&encoded).unwrap();
|
||||
assert_eq!(decoded.name, Some("batch".to_string()));
|
||||
assert_eq!(decoded.last_id, Some(id), "roundtrip failed for id={}", id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_preserved_in_roundtrip() {
|
||||
let encoder = OpaqueCursorEncoderV2::new(SECRET);
|
||||
let encoded = encoder.encode_last_id_cursor("media_files_list", 999).unwrap();
|
||||
let decoded = encoder.decode_cursor(&encoded).unwrap();
|
||||
assert_eq!(decoded.name, Some("media_files_list".to_string()));
|
||||
assert_eq!(decoded.last_id, Some(999));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_secrets_produce_different_output() {
|
||||
let encoder_a = OpaqueCursorEncoderV2::new("secret_a");
|
||||
let encoder_b = OpaqueCursorEncoderV2::new("secret_b");
|
||||
|
||||
let encoded_a = encoder_a.encode_last_id_cursor(42).unwrap();
|
||||
let encoded_b = encoder_b.encode_last_id_cursor(42).unwrap();
|
||||
let encoded_a = encoder_a.encode_last_id_cursor("test", 42).unwrap();
|
||||
let encoded_b = encoder_b.encode_last_id_cursor("test", 42).unwrap();
|
||||
|
||||
assert_ne!(encoded_a, encoded_b);
|
||||
}
|
||||
@@ -131,7 +167,7 @@ mod tests {
|
||||
let encoder_a = OpaqueCursorEncoderV2::new("secret_a");
|
||||
let encoder_b = OpaqueCursorEncoderV2::new("secret_b");
|
||||
|
||||
let encoded = encoder_a.encode_last_id_cursor(42).unwrap();
|
||||
let encoded = encoder_a.encode_last_id_cursor("test", 42).unwrap();
|
||||
let result = encoder_b.decode_cursor(&encoded);
|
||||
|
||||
assert!(result.is_err());
|
||||
@@ -155,8 +191,7 @@ mod tests {
|
||||
fn encoded_cursor_is_url_safe() {
|
||||
let encoder = OpaqueCursorEncoderV2::new(SECRET);
|
||||
for id in [0, 1, 42, 999_999_999] {
|
||||
let encoded = encoder.encode_last_id_cursor(id).unwrap();
|
||||
// URL-safe base64 uses only alphanumerics, '-', '_', and no padding '='
|
||||
let encoded = encoder.encode_last_id_cursor("safe", id).unwrap();
|
||||
assert!(
|
||||
encoded.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
|
||||
"non-URL-safe character in cursor: {:?}",
|
||||
@@ -165,12 +200,37 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_expecting_name_matches() {
|
||||
let encoder = OpaqueCursorEncoderV2::new(SECRET);
|
||||
let encoded = encoder.encode_last_id_cursor("my_list", 100).unwrap();
|
||||
let decoded = encoder.decode_cursor_expecting_name("my_list", &encoded).unwrap();
|
||||
assert_eq!(decoded.name, Some("my_list".to_string()));
|
||||
assert_eq!(decoded.last_id, Some(100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_expecting_name_mismatch() {
|
||||
let encoder = OpaqueCursorEncoderV2::new(SECRET);
|
||||
let encoded = encoder.encode_last_id_cursor("wrong_name", 100).unwrap();
|
||||
let result = encoder.decode_cursor_expecting_name("expected_name", &encoded);
|
||||
assert!(matches!(result, Err(OpaqueCursorErrorV2::DecodedNameMismatch { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_expecting_name_not_present() {
|
||||
let encoder = OpaqueCursorEncoderV2::new(SECRET);
|
||||
let cursor = OpaqueCursorV2 { name: None, last_id: Some(42) };
|
||||
let encoded = encoder.encode_cursor(&cursor).unwrap();
|
||||
let result = encoder.decode_cursor_expecting_name("anything", &encoded);
|
||||
assert!(matches!(result, Err(OpaqueCursorErrorV2::DecodedNameNotPresent)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_output() {
|
||||
let encoder = OpaqueCursorEncoderV2::new(SECRET);
|
||||
let a = encoder.encode_last_id_cursor(42).unwrap();
|
||||
let b = encoder.encode_last_id_cursor(42).unwrap();
|
||||
// No entropy means same input produces same output.
|
||||
let a = encoder.encode_last_id_cursor("det", 42).unwrap();
|
||||
let b = encoder.encode_last_id_cursor("det", 42).unwrap();
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,10 @@ pub enum OpaqueCursorErrorV2 {
|
||||
MagicCryptError(MagicCryptError),
|
||||
Base64DecodeError(base64::DecodeError),
|
||||
Utf8Error(FromUtf8Error),
|
||||
/// The decoded cursor did not contain a name at all.
|
||||
DecodedNameNotPresent,
|
||||
/// The decoded cursor's name did not match the expected name.
|
||||
DecodedNameMismatch { expected: String, actual: String },
|
||||
}
|
||||
|
||||
impl fmt::Display for OpaqueCursorErrorV2 {
|
||||
|
||||
@@ -6,6 +6,11 @@ use serde_derive::{Deserialize, Serialize};
|
||||
/// The frontend cannot read or forge these cursors.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OpaqueCursorV2 {
|
||||
/// Name for the cursor type.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "n")]
|
||||
pub name: Option<String>,
|
||||
|
||||
/// The id of the last ordered element we fetched, so the next page
|
||||
/// will start after (or before) this id depending on sort direction.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
||||
Reference in new issue
Block a user