diff --git a/src/routes/openapi.yaml b/openapi.yaml similarity index 75% rename from src/routes/openapi.yaml rename to openapi.yaml index a4a6b1d..6e15ace 100644 --- a/src/routes/openapi.yaml +++ b/openapi.yaml @@ -28,7 +28,8 @@ paths: content: application/json: schema: - type: "null" + nullable: true + example: null patch: summary: "Verify that everything is working as expected" tags: ["Home"] @@ -38,7 +39,8 @@ paths: content: application/json: schema: - type: "null" + nullable: true + example: null 500: description: "Did not receive `PONG` from redis" content: @@ -52,6 +54,44 @@ paths: $ref: "#/components/responses/RedisConnFailed" /board/: + get: + summary: "Get the scores of a board" + tags: ["Board"] + parameters: + - $ref: "#/components/parameters/board" + - $ref: "#/components/parameters/offset" + - $ref: "#/components/parameters/size" + responses: + 200: + description: "Scores retrieved successfully" + content: + application/json: + schema: + type: object + properties: + offset: + type: integer + description: "The offset to pass to get the next page, or 0 if there are no more results." + example: 1 + scores: + type: array + items: + type: object + description: "A score submitted by an user." + properties: + name: + type: string + description: "The name of the user who submitted the score." + example: "Steffo" + score: + type: number + description: "The submitted score." + example: 1234.56 + 502: + $ref: "#/components/responses/RedisCmdFailed" + 504: + $ref: "#/components/responses/RedisConnFailed" + post: summary: "Create a new board" tags: ["Board"] @@ -187,6 +227,20 @@ components: in: query schema: type: string + offset: + name: "offset" + description: "The offset to start returning results from." + in: query + schema: + type: integer + size: + name: "size" + description: "How many results to return." + in: query + schema: + type: integer + minimum: 0 + maximum: 500 responses: RedisCmdFailed: diff --git a/src/main.rs b/src/main.rs index d3853aa..cbc02a5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,6 +23,7 @@ async fn main() { let webapp = axum::Router::new() .route("/", get(routes::home::route_home_get)) .route("/", patch(routes::home::route_home_patch)) + .route("/board/", get(routes::board::route_board_get)) .route("/board/", post(routes::board::route_board_post)) .route("/score/", get(routes::score::route_score_get)) .route("/score/", put(routes::score::route_score_put)) diff --git a/src/outcome.rs b/src/outcome.rs index 0055408..e9322af 100644 --- a/src/outcome.rs +++ b/src/outcome.rs @@ -27,7 +27,8 @@ pub(crate) use req_success; /// The execution of a command in Redis failed. -pub(crate) fn redis_cmd_failed() -> RequestTuple { +pub(crate) fn redis_cmd_failed(err: redis::RedisError) -> RequestTuple { + log::error!("{err:#?}"); ( StatusCode::BAD_GATEWAY, req_error!("Could not execute Redis command") diff --git a/src/routes/board.rs b/src/routes/board.rs index aac0854..621fdae 100644 --- a/src/routes/board.rs +++ b/src/routes/board.rs @@ -1,7 +1,7 @@ //! Module defining routes for `/board/`. use axum::http::StatusCode; -use axum::extract::{Extension, Json}; +use axum::extract::{Extension, Json, Query}; use redis::AsyncCommands; use serde::Serialize; use serde::Deserialize; @@ -13,7 +13,7 @@ use crate::utils::kebab::Skewer; use crate::utils::token::SecureToken; -/// Expected input data for [`POST /board/`](route_board_post). +/// Expected body for [`POST /board/`](route_board_post). #[derive(Clone, Debug, Serialize, Deserialize)] pub(crate) struct RouteBoardBody { /// The name of the board to create. @@ -23,18 +23,97 @@ pub(crate) struct RouteBoardBody { } +/// Expected query params for [`GET /board/`](route_board_get). +#[derive(Clone, Debug, Serialize, Deserialize)] +pub(crate) struct RouteBoardQuery { + /// The name of the board to access. + pub(crate) board: String, + /// The offset to start returning scores from. + pub(crate) offset: usize, + /// How many scores to return. + pub(crate) size: usize, +} + + +/// Expected response for [`GET /board/`](route_board_get). +#[derive(Clone, Debug, Serialize, Deserialize)] +pub(crate) struct RouteBoardResponse { + /// The offset of the next page. + pub(crate) offset: usize, + /// The scores of the current page. + pub(crate) scores: Vec, +} + + +/// A score set by a player, as a serializable struct. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub(crate) struct ScoreObject { + /// The name of the player who set the score. + pub(crate) name: String, + /// The score that the player set. + pub(crate) score: f64, +} + +impl From<(String, f64)> for ScoreObject { + fn from(t: (String, f64)) -> Self { + ScoreObject {name: t.0, score: t.1} + } +} + +impl From<(usize, Vec<(String, f64)>)> for RouteBoardResponse { + fn from(t: (usize, Vec<(String, f64)>)) -> Self { + RouteBoardResponse { + offset: t.0, + scores: t.1.into_iter().map(From::from).collect() + } + } +} + /// Ensure that there is nothing stored at a certain Redis key. async fn ensure_key_is_empty(rconn: &mut redis::aio::Connection, key: &str) -> Result<(), outcome::RequestTuple> { log::trace!("Ensuring that the Redis key `{key}` does not contain anything..."); redis::cmd("TYPE").arg(&key) .query_async::(rconn).await - .map_err(|_| outcome::redis_cmd_failed())? + .map_err(outcome::redis_cmd_failed)? .eq("none") .then_some(()) .ok_or((StatusCode::CONFLICT, outcome::req_error!("Board already exists"))) } +/// Handler for `GET /board/`. +pub(crate) async fn route_board_get( + // Request query + Query(RouteBoardQuery {board, offset, size}): Query, + // Redis client + Extension(rclient): Extension, +) -> outcome::RequestResult { + + let board = board.to_kebab_lowercase(); + + log::trace!("Ensuring the size is within limits..."); + if size > 500 { + return Err(( + StatusCode::BAD_REQUEST, + outcome::req_error!("Cannot request more than 500 scores at a time") + )) + } + + log::trace!("Determining the Redis key name..."); + let scores_key = format!("board:{board}:scores"); + + let mut rconn = rclient.get_connection_or_504().await?; + + log::trace!("Retrieving scores from {board}..."); + let result: RouteBoardResponse = redis::cmd("ZSCAN").arg(&scores_key).arg(offset).arg("COUNT").arg(&size) + .query_async::)>(&mut rconn).await + .map_err(outcome::redis_cmd_failed)? + .into(); + + Ok((StatusCode::OK, outcome::req_success!(result))) +} + + /// Handler for `POST /board/`. /// /// Creates a new board, storing the details on [Redis]. @@ -69,19 +148,19 @@ pub(crate) async fn route_board_post( log::trace!("Starting Redis transaction..."); redis::cmd("MULTI").query_async(&mut rconn).await - .map_err(|_| outcome::redis_cmd_failed())?; + .map_err(outcome::redis_cmd_failed)?; log::trace!("Setting board order..."); rconn.set(&order_key, Into::<&str>::into(order)).await - .map_err(|_| outcome::redis_cmd_failed())?; + .map_err(outcome::redis_cmd_failed)?; log::trace!("Setting board token..."); rconn.set(&token_key, &token.0).await - .map_err(|_| outcome::redis_cmd_failed())?; + .map_err(outcome::redis_cmd_failed)?; log::trace!("Executing Redis transaction..."); redis::cmd("EXEC").query_async(&mut rconn).await - .map_err(|_| outcome::redis_cmd_failed())?; + .map_err(outcome::redis_cmd_failed)?; Ok(( StatusCode::CREATED, diff --git a/src/routes/home.rs b/src/routes/home.rs index 8e8c48a..970cef5 100644 --- a/src/routes/home.rs +++ b/src/routes/home.rs @@ -26,7 +26,7 @@ pub(crate) async fn route_home_patch( log::trace!("Sending PING and expecting PONG..."); redis::cmd("PING") .query_async::(&mut rconn).await - .map_err(|_| outcome::redis_cmd_failed())? + .map_err(outcome::redis_cmd_failed)? .eq("PONG") .then(outcome::success_null) .ok_or_else(outcome::redis_unexpected_behaviour) diff --git a/src/routes/score.rs b/src/routes/score.rs index 5d2c3af..73784ea 100644 --- a/src/routes/score.rs +++ b/src/routes/score.rs @@ -14,6 +14,7 @@ use crate::utils::sorting::SortingOrder; /// Query parameters for `/score/` routes. +#[derive(Clone, Debug, Serialize, Deserialize)] pub(crate) struct RouteScoreQuery { /// The board to access. pub board: String, @@ -39,7 +40,7 @@ pub(crate) async fn route_score_get( log::trace!("Getting score..."); let score = rconn.zscore(&scores_key, &player).await - .map_err(|_| outcome::redis_cmd_failed())?; + .map_err(outcome::redis_cmd_failed)?; log::trace!("Score is: {score:?}"); Ok(( @@ -73,7 +74,7 @@ pub(crate) async fn route_score_put( log::trace!("Checking if the token exists and matches..."); let btoken = rconn.get::<&str, String>(&token_key).await - .map_err(|_| outcome::redis_cmd_failed())?; + .map_err(outcome::redis_cmd_failed)?; if btoken.is_empty() { log::trace!("Token is not set, board does not exist..."); @@ -87,7 +88,7 @@ pub(crate) async fn route_score_put( log::trace!("Determining sorting order..."); let order = rconn.get::<&str, String>(&order_key).await - .map_err(|_| outcome::redis_cmd_failed())?; + .map_err(outcome::redis_cmd_failed)?; let order = SortingOrder::try_from(order.as_str()) .map_err(|_| outcome::redis_unexpected_behaviour())?; log::trace!("Sorting order is: {order:?}"); @@ -95,11 +96,11 @@ pub(crate) async fn route_score_put( log::trace!("Inserting score: {score:?}"); let changed = redis::cmd("ZADD").arg(&scores_key).arg(order.zadd_mode()).arg("CH").arg(&score).arg(&player) .query_async::(&mut rconn).await - .map_err(|_| outcome::redis_cmd_failed())?; + .map_err(outcome::redis_cmd_failed)?; log::trace!("Getting the new score..."); let nscore = rconn.zscore::<&str, &str, f64>(&scores_key, &player).await - .map_err(|_| outcome::redis_cmd_failed())?; + .map_err(outcome::redis_cmd_failed)?; log::trace!("Received score: {nscore:?}"); Ok((