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.
This commit is contained in:
1 parent
adc3d340b1
commit
7b3b38ccbc
136 files changed
+16821
-11777
No files matched your search
@@ -0,0 +1,122 @@
|
||||
//! 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)
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user