1
Fork 0
mirror of https://github.com/Steffo99/distributed-arcade.git synced 2024-10-16 06:27:30 +00:00

Refactor code for readability

This commit is contained in:
Steffo 2022-11-12 02:34:03 +01:00
parent 71fa8482eb
commit 0b8e571a55
Signed by: steffo
GPG key ID: 6965406171929D01
16 changed files with 397 additions and 110 deletions

1
Cargo.lock generated
View file

@ -145,6 +145,7 @@ checksum = "338089f42c427b86394a5ee60ff321da23a5c89c9d89514c829687b26359fcff"
name = "distributed_arcade"
version = "0.1.0"
dependencies = [
"async-trait",
"axum",
"lazy_static",
"log",

View file

@ -17,3 +17,4 @@ 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" }

View file

@ -4,14 +4,14 @@ use std::env;
lazy_static! {
pub static ref REDIS_CONN: String = env::var("REDIS_CONN_STRING")
pub(crate) static ref REDIS_CONN: String = env::var("REDIS_CONN_STRING")
.expect("REDIS_CONN_STRING to be set");
pub static ref AXUM_HOST: SocketAddr = env::var("AXUM_HOST_STRING")
pub(crate) static ref AXUM_HOST: SocketAddr = env::var("AXUM_HOST_STRING")
.expect("AXUM_HOST_STRING to be set")
.parse()
.expect("AXUM_HOST_STRING to be a valid SocketAddr");
pub static ref CREATE_TOKEN: String = env::var("CREATE_TOKEN")
pub(crate) static ref CREATE_TOKEN: String = env::var("CREATE_TOKEN")
.expect("CREATE_TOKEN to be set");
}

View file

@ -1,10 +1,11 @@
pub(crate) mod config;
pub(crate) mod outcome;
pub mod types;
pub mod utils;
mod routes;
mod shortcuts;
use axum::routing::{get, post, put};
use axum::routing::{get, post, put, patch};
#[tokio::main]
@ -21,6 +22,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/", post(routes::board::route_board_post))
.route("/score/", put(routes::score::route_score_put))
.layer(axum::Extension(rclient));

View file

@ -1,19 +1,15 @@
use axum::extract::Json;
use axum::http::StatusCode;
use serde_json::Value;
/// The `([StatusCode], Body)` tuple returned by API handlers.
pub(crate) type RequestTuple = (StatusCode, Json<Value>);
pub(crate) type RequestTuple = (StatusCode, axum::extract::Json<Value>);
/// A [`Result`] made of two [`RequestTuple`]s to make handling errors easier.
pub(crate) type RequestResult = Result<RequestTuple, RequestTuple>;
macro_rules! req_error {
( $val:tt ) => {
Json(serde_json::json!({
"ok": false,
"error": $val
}))
axum::extract::Json(serde_json::json!($val))
};
}
@ -22,23 +18,13 @@ pub(crate) use req_error;
macro_rules! req_success {
( $val:tt ) => {
Json(serde_json::json!({
"ok": true,
"data": $val
}))
axum::extract::Json(serde_json::json!($val))
}
}
/// Macro used to build a API success.
pub(crate) use req_success;
/// The server could not connect to Redis.
pub(crate) fn redis_conn_failed() -> RequestTuple {
(
StatusCode::GATEWAY_TIMEOUT,
req_error!("Could not connect to Redis")
)
}
/// The execution of a command in Redis failed.
pub(crate) fn redis_cmd_failed() -> RequestTuple {

View file

@ -6,7 +6,11 @@ use redis::AsyncCommands;
use serde::Serialize;
use serde::Deserialize;
use crate::outcome;
use crate::types::SortingOrder;
use crate::shortcuts::redis::RedisConnectOr504;
use crate::shortcuts::token::Generate;
use crate::utils::sorting::SortingOrder;
use crate::utils::kebab::Skewer;
use crate::utils::token::SecureToken;
/// Expected input data for [`POST /board/`](route_board_post).
@ -18,16 +22,6 @@ pub(crate) struct RouteBoardPostInput {
pub(crate) order: SortingOrder,
}
/// Expected output data for [`POST /board/`](route_board_post).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct RouteBoardPostOutput {
/// The token to use to submit scores to the board.
///
/// ### It's a secret
///
/// Be careful to keep this visible only to the board admins!
pub(crate) token: String,
}
/// 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> {
@ -41,28 +35,6 @@ async fn ensure_key_is_empty(rconn: &mut redis::aio::Connection, key: &str) -> R
.ok_or((StatusCode::CONFLICT, outcome::req_error!("Board already exists")))
}
/// Alphabet for base-62 encoding.
const TOKEN_CHARS: &[char; 62] = &[
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z'
];
/// Generate a cryptographically secure Base-62 token via [`rand::rngs::OsRng`].
fn generate_secure_token() -> Result<String, outcome::RequestTuple> {
log::trace!("Generating a board token...");
let mut rng = rand::rngs::OsRng::default();
let mut token: [u32; 16] = [0; 16];
rand::Fill::try_fill(&mut token, &mut rng)
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, outcome::req_error!("Failed to generate a secure board token.")))?;
Ok(
// FIXME: only works on platforms where usize >= 32-bit?
token.iter().map(|e| TOKEN_CHARS.get(*e as usize % 62).expect("randomly generated value to be a valid index"))
.collect::<String>()
)
}
/// Handler for `POST /board/`.
///
/// Creates a new board, storing the details on [Redis].
@ -77,37 +49,34 @@ pub(crate) async fn route_board_post(
Json(RouteBoardPostInput {name, order}): Json<RouteBoardPostInput>,
) -> outcome::RequestResult {
let name = name.to_kebab_lowercase();
log::trace!("Determining the Redis key names...");
let order_key = format!("board:{name}:order");
let token_key = format!("board:{name}:token");
let scores_key = format!("board:{name}:scores");
log::trace!("Connecting to Redis...");
let mut rconn = rclient.get_async_connection().await
.map_err(|_| outcome::redis_conn_failed())?;
let mut rconn = rclient.get_connection_or_504().await?;
log::trace!("Ensuring a board does not already exist...");
ensure_key_is_empty(&mut rconn, &order_key).await?;
ensure_key_is_empty(&mut rconn, &token_key).await?;
ensure_key_is_empty(&mut rconn, &scores_key).await?;
let token = SecureToken::new_or_500()?;
log::debug!("Creating board: {name:?}");
let token = generate_secure_token()?;
log::trace!("Board token is: {token:?}");
log::trace!("Board order is: {order:?}");
log::trace!("Starting Redis transaction...");
redis::cmd("MULTI").query_async(&mut rconn).await
.map_err(|_| outcome::redis_cmd_failed())?;
log::trace!("Setting board order...");
rconn.set(&order_key, String::from(order)).await
rconn.set(&order_key, Into::<&str>::into(order)).await
.map_err(|_| outcome::redis_cmd_failed())?;
log::trace!("Setting board token...");
rconn.set(&token_key, &token).await
rconn.set(&token_key, &token.0).await
.map_err(|_| outcome::redis_cmd_failed())?;
log::trace!("Executing Redis transaction...");
@ -116,6 +85,6 @@ pub(crate) async fn route_board_post(
Ok((
StatusCode::CREATED,
Json(serde_json::to_value(RouteBoardPostOutput {token}).expect("to be able to serialize RouteBoardPostOutput"))
outcome::req_success!((token.0))
))
}

View file

@ -2,18 +2,26 @@
use axum::Extension;
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 {
log::trace!("Echoing back a success...");
Ok(outcome::success_null())
}
/// Handler for `PATCH /`.
///
/// Pings Redis to verify that everything is working correctly.
pub(crate) async fn route_home_get(
pub(crate) async fn route_home_patch(
Extension(rclient): Extension<redis::Client>
) -> outcome::RequestResult {
log::trace!("Connecting to Redis...");
let mut rconn = rclient.get_async_connection().await
.map_err(|_| outcome::redis_conn_failed())?;
let mut rconn = rclient.get_connection_or_504().await?;
log::trace!("Sending PING and expecting PONG...");
redis::cmd("PING")

163
src/routes/openapi.yaml Normal file
View file

@ -0,0 +1,163 @@
openapi: 3.0.3
info:
title: "Distributed Arcade"
description: |-
A super-fast high score gatherer using Rust and Redis.
version: 0.1.0
servers:
- url: "http://127.0.0.1:30000"
tags:
- name: "Home"
description: "Miscellaneous routes"
- name: "Board"
description: "About boards"
- name: "Score"
description: "Submit scores"
paths:
/:
get:
summary: "Verify that the web server is working as expected"
tags: ["Home"]
responses:
200:
description: "Working as expected"
content:
application/json:
schema:
type: "null"
patch:
summary: "Verify that everything is working as expected"
tags: ["Home"]
responses:
200:
description: "Working as expected"
content:
application/json:
schema:
type: "null"
500:
description: "Did not receive `PONG` from redis"
content:
application/json:
schema:
type: string
example: "Redis gave an unexpected response"
502:
$ref: "#/components/responses/RedisCmdFailed"
504:
$ref: "#/components/responses/RedisConnFailed"
/board/:
post:
summary: "Create a new board"
tags: ["Board"]
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name:
type: string
description: "The name of the board to create."
example: "gravityfusion"
order:
type: string
example: "DSC"
description: "The ordering of the board, either ascending or descending."
enum:
- "ASC"
- "DSC"
responses:
201:
description: "Board created successfully"
content:
application/json:
schema:
type: string
example: "W4SbhbJ3tnGaIM1S"
409:
description: "Board already exists"
content:
application/json:
schema:
type: string
example: "Board already exists"
500:
description: "Could not generate secure board token"
content:
application/json:
schema:
type: string
example: "Could not generate secure board token"
502:
$ref: "#/components/responses/RedisCmdFailed"
504:
$ref: "#/components/responses/RedisConnFailed"
/score/:
put:
summary: "Submit a score to a board"
tags: ["Score"]
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
board:
type: string
description: "The board to submit the score to."
example: "gravityfusion"
score:
type: number
description: "The score to submit to the board."
example: 1234.56
player:
type: string
description: "The name of the player that the score should be submitted as."
example: "Steffo"
security:
- XBoardToken: []
responses:
401:
description: "Missing, invalid or malformed Authorization header"
content:
application/json:
schema:
type: string
example: "Missing Authorization header"
502:
$ref: "#/components/responses/RedisCmdFailed"
504:
$ref: "#/components/responses/RedisConnFailed"
components:
securitySchemes:
XBoardToken:
type: apiKey
in: header
name: "Authorization"
responses:
RedisCmdFailed:
description: "Could not execute Redis command"
content:
application/json:
schema:
type: string
example: "Could not execute Redis command"
RedisConnFailed:
description: "Could not connect to Redis"
content:
application/json:
schema:
type: string
example: "Could not connect to Redis"

View file

@ -4,11 +4,13 @@ use axum::http::StatusCode;
use axum::http::header::HeaderMap;
use axum::extract::{Extension, Json};
use redis::AsyncCommands;
use regex::Regex;
use serde::Serialize;
use serde::Deserialize;
use crate::outcome;
use crate::types::SortingOrder;
use crate::shortcuts::redis::RedisConnectOr504;
use crate::shortcuts::token::Authorize;
use crate::utils::kebab::Skewer;
use crate::utils::sorting::SortingOrder;
/// Expected input data for `PUT /score/`.
@ -40,35 +42,15 @@ pub(crate) async fn route_score_put(
// Redis client
Extension(rclient): Extension<redis::Client>,
) -> outcome::RequestResult {
lazy_static::lazy_static! {
static ref AUTH_HEADER_REGEX: Regex = Regex::new(r#"X-Board (\S+)"#)
.expect("AUTH_HEADER_REGEX to be valid");
}
log::trace!("Checking the Authorization header...");
let token = headers.get("Authorization")
.ok_or_else(|| (StatusCode::UNAUTHORIZED, outcome::req_error!("Missing Authorization header")))?;
let token = token.to_str()
.map_err(|_| (StatusCode::BAD_REQUEST, outcome::req_error!("Malformed Authorization header")))?;
let token = AUTH_HEADER_REGEX.captures(token)
.ok_or_else(|| (StatusCode::BAD_REQUEST, outcome::req_error!("Malformed Authorization header")))?;
let token = token.get(1)
.ok_or_else(|| (StatusCode::UNAUTHORIZED, outcome::req_error!("Invalid Authorization header")))?;
let token = token.as_str();
log::trace!("Received token: {token:?}");
let board = board.to_kebab_lowercase();
log::trace!("Determining the Redis key names...");
let order_key = format!("board:{board}:order");
let token_key = format!("board:{board}:token");
let scores_key = format!("board:{board}:scores");
log::trace!("Connecting to Redis...");
let mut rconn = rclient.get_async_connection().await
.map_err(|_| outcome::redis_conn_failed())?;
let token = headers.get_authorization_or_401("X-Board-Token")?;
let mut rconn = rclient.get_connection_or_504().await?;
log::trace!("Checking if the token exists and matches...");
let btoken = rconn.get::<&str, String>(&token_key).await
@ -84,11 +66,12 @@ pub(crate) async fn route_score_put(
return Err((StatusCode::FORBIDDEN, outcome::req_error!("Invalid board token")))
}
log::trace!("Determining score insertion mode...");
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)
let order = SortingOrder::try_from(order.as_str())
.map_err(|_| outcome::redis_unexpected_behaviour())?;
log::trace!("Sorting order is: {order:?}");
log::trace!("Inserting score: {score:?}");
redis::cmd("ZADD").arg(&scores_key).arg(order.zadd_mode()).arg(&score).arg(&player).query_async(&mut rconn).await
@ -101,6 +84,6 @@ pub(crate) async fn route_score_put(
Ok((
StatusCode::OK,
Json(serde_json::to_value(RouteScorePutOutput {score: nscore}).expect("to be able to serialize RouteScorePutOutput"))
outcome::req_success!(nscore)
))
}

4
src/shortcuts/mod.rs Normal file
View file

@ -0,0 +1,4 @@
//! Module containing utilities that **are** specific to [`distributed_arcade`].
pub(crate) mod redis;
pub(crate) mod token;

24
src/shortcuts/redis.rs Normal file
View file

@ -0,0 +1,24 @@
use async_trait::async_trait;
use axum::http::StatusCode;
use crate::outcome;
#[async_trait]
pub(crate) trait RedisConnectOr504 {
async fn get_connection_or_504(&self) -> Result<redis::aio::Connection, outcome::RequestTuple>;
}
#[async_trait]
impl RedisConnectOr504 for redis::Client {
async fn get_connection_or_504(&self) -> Result<redis::aio::Connection, outcome::RequestTuple> {
log::trace!("Connecting to Redis...");
let rconn = self.get_async_connection().await
.map_err(|_|
(StatusCode::GATEWAY_TIMEOUT, outcome::req_error!("Could not connect to Redis"))
)?;
log::trace!("Connection successful!");
Ok(rconn)
}
}

50
src/shortcuts/token.rs Normal file
View file

@ -0,0 +1,50 @@
use axum::http::{HeaderMap, StatusCode};
use regex::Regex;
use crate::outcome;
use crate::utils::token::SecureToken;
pub trait Authorize<'h> {
fn get_authorization_or_401(&'h self, scheme: &str) -> Result<&'h str, outcome::RequestTuple>;
}
impl<'h> Authorize<'h> for HeaderMap {
fn get_authorization_or_401(&'h self, scheme: &str) -> Result<&'h str, outcome::RequestTuple> {
log::trace!("Searching for the {scheme:?} Authorization header...");
log::trace!("Compiling regex...");
let auth_header_regex = Regex::new(&*format!(r#"{scheme} (\S+)"#))
.expect("scheme to create a valid regex");
log::trace!("Searching Authorization header...");
let token = self.get("Authorization")
.ok_or_else(|| (StatusCode::UNAUTHORIZED, outcome::req_error!("Missing Authorization header")))?;
log::trace!("Converting Authorization header to ASCII string...");
let token = token.to_str()
.map_err(|_| (StatusCode::UNAUTHORIZED, outcome::req_error!("Malformed Authorization header")))?;
log::trace!("Capturing the Authorization scheme value...");
let token = auth_header_regex.captures(token)
.ok_or_else(|| (StatusCode::UNAUTHORIZED, outcome::req_error!("Malformed Authorization header")))?;
log::trace!("Getting the Authorization scheme match...");
let token = token.get(1)
.ok_or_else(|| (StatusCode::UNAUTHORIZED, outcome::req_error!("Invalid Authorization header")))?;
log::trace!("Obtained Authorization scheme token!");
Ok(token.as_str())
}
}
pub trait Generate where Self: Sized {
fn new_or_500() -> Result<Self, outcome::RequestTuple>;
}
impl Generate for SecureToken {
fn new_or_500() -> Result<Self, outcome::RequestTuple> {
SecureToken::new()
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, outcome::req_error!("Could not generate token")))
}
}

59
src/utils/kebab.rs Normal file
View file

@ -0,0 +1,59 @@
//! Module defining and implementing the [`Skewer`] trait.
use regex::Regex;
/// Trait to skewer strings into `UPPER-KEBAB-CASE` or `lower-kebab-case`.
pub trait Skewer {
/// Replace the non-alphanumeric characters of the string with dashes.
fn to_kebab_anycase(&self) -> String;
/// Lowercase the string, then [kebabify](to_kebab_anycase) it.
fn to_kebab_lowercase(&self) -> String;
/// Uppercase the string, then [kebabify](to_kebab_anycase) it.
fn to_kebab_uppercase(&self) -> String;
}
impl Skewer for &str {
fn to_kebab_anycase(&self) -> String {
lazy_static::lazy_static! {
static ref INVALID_CHARACTERS_REGEX: Regex = Regex::new(r#"[^A-Za-z0-9-]"#)
.expect("INVALID_CHARACTERS_REGEX to be valid");
}
log::trace!("Kebab-ifying: {self:?}");
let kebab = INVALID_CHARACTERS_REGEX.replace_all(self, "-").into_owned();
log::trace!("Kebab-ification complete: {kebab:?}");
kebab
}
fn to_kebab_lowercase(&self) -> String {
log::trace!("Kebab-i-lower-fying: {self:?}");
let kebab = self.to_ascii_lowercase().as_str().to_kebab_anycase();
log::trace!("Kebab-i-lower-ification complete: {kebab:?}");
kebab
}
fn to_kebab_uppercase(&self) -> String {
log::trace!("Kebab-i-lower-fying: {self:?}");
let kebab = self.to_ascii_uppercase().as_str().to_kebab_anycase();
log::trace!("Kebab-i-lower-ification complete: {kebab:?}");
kebab
}
}
impl Skewer for String {
fn to_kebab_anycase(&self) -> String {
self.as_str().to_kebab_anycase()
}
fn to_kebab_lowercase(&self) -> String {
self.as_str().to_kebab_lowercase()
}
fn to_kebab_uppercase(&self) -> String {
self.as_str().to_kebab_uppercase()
}
}

5
src/utils/mod.rs Normal file
View file

@ -0,0 +1,5 @@
//! Module containing utilities that aren't specific to [`distributed_arcade`].
pub mod kebab;
pub mod sorting;
pub mod token;

View file

@ -1,5 +1,4 @@
//! Module containing various types recurring in multiple modules of the crate.
//! Module defining and implementing [`SortingOrder`].
use serde::Serialize;
use serde::Deserialize;
@ -9,10 +8,9 @@ use serde::Deserialize;
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
pub enum SortingOrder {
/// The greater the score, the worse it is.
#[serde(rename = "ASC")]
Ascending,
/// The greater the score, the better it is.
#[serde(rename = "DSC")]
Descending,
}
@ -27,23 +25,23 @@ impl SortingOrder {
}
/// How the [`SortingOrder`] is stored in [Redis].
impl From<SortingOrder> for String {
impl From<SortingOrder> for &str {
fn from(ord: SortingOrder) -> Self {
match ord {
SortingOrder::Ascending => "ASC".to_string(),
SortingOrder::Descending => "DSC".to_string(),
SortingOrder::Ascending => "Ascending",
SortingOrder::Descending => "Descending",
}
}
}
/// How the [`SortingOrder`] is retrieved from [Redis].
impl TryFrom<String> for SortingOrder {
impl TryFrom<&str> for SortingOrder {
type Error = ();
fn try_from(val: String) -> Result<Self, Self::Error> {
match val.as_str() {
"ASC" => Ok(Self::Ascending),
"DSC" => Ok(Self::Descending),
fn try_from(val: &str) -> Result<Self, Self::Error> {
match val {
"Ascending" => Ok(Self::Ascending),
"Descending" => Ok(Self::Descending),
_ => Err(())
}
}

34
src/utils/token.rs Normal file
View file

@ -0,0 +1,34 @@
//! Module defining and implementing [`SecureToken`].
use serde::Serialize;
use serde::Deserialize;
/// Alphabet for base-62 encoding.
const TOKEN_CHARS: &[char; 62] = &[
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z'
];
/// A cryptographically secure, [base-62](TOKEN_CHARS) token.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SecureToken(pub String);
impl SecureToken {
pub fn new() -> Result<Self, rand::Error> {
log::trace!("Initializing secure RNG...");
let mut rng = rand::rngs::OsRng::default();
log::trace!("Generating a secure token...");
let mut token: [u32; 16] = [0; 16];
rand::Fill::try_fill(&mut token, &mut rng)?;
let token = token.iter()
.map(|e|
// Only works on platforms where usize >= 32-bit?
TOKEN_CHARS.get(*e as usize % 62)
.expect("randomly generated value to be a valid index")
)
.collect::<String>();
Ok(Self(token))
}
}