1
Fork 0
mirror of https://github.com/Steffo99/distributed-arcade.git synced 2024-10-16 06:27:30 +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]]
name = "distributed_arcade"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"async-trait",
"axum",
@ -157,6 +157,7 @@ dependencies = [
"serde",
"serde_json",
"tokio",
"tower-http",
]
[[package]]

View file

@ -1,6 +1,6 @@
[package]
name = "distributed_arcade"
version = "0.1.0"
version = "0.2.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@ -17,4 +17,5 @@ log = { version = "0.4.17" }
pretty_env_logger = { version = "0.4.0" }
rand = { version = "0.8.5" }
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:
title: "Distributed Arcade"
version: 0.2.0
description: |-
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:
- url: "https://arcade.steffo.eu"
description: "Experimental production server"
- url: "http://127.0.0.1:30000"
description: "Local development server"
tags:
- name: "Home"
description: "Miscellaneous routes"
description: "Launch checklist"
- name: "Board"
description: "About boards"
- name: "Score"
@ -20,27 +36,23 @@ tags:
paths:
/:
get:
operationId: "getHome"
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"]
responses:
200:
204:
description: "Working as expected"
content:
application/json:
schema:
nullable: true
example: null
patch:
post:
operationId: "postHome"
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"]
responses:
200:
204:
description: "Working as expected"
content:
application/json:
schema:
nullable: true
example: null
500:
description: "Did not receive `PONG` from redis"
content:
@ -55,7 +67,13 @@ paths:
/board/:
get:
operationId: "getBoard"
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"]
parameters:
- $ref: "#/components/parameters/board"
@ -67,33 +85,37 @@ paths:
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
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:
operationId: "postBoard"
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"]
requestBody:
required: true
@ -104,15 +126,15 @@ paths:
properties:
name:
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"
order:
type: string
example: "DSC"
example: "Descending"
description: "The ordering of the board, either ascending or descending."
enum:
- "ASC"
- "DSC"
- "Ascending"
- "Descending"
responses:
201:
description: "Board created successfully"
@ -121,6 +143,15 @@ paths:
schema:
type: string
example: "W4SbhbJ3tnGaIM1S"
links:
getScore:
operationId: "getScore"
parameters:
board: "$response.body/name"
putScore:
operationId: "putScore"
parameters:
board: "$response.body/name"
409:
description: "Board already exists"
content:
@ -142,7 +173,10 @@ paths:
/score/:
get:
operationId: "getScore"
summary: "Get a score from a board"
description: |-
Retrieve the score and the position that the given user has on the leaderboard.
tags: ["Score"]
parameters:
- $ref: "#/components/parameters/board"
@ -153,15 +187,23 @@ paths:
content:
application/json:
schema:
type: number
description: "The score of the specified player."
example: 1234.56
type: object
properties:
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:
$ref: "#/components/responses/RedisCmdFailed"
504:
$ref: "#/components/responses/RedisConnFailed"
put:
operationId: "putScore"
summary: "Submit a score to a board"
tags: ["Score"]
parameters:
@ -183,17 +225,31 @@ paths:
content:
application/json:
schema:
type: number
description: "The previous score."
example: 2468.12
type: object
properties:
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:
description: "Score submitted and updated"
content:
application/json:
schema:
type: number
description: "The new score."
example: 1234.56
type: object
properties:
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:
description: "Missing, invalid or malformed Authorization header"
content:
@ -210,9 +266,9 @@ paths:
components:
securitySchemes:
XBoardToken:
type: apiKey
in: header
name: "Authorization"
type: http
scheme: "Bearer"
bearerFormat: "gVsuzIxgVfRx4RNl"
parameters:
board:

View file

@ -5,7 +5,7 @@ mod routes;
mod shortcuts;
use axum::routing::{get, post, put, patch};
use axum::routing::{get, post, put};
#[tokio::main]
@ -22,12 +22,15 @@ async fn main() {
let webapp = axum::Router::new()
.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/", post(routes::board::route_board_post))
.route("/score/", get(routes::score::route_score_get))
.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...");

View file

@ -42,11 +42,3 @@ pub(crate) fn redis_unexpected_behaviour() -> RequestTuple {
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.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct ScoreObject {
@ -56,19 +46,14 @@ pub(crate) struct ScoreObject {
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()
ScoreObject {
name: t.0,
score: t.1
}
}
}
/// 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...");
@ -100,15 +85,33 @@ pub(crate) async fn route_board_get(
}
log::trace!("Determining the Redis key name...");
let order_key = format!("board:{board}:order");
let scores_key = format!("board:{board}:scores");
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}...");
let result: RouteBoardResponse = redis::cmd("ZSCAN").arg(&scores_key).arg(offset).arg("COUNT").arg(&size)
.query_async::<redis::aio::Connection, (usize, Vec<(String, f64)>)>(&mut rconn).await
let result: Vec<ScoreObject> = cmd_with_args
.query_async::<redis::aio::Connection, Vec<(String, f64)>>(&mut rconn).await
.map_err(outcome::redis_cmd_failed)?
.into();
.into_iter()
.map(From::<(String, f64)>::from)
.collect();
Ok((StatusCode::OK, outcome::req_success!(result)))
}

View file

@ -1,25 +1,22 @@
//! Module defining routes for `/`.
use axum::Extension;
use axum::http::StatusCode;
use crate::outcome;
use crate::shortcuts::redis::RedisConnectOr504;
/// Handler for `GET /`.
///
/// Verifies that the web server is working correctly.
pub(crate) async fn route_home_get() -> outcome::RequestResult {
pub(crate) async fn route_home_get() -> StatusCode {
log::trace!("Echoing back a success...");
Ok(outcome::success_null())
StatusCode::NO_CONTENT
}
/// Handler for `PATCH /`.
///
/// Pings Redis to verify that everything is working correctly.
pub(crate) async fn route_home_patch(
/// Handler for `POST /`.
pub(crate) async fn route_home_post(
Extension(rclient): Extension<redis::Client>
) -> outcome::RequestResult {
) -> Result<StatusCode, outcome::RequestTuple> {
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
.map_err(outcome::redis_cmd_failed)?
.eq("PONG")
.then(outcome::success_null)
.then(|| StatusCode::NO_CONTENT)
.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/`.
pub(crate) async fn route_score_get(
// Request query
@ -33,7 +42,8 @@ pub(crate) async fn route_score_get(
let board = board.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 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)?;
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((
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 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?;
log::trace!("Checking if the token exists and matches...");
@ -102,12 +128,21 @@ pub(crate) async fn route_score_put(
let nscore = rconn.zscore::<&str, &str, f64>(&scores_key, &player).await
.map_err(outcome::redis_cmd_failed)?;
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((
match changed.gt(&0) {
true => StatusCode::CREATED,
false => StatusCode::OK,
},
outcome::req_success!(nscore)
outcome::req_success!(result)
))
}