Files
tavolo/server/crates/tavolo-platform/src/pagination.rs
T
woggioni 7b3b38ccbc
CI / Build and push docker image (push) Successful in 4m20s
Rewrite the backend in Rust with Axum
Replace the Python/kaya server in server/ with a Rust Cargo workspace of
three crates mirroring the old layout: tavolo-platform (lobby API, live-game
store, websocket endpoint, deadline scheduler, stats/Elo, OIDC sessions),
tavolo-scopone (pure scopone scientifico rules plus the platform adapter)
and tavolo-app (composition root and the `tavolo` binary with
serve/migrate/backfill-elo).

The HTTP and WebSocket contracts, error messages, Redis key layout and
Postgres schema are unchanged, so the existing web/ frontend and in-flight
games keep working. Notable differences: sessions are JSON under
tavolo:session: (Python pickle sessions invalidate once), LOGGING_CONFIG is
ignored in favour of RUST_LOG, DATABASE_URL must be Postgres, sqlx migrations
replace aerich, and OpenTelemetry exports Rust process metrics instead of
CPython gc metrics (no Postgres/Redis client instrumentation).

Docker Compose and the Kubernetes manifest now run the single Rust image
(embedded migrations, /static and /assets served by the app); the release CI
keeps building server/Dockerfile. The full test suite (scopone rules,
platform store/deadlines/stats/websockets, OIDC login flow, scopone e2e) is
ported and runs with cargo test --workspace; Redis/Postgres integration tests
are gated on TEST_REDIS_URL/TEST_DATABASE_URL.
2026-10-02 13:54:55 +00:00

123 lines
4.0 KiB
Rust

//! Cursor-based pagination for listing endpoints.
//!
//! Uses keyset pagination (not OFFSET/LIMIT): each page ends with an opaque
//! cursor encoding the sort key tuple of the last item on that page. The
//! cursor is a base64url-encoded JSON object mapping the sort-field names to
//! the values of the last item; it is opaque to callers.
use std::collections::HashMap;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use serde_json::{Map, Value};
pub const DEFAULT_LIMIT: usize = 20;
pub const MAX_LIMIT: usize = 100;
pub const MIN_LIMIT: usize = 1;
pub const CURSOR_PARAM: &str = "cursor";
pub const LIMIT_PARAM: &str = "limit";
/// Raised when the `cursor`/`limit` query params cannot be decoded; the
/// message is sent verbatim in the 400 response.
#[derive(Debug, Clone, thiserror::Error)]
pub enum CursorDecodeError {
#[error("invalid cursor")]
InvalidCursor,
#[error("invalid limit")]
InvalidLimit,
}
#[derive(Debug, Clone)]
pub struct Cursor {
pub limit: usize,
pub after: Option<Map<String, Value>>,
}
/// Encode sort-key values as an opaque cursor.
pub fn encode_cursor(values: &Map<String, Value>) -> String {
let raw = serde_json::to_vec(values).unwrap_or_default();
URL_SAFE_NO_PAD.encode(raw)
}
/// Decode an opaque cursor; tolerates missing base64 padding.
pub fn decode_cursor(value: Option<&str>) -> Result<Option<Map<String, Value>>, CursorDecodeError> {
let Some(value) = value else {
return Ok(None);
};
if value.is_empty() {
return Ok(None);
}
// Tolerate both padded and unpadded base64url input.
let unpadded = value.trim_end_matches('=');
let raw = URL_SAFE_NO_PAD
.decode(unpadded)
.map_err(|_| CursorDecodeError::InvalidCursor)?;
let parsed: Value =
serde_json::from_slice(&raw).map_err(|_| CursorDecodeError::InvalidCursor)?;
match parsed {
Value::Object(map) => Ok(Some(map)),
_ => Err(CursorDecodeError::InvalidCursor),
}
}
/// Parse the `limit` and `cursor` query parameters.
pub fn parse_cursor_params(
params: &HashMap<String, Vec<String>>,
) -> Result<Cursor, CursorDecodeError> {
let limit = match params.get(LIMIT_PARAM).and_then(|values| values.first()) {
Some(value) => value
.parse::<i64>()
.map_err(|_| CursorDecodeError::InvalidLimit)?,
None => DEFAULT_LIMIT as i64,
};
let limit = limit.clamp(MIN_LIMIT as i64, MAX_LIMIT as i64) as usize;
let after = decode_cursor(
params
.get(CURSOR_PARAM)
.and_then(|values| values.first())
.map(String::as_str),
)?;
Ok(Cursor { limit, after })
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn cursor_round_trip() {
let mut values = Map::new();
values.insert("finished_at".into(), json!("2026-09-19T06:59:17+00:00"));
values.insert("id".into(), json!("7c9e6679-7425-40de-944b-e07fc1f90ae7"));
let encoded = encode_cursor(&values);
assert!(!encoded.contains('='));
let decoded = decode_cursor(Some(&encoded)).unwrap().unwrap();
assert_eq!(decoded, values);
// Padded input is tolerated too.
let padded = format!("{encoded}==");
let decoded = decode_cursor(Some(&padded)).unwrap().unwrap();
assert_eq!(decoded, values);
}
#[test]
fn parse_defaults_and_clamps() {
let params = HashMap::new();
let cursor = parse_cursor_params(&params).unwrap();
assert_eq!(cursor.limit, 20);
assert!(cursor.after.is_none());
let mut params = HashMap::new();
params.insert("limit".into(), vec!["1000".into()]);
assert_eq!(parse_cursor_params(&params).unwrap().limit, 100);
let mut params = HashMap::new();
params.insert("limit".into(), vec!["zero".into()]);
assert!(matches!(
parse_cursor_params(&params),
Err(CursorDecodeError::InvalidLimit)
));
}
}