CI / Build and push docker image (push) Successful in 4m20s
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.
123 lines
4.0 KiB
Rust
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(¶ms).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(¶ms).unwrap().limit, 100);
|
|
|
|
let mut params = HashMap::new();
|
|
params.insert("limit".into(), vec!["zero".into()]);
|
|
assert!(matches!(
|
|
parse_cursor_params(¶ms),
|
|
Err(CursorDecodeError::InvalidLimit)
|
|
));
|
|
}
|
|
}
|