48 lines
1.5 KiB
Python
48 lines
1.5 KiB
Python
"""Opaque, scope-bound pagination cursors."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import json
|
|
from dataclasses import dataclass
|
|
from hashlib import sha256
|
|
|
|
|
|
class InvalidCursor(ValueError):
|
|
"""Raised for malformed, cross-query, or out-of-range cursors."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Cursor:
|
|
offset: int
|
|
fingerprint: str
|
|
|
|
|
|
def _fingerprint(*parts: str) -> str:
|
|
return sha256("\x1f".join(parts).encode("utf-8")).hexdigest()[:32]
|
|
|
|
|
|
def encode_cursor(offset: int, *scope: str) -> str:
|
|
if offset < 0:
|
|
raise ValueError("cursor offset must be non-negative")
|
|
payload = {"o": offset, "f": _fingerprint(*scope)}
|
|
raw = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("ascii")
|
|
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
|
|
|
|
|
def decode_cursor(value: str | None, *scope: str) -> Cursor:
|
|
if value is None:
|
|
return Cursor(0, _fingerprint(*scope))
|
|
if not 1 <= len(value) <= 500:
|
|
raise InvalidCursor("cursor length is invalid")
|
|
try:
|
|
padded = value + "=" * (-len(value) % 4)
|
|
payload = json.loads(base64.urlsafe_b64decode(padded).decode("ascii"))
|
|
offset = payload["o"]
|
|
fingerprint = payload["f"]
|
|
except (ValueError, KeyError, TypeError, json.JSONDecodeError) as exc:
|
|
raise InvalidCursor("cursor is invalid") from exc
|
|
if type(offset) is not int or offset < 0 or fingerprint != _fingerprint(*scope):
|
|
raise InvalidCursor("cursor does not match this query")
|
|
return Cursor(offset, fingerprint)
|