Bound paged retrieval responses
This commit is contained in:
parent
176b2d2784
commit
529accf858
15 changed files with 1567 additions and 30 deletions
163
src/docforge/pagination.py
Normal file
163
src/docforge/pagination.py
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
"""Deterministic, generation-bound pagination cursors for bounded public results."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from typing import cast
|
||||
|
||||
from .errors import DocForgeError
|
||||
|
||||
CURSOR_SCHEMA_VERSION = 1
|
||||
MAX_CURSOR_CHARS = 8_192
|
||||
_CURSOR_DOMAIN = b"docforge-page-cursor-v1\0"
|
||||
_CURSOR_KEYS = frozenset({"schema_version", "kind", "binding", "position", "checksum"})
|
||||
|
||||
|
||||
def canonical_hash(value: object) -> str:
|
||||
"""Hash one JSON-compatible value using DocForge's deterministic JSON form."""
|
||||
|
||||
try:
|
||||
encoded = _canonical_bytes(value)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise DocForgeError(
|
||||
"invalid_pagination_source",
|
||||
"Pagination source data is not deterministic JSON",
|
||||
) from error
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
def page_limit(limit: int | None, *, default: int, maximum: int) -> int:
|
||||
"""Validate one additive page size against the project result policy."""
|
||||
|
||||
selected = default if limit is None else limit
|
||||
if type(selected) is not int or selected < 1 or selected > maximum:
|
||||
raise DocForgeError(
|
||||
"invalid_limit",
|
||||
"Page limit is outside the configured result limit",
|
||||
maximum=maximum,
|
||||
)
|
||||
return selected
|
||||
|
||||
|
||||
def encode_cursor(
|
||||
*,
|
||||
kind: str,
|
||||
binding: Mapping[str, object],
|
||||
position: int,
|
||||
) -> str:
|
||||
"""Encode a corruption-detecting cursor bound to an immutable result identity."""
|
||||
|
||||
if not kind or type(position) is not int or position < 0:
|
||||
raise ValueError("Cursor kind and position must be valid")
|
||||
body: dict[str, object] = {
|
||||
"schema_version": CURSOR_SCHEMA_VERSION,
|
||||
"kind": kind,
|
||||
"binding": dict(binding),
|
||||
"position": position,
|
||||
}
|
||||
checksum = hashlib.sha256(_CURSOR_DOMAIN + _canonical_bytes(body)).hexdigest()
|
||||
envelope = {**body, "checksum": checksum}
|
||||
return base64.urlsafe_b64encode(_canonical_bytes(envelope)).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def decode_cursor(
|
||||
cursor: str | None,
|
||||
*,
|
||||
kind: str,
|
||||
binding: Mapping[str, object],
|
||||
total_count: int,
|
||||
) -> int:
|
||||
"""Return a validated position, rejecting corrupt, foreign, or stale cursors."""
|
||||
|
||||
if cursor is None:
|
||||
return 0
|
||||
if not cursor or len(cursor) > MAX_CURSOR_CHARS or not cursor.isascii():
|
||||
raise _invalid_cursor()
|
||||
padding = "=" * (-len(cursor) % 4)
|
||||
try:
|
||||
raw = base64.b64decode(
|
||||
(cursor + padding).encode("ascii"),
|
||||
altchars=b"-_",
|
||||
validate=True,
|
||||
)
|
||||
parsed: object = json.loads(raw.decode("utf-8"))
|
||||
except (binascii.Error, UnicodeDecodeError, json.JSONDecodeError):
|
||||
raise _invalid_cursor() from None
|
||||
if base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") != cursor or not isinstance(
|
||||
parsed, dict
|
||||
):
|
||||
raise _invalid_cursor()
|
||||
payload = cast(dict[str, object], parsed)
|
||||
checksum = payload.get("checksum")
|
||||
position = payload.get("position")
|
||||
stored_binding = payload.get("binding")
|
||||
if (
|
||||
frozenset(payload) != _CURSOR_KEYS
|
||||
or payload.get("schema_version") != CURSOR_SCHEMA_VERSION
|
||||
or payload.get("kind") != kind
|
||||
or not isinstance(stored_binding, dict)
|
||||
or type(position) is not int
|
||||
or position < 0
|
||||
or position >= total_count
|
||||
or not isinstance(checksum, str)
|
||||
or len(checksum) != 64
|
||||
):
|
||||
raise _invalid_cursor()
|
||||
body = {key: payload[key] for key in payload if key != "checksum"}
|
||||
expected = hashlib.sha256(_CURSOR_DOMAIN + _canonical_bytes(body)).hexdigest()
|
||||
if not hmac.compare_digest(checksum, expected):
|
||||
raise _invalid_cursor()
|
||||
if stored_binding != dict(binding):
|
||||
raise DocForgeError(
|
||||
"stale_cursor",
|
||||
"Pagination cursor does not match the current result generation",
|
||||
)
|
||||
return position
|
||||
|
||||
|
||||
def page_receipt(
|
||||
*,
|
||||
kind: str,
|
||||
binding: Mapping[str, object],
|
||||
position: int,
|
||||
count: int,
|
||||
limit: int,
|
||||
total_count: int,
|
||||
) -> dict[str, object]:
|
||||
"""Return one bounded page receipt and the next generation-bound cursor."""
|
||||
|
||||
next_position = position + count
|
||||
has_more = next_position < total_count
|
||||
return {
|
||||
"schema_version": CURSOR_SCHEMA_VERSION,
|
||||
"kind": kind,
|
||||
"returned_count": count,
|
||||
"limit": limit,
|
||||
"total_count": total_count,
|
||||
"has_more": has_more,
|
||||
"next_cursor": (
|
||||
encode_cursor(kind=kind, binding=binding, position=next_position) if has_more else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _canonical_bytes(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=True,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _invalid_cursor() -> DocForgeError:
|
||||
return DocForgeError(
|
||||
"invalid_cursor",
|
||||
"Pagination cursor is malformed or does not match its operation",
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue