1
Fork 0
mirror of https://github.com/Steffo99/distributed-arcade.git synced 2024-11-24 17:14:25 +00:00

Some more final improvements

This commit is contained in:
Steffo 2022-11-12 23:36:28 +01:00
parent 1a34bb1699
commit 67c94fdf50
Signed by: steffo
GPG key ID: 6965406171929D01
11 changed files with 252 additions and 103 deletions

3
Cargo.lock generated
View file

@ -143,7 +143,7 @@ checksum = "338089f42c427b86394a5ee60ff321da23a5c89c9d89514c829687b26359fcff"
[[package]] [[package]]
name = "distributed_arcade" name = "distributed_arcade"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
@ -157,6 +157,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",
"tower-http",
] ]
[[package]] [[package]]

View file

@ -1,6 +1,6 @@
[package] [package]
name = "distributed_arcade" name = "distributed_arcade"
version = "0.1.0" version = "0.2.0"
edition = "2021" edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@ -18,3 +18,4 @@ pretty_env_logger = { version = "0.4.0" }
rand = { version = "0.8.5" } rand = { version = "0.8.5" }
regex = { version = "1.7.0" } regex = { version = "1.7.0" }
async-trait = { version = "0.1.58" } async-trait = { version = "0.1.58" }
tower-http = { version = "0.3.4", features=["cors"] }

7
README.md Normal file
View file

@ -0,0 +1,7 @@
# Distributed Arcade
Fast and simple scoreboard service for games
\[ [**Swagger UI**]() \]
Developed for [`Steffo99/unimore-bda-2`](https://github.com/Steffo99/unimore-bda-2)

51
docs/examples.http Normal file
View file

@ -0,0 +1,51 @@
### Verify that the webserver is working
GET http://localhost:30000/
### Verify that everything is working
POST http://localhost:30000/
### Create a board
POST http://localhost:30000/board/
Content-Type: application/json
{
"name": "example",
"order": "Descending"
}
### Set a score on the board
PUT http://localhost:30000/score/?board=example&player=steffo
Content-Type: application/json
Authorization: Bearer adz313TlarO98B0P
1234.56
### Set another score on the board
PUT http://localhost:30000/score/?board=example&player=offets
Content-Type: application/json
Authorization: Bearer adz313TlarO98B0P
2412.25
### Improve the first score
PUT http://localhost:30000/score/?board=example&player=steffo
Content-Type: application/json
Authorization: Bearer adz313TlarO98B0P
6666.66
### Set a third score
PUT http://localhost:30000/score/?board=example&player=oooooo
Content-Type: application/json
Authorization: Bearer adz313TlarO98B0P
3333.33
### Get the leaderboards
GET http://localhost:30000/board/?board=example&offset=0&size=10
### Get a player's rank
GET http://localhost:30000/score/?board=example&player=steffo
### Get another player's rank
GET http://localhost:30000/score/?board=example&player=offets

3
docs/index.html Normal file
View file

@ -0,0 +1,3 @@
<!DOCTYPE html>
<html lang="en">
</html>

View file

@ -2,16 +2,32 @@ openapi: 3.0.3
info: info:
title: "Distributed Arcade" title: "Distributed Arcade"
version: 0.2.0
description: |- description: |-
A super-fast high score gatherer using Rust and Redis. A super-fast high score gatherer using Rust and Redis.
version: 0.1.0
It allows the creation of _boards_, [sorted sets](https://redis.io/docs/data-types/sorted-sets/) that can be used to track the scores of players in videogames.
It is written to be extremely fast and scalable: it should be able to receive bursts of many requests.
Errors can be distinguished from successful requests via HTTP status codes >=400.
contact:
name: "Stefano Pigozzi"
url: "https://www.steffo.eu"
email: "me@steffo.eu"
license:
name: "GNU Affero General Public License v3.0 or later"
url: https://github.com/Steffo99/distributed-arcade/blob/main/LICENSE.txt"
servers: servers:
- url: "https://arcade.steffo.eu"
description: "Experimental production server"
- url: "http://127.0.0.1:30000" - url: "http://127.0.0.1:30000"
description: "Local development server"
tags: tags:
- name: "Home" - name: "Home"
description: "Miscellaneous routes" description: "Launch checklist"
- name: "Board" - name: "Board"
description: "About boards" description: "About boards"
- name: "Score" - name: "Score"
@ -20,27 +36,23 @@ tags:
paths: paths:
/: /:
get: get:
operationId: "getHome"
summary: "Verify that the web server is working as expected" summary: "Verify that the web server is working as expected"
description: |-
This method simply echoes back a response, and can be used to verify that the API web server is working.
tags: ["Home"] tags: ["Home"]
responses: responses:
200: 204:
description: "Working as expected" description: "Working as expected"
content: post:
application/json: operationId: "postHome"
schema:
nullable: true
example: null
patch:
summary: "Verify that everything is working as expected" summary: "Verify that everything is working as expected"
description: |-
This method is like `GET /`, but it also `PING`s the Redis server to ensure it is working and configured properly.
tags: ["Home"] tags: ["Home"]
responses: responses:
200: 204:
description: "Working as expected" description: "Working as expected"
content:
application/json:
schema:
nullable: true
example: null
500: 500:
description: "Did not receive `PONG` from redis" description: "Did not receive `PONG` from redis"
content: content:
@ -55,7 +67,13 @@ paths:
/board/: /board/:
get: get:
operationId: "getBoard"
summary: "Get the scores of a board" summary: "Get the scores of a board"
description: |-
This method requests a page of scores from a board using the [`ZSCAN`](https://redis.io/commands/zscan/) Redis command.
An offset must be specified to start returning scores from a certain index.
The number of responses to return must be specified as well.
tags: ["Board"] tags: ["Board"]
parameters: parameters:
- $ref: "#/components/parameters/board" - $ref: "#/components/parameters/board"
@ -67,33 +85,37 @@ paths:
content: content:
application/json: application/json:
schema: schema:
type: object type: array
properties: items:
offset: type: object
type: integer description: "A score submitted by an user."
description: "The offset to pass to get the next page, or 0 if there are no more results." properties:
example: 1 name:
scores: type: string
type: array description: "The name of the user who submitted the score."
items: example: "Steffo"
type: object score:
description: "A score submitted by an user." type: number
properties: description: "The submitted score."
name: example: 1234.56
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: 502:
$ref: "#/components/responses/RedisCmdFailed" $ref: "#/components/responses/RedisCmdFailed"
504: 504:
$ref: "#/components/responses/RedisConnFailed" $ref: "#/components/responses/RedisConnFailed"
post: post:
operationId: "postBoard"
summary: "Create a new board" summary: "Create a new board"
description: |-
This method creates a new board.
It returns its _score submission token_ (`XBoardToken` in this spec), which is required to submit new scores to the board.
Boards can use two different orders:
- using the `Ascending` order, lower scores are better ranked than higher scores, like in racing games or golf;
- using the `Descending` order, higher scores are better ranked than lower scores, like in arcade games or athletics.
**WARNING: Once created, a board cannot be edited or deleted, and its token will not be accessible any longer!**
tags: ["Board"] tags: ["Board"]
requestBody: requestBody:
required: true required: true
@ -104,15 +126,15 @@ paths:
properties: properties:
name: name:
type: string type: string
description: "The name of the board to create." description: "The name of the board to create. It will be converted to kebab-case."
example: "gravityfusion" example: "gravityfusion"
order: order:
type: string type: string
example: "DSC" example: "Descending"
description: "The ordering of the board, either ascending or descending." description: "The ordering of the board, either ascending or descending."
enum: enum:
- "ASC" - "Ascending"
- "DSC" - "Descending"
responses: responses:
201: 201:
description: "Board created successfully" description: "Board created successfully"
@ -121,6 +143,15 @@ paths:
schema: schema:
type: string type: string
example: "W4SbhbJ3tnGaIM1S" example: "W4SbhbJ3tnGaIM1S"
links:
getScore:
operationId: "getScore"
parameters:
board: "$response.body/name"
putScore:
operationId: "putScore"
parameters:
board: "$response.body/name"
409: 409:
description: "Board already exists" description: "Board already exists"
content: content:
@ -142,7 +173,10 @@ paths:
/score/: /score/:
get: get:
operationId: "getScore"
summary: "Get a score from a board" summary: "Get a score from a board"
description: |-
Retrieve the score and the position that the given user has on the leaderboard.
tags: ["Score"] tags: ["Score"]
parameters: parameters:
- $ref: "#/components/parameters/board" - $ref: "#/components/parameters/board"
@ -153,15 +187,23 @@ paths:
content: content:
application/json: application/json:
schema: schema:
type: number type: object
description: "The score of the specified player." properties:
example: 1234.56 score:
type: number
description: "The score of the specified player."
example: 1234.56
rank:
type: integer
description: "The zero-indexed rank of the specified player. (You may probably want to add `1` before displaying it to an user.)"
example: 0
502: 502:
$ref: "#/components/responses/RedisCmdFailed" $ref: "#/components/responses/RedisCmdFailed"
504: 504:
$ref: "#/components/responses/RedisConnFailed" $ref: "#/components/responses/RedisConnFailed"
put: put:
operationId: "putScore"
summary: "Submit a score to a board" summary: "Submit a score to a board"
tags: ["Score"] tags: ["Score"]
parameters: parameters:
@ -183,17 +225,31 @@ paths:
content: content:
application/json: application/json:
schema: schema:
type: number type: object
description: "The previous score." properties:
example: 2468.12 score:
type: number
description: "The score of the specified player."
example: 1234.56
rank:
type: integer
description: "The zero-indexed rank of the specified player. (You may probably want to add `1` before displaying it to an user.)"
example: 0
201: 201:
description: "Score submitted and updated" description: "Score submitted and updated"
content: content:
application/json: application/json:
schema: schema:
type: number type: object
description: "The new score." properties:
example: 1234.56 score:
type: number
description: "The score of the specified player."
example: 2468.13
rank:
type: integer
description: "The zero-indexed rank of the specified player. (You may probably want to add `1` before displaying it to an user.)"
example: 0
401: 401:
description: "Missing, invalid or malformed Authorization header" description: "Missing, invalid or malformed Authorization header"
content: content:
@ -210,9 +266,9 @@ paths:
components: components:
securitySchemes: securitySchemes:
XBoardToken: XBoardToken:
type: apiKey type: http
in: header scheme: "Bearer"
name: "Authorization" bearerFormat: "gVsuzIxgVfRx4RNl"
parameters: parameters:
board: board:

View file

@ -5,7 +5,7 @@ mod routes;
mod shortcuts; mod shortcuts;
use axum::routing::{get, post, put, patch}; use axum::routing::{get, post, put};
#[tokio::main] #[tokio::main]
@ -22,12 +22,15 @@ async fn main() {
let webapp = axum::Router::new() let webapp = axum::Router::new()
.route("/", get(routes::home::route_home_get)) .route("/", get(routes::home::route_home_get))
.route("/", patch(routes::home::route_home_patch)) .route("/", post(routes::home::route_home_post))
.route("/board/", get(routes::board::route_board_get)) .route("/board/", get(routes::board::route_board_get))
.route("/board/", post(routes::board::route_board_post)) .route("/board/", post(routes::board::route_board_post))
.route("/score/", get(routes::score::route_score_get)) .route("/score/", get(routes::score::route_score_get))
.route("/score/", put(routes::score::route_score_put)) .route("/score/", put(routes::score::route_score_put))
.layer(axum::Extension(rclient)); .layer(axum::Extension(rclient))
.layer(tower_http::cors::CorsLayer::new()
.allow_origin("*")
);
log::info!("Starting Axum server..."); log::info!("Starting Axum server...");

View file

@ -42,11 +42,3 @@ pub(crate) fn redis_unexpected_behaviour() -> RequestTuple {
req_error!("Redis gave an unexpected response") req_error!("Redis gave an unexpected response")
) )
} }
/// The request succeeded, and there's no data to be returned.
pub(crate) fn success_null() -> RequestTuple {
(
StatusCode::OK,
req_success!(null)
)
}

View file

@ -35,16 +35,6 @@ pub(crate) struct RouteBoardQuery {
} }
/// 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<ScoreObject>,
}
/// A score set by a player, as a serializable struct. /// A score set by a player, as a serializable struct.
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct ScoreObject { pub(crate) struct ScoreObject {
@ -56,19 +46,14 @@ pub(crate) struct ScoreObject {
impl From<(String, f64)> for ScoreObject { impl From<(String, f64)> for ScoreObject {
fn from(t: (String, f64)) -> Self { fn from(t: (String, f64)) -> Self {
ScoreObject {name: t.0, score: t.1} 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. /// 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> { 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..."); log::trace!("Ensuring that the Redis key `{key}` does not contain anything...");
@ -100,15 +85,33 @@ pub(crate) async fn route_board_get(
} }
log::trace!("Determining the Redis key name..."); log::trace!("Determining the Redis key name...");
let order_key = format!("board:{board}:order");
let scores_key = format!("board:{board}:scores"); let scores_key = format!("board:{board}:scores");
let mut rconn = rclient.get_connection_or_504().await?; let mut rconn = rclient.get_connection_or_504().await?;
log::trace!("Determining sorting order...");
let order = rconn.get::<&str, String>(&order_key).await
.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:?}");
log::trace!("Building score retrieval command...");
let mut cmd = redis::Cmd::new();
let mut cmd_with_args = cmd.arg("ZRANGE").arg(&scores_key).arg(&offset).arg(&offset + &size);
if let SortingOrder::Descending = &order {
cmd_with_args = cmd_with_args.arg("REV");
}
cmd_with_args = cmd_with_args.arg("WITHSCORES");
log::trace!("Retrieving scores from {board}..."); log::trace!("Retrieving scores from {board}...");
let result: RouteBoardResponse = redis::cmd("ZSCAN").arg(&scores_key).arg(offset).arg("COUNT").arg(&size) let result: Vec<ScoreObject> = cmd_with_args
.query_async::<redis::aio::Connection, (usize, Vec<(String, f64)>)>(&mut rconn).await .query_async::<redis::aio::Connection, Vec<(String, f64)>>(&mut rconn).await
.map_err(outcome::redis_cmd_failed)? .map_err(outcome::redis_cmd_failed)?
.into(); .into_iter()
.map(From::<(String, f64)>::from)
.collect();
Ok((StatusCode::OK, outcome::req_success!(result))) Ok((StatusCode::OK, outcome::req_success!(result)))
} }

View file

@ -1,25 +1,22 @@
//! Module defining routes for `/`. //! Module defining routes for `/`.
use axum::Extension; use axum::Extension;
use axum::http::StatusCode;
use crate::outcome; use crate::outcome;
use crate::shortcuts::redis::RedisConnectOr504; use crate::shortcuts::redis::RedisConnectOr504;
/// Handler for `GET /`. /// Handler for `GET /`.
/// pub(crate) async fn route_home_get() -> StatusCode {
/// Verifies that the web server is working correctly.
pub(crate) async fn route_home_get() -> outcome::RequestResult {
log::trace!("Echoing back a success..."); log::trace!("Echoing back a success...");
Ok(outcome::success_null()) StatusCode::NO_CONTENT
} }
/// Handler for `PATCH /`. /// Handler for `POST /`.
/// pub(crate) async fn route_home_post(
/// Pings Redis to verify that everything is working correctly.
pub(crate) async fn route_home_patch(
Extension(rclient): Extension<redis::Client> Extension(rclient): Extension<redis::Client>
) -> outcome::RequestResult { ) -> Result<StatusCode, outcome::RequestTuple> {
let mut rconn = rclient.get_connection_or_504().await?; let mut rconn = rclient.get_connection_or_504().await?;
@ -28,6 +25,6 @@ pub(crate) async fn route_home_patch(
.query_async::<redis::aio::Connection, String>(&mut rconn).await .query_async::<redis::aio::Connection, String>(&mut rconn).await
.map_err(outcome::redis_cmd_failed)? .map_err(outcome::redis_cmd_failed)?
.eq("PONG") .eq("PONG")
.then(outcome::success_null) .then(|| StatusCode::NO_CONTENT)
.ok_or_else(outcome::redis_unexpected_behaviour) .ok_or_else(outcome::redis_unexpected_behaviour)
} }

View file

@ -23,6 +23,15 @@ pub(crate) struct RouteScoreQuery {
} }
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct RouteScoreResponse {
/// The score the user has on the board.
pub score: f64,
/// The position of the user relative to the other users on the board, zero-based.
pub rank: usize,
}
/// Handler for `GET /score/`. /// Handler for `GET /score/`.
pub(crate) async fn route_score_get( pub(crate) async fn route_score_get(
// Request query // Request query
@ -33,7 +42,8 @@ pub(crate) async fn route_score_get(
let board = board.to_kebab_lowercase(); let board = board.to_kebab_lowercase();
let player = player.to_kebab_lowercase(); let player = player.to_kebab_lowercase();
log::trace!("Determining the Redis key name..."); log::trace!("Determining the Redis key names...");
let order_key = format!("board:{board}:order");
let scores_key = format!("board:{board}:scores"); let scores_key = format!("board:{board}:scores");
let mut rconn = rclient.get_connection_or_504().await?; let mut rconn = rclient.get_connection_or_504().await?;
@ -43,9 +53,25 @@ pub(crate) async fn route_score_get(
.map_err(outcome::redis_cmd_failed)?; .map_err(outcome::redis_cmd_failed)?;
log::trace!("Score is: {score:?}"); log::trace!("Score is: {score:?}");
log::trace!("Determining sorting order...");
let order = rconn.get::<&str, String>(&order_key).await
.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:?}");
log::trace!("Getting rank...");
let rank = match order {
SortingOrder::Ascending => rconn.zrank::<&str, &str, usize>(&scores_key, &player),
SortingOrder::Descending => rconn.zrevrank::<&str, &str, usize>(&scores_key, &player),
}.await.map_err(outcome::redis_cmd_failed)?;
log::trace!("Rank is: {rank:?}");
let result = RouteScoreResponse {score, rank};
Ok(( Ok((
StatusCode::OK, StatusCode::OK,
outcome::req_success!(score) outcome::req_success!(result)
)) ))
} }
@ -69,7 +95,7 @@ pub(crate) async fn route_score_put(
let token_key = format!("board:{board}:token"); let token_key = format!("board:{board}:token");
let scores_key = format!("board:{board}:scores"); let scores_key = format!("board:{board}:scores");
let token = headers.get_authorization_or_401("X-Board-Token")?; let token = headers.get_authorization_or_401("Bearer")?;
let mut rconn = rclient.get_connection_or_504().await?; let mut rconn = rclient.get_connection_or_504().await?;
log::trace!("Checking if the token exists and matches..."); log::trace!("Checking if the token exists and matches...");
@ -103,11 +129,20 @@ pub(crate) async fn route_score_put(
.map_err(outcome::redis_cmd_failed)?; .map_err(outcome::redis_cmd_failed)?;
log::trace!("Received score: {nscore:?}"); log::trace!("Received score: {nscore:?}");
log::trace!("Getting rank...");
let rank = match order {
SortingOrder::Ascending => rconn.zrank::<&str, &str, usize>(&scores_key, &player),
SortingOrder::Descending => rconn.zrevrank::<&str, &str, usize>(&scores_key, &player),
}.await.map_err(outcome::redis_cmd_failed)?;
log::trace!("Rank is: {rank:?}");
let result = RouteScoreResponse {score, rank};
Ok(( Ok((
match changed.gt(&0) { match changed.gt(&0) {
true => StatusCode::CREATED, true => StatusCode::CREATED,
false => StatusCode::OK, false => StatusCode::OK,
}, },
outcome::req_success!(nscore) outcome::req_success!(result)
)) ))
} }