In this tutorial you'll build a Gaming Leaderboard to store runs from a rhythm game.
Let's download Rust and the dependencies needed for this project.
If you don't have rust installed in your machine yet, run the command below and it will install Rust and some other helpful tools (such as Cargo).
$ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Now with the Rust and Cargo installed, just create a new project using this command:
cargo new leaderboard-rustWe're gonna be using sensitive credentials on our project, to connect into ScyllaDB Cluster, so let's prepare an .env file to handle that. Create a new env in the root folder, next to cargo.toml and replace to your cluster credentials:
# App Config
APP_NAME="Gaming Leaderboard"
APP_VERSION="0.0.1"
APP_URL="0.0.0.0"
APP_PORT="8000"
# Database Config
SCYLLA_NODES="node-0.clusters.scylla.cloud,node-1.clusters.scylla.cloud,node-2.clusters.scylla.cloud"
SCYLLA_USERNAME="scylla"
SCYLLA_PASSWORD="your-password"
SCYLLA_CACHED_QUERIES="15"
SCYLLA_KEYSPACE="leaderboard"
Let's do a quick change into our cargo.toml and add our project dependencies.
[package]
name = "leaderboard-rust"
version = "0.1.0"
edition = "2021"
[dependencies]
actix-web = "4.5.1"
charybdis = "0.4.2"
chrono = "0.4.34"
dotenvy = "0.15.7"
scylla = { version = "0.12.0", features = ["time", "chrono"] }
serde = { version = "1.0.197", features = ["derive"] }
serde_json = "1.0.113"
thiserror = "1.0.56"
uuid = { version = "1.7.0", features = ["v4"] }
log = "0.4.20"After setting the dependencies, let's install them using:
cargo runImportant items to check:
- Scylla: using the latest driver release.
- Charybdis ORM: Charybdis is a ORM layer on top of scylla_rust_driver focused on easy of use and performance.
- Uuid: help us to create UUIDs in our project
- Actix: Rust Web Framework.
- This Error: Idiomatic Error Handling.
- Chrono: DateTime/Timestamp Handling.
This is how it will be our source folder at the end of this tutorial:
/src
├── main.rs
├── config
│ ├── app.rs
│ ├── config.rs
│ └── mod.rs
├── http
│ ├── controllers
│ │ ├── leaderboard_controller.rs
│ │ ├── mod.rs
│ │ └── submissions_controller.rs
│ ├── mod.rs
│ └── requests
│ ├── leaderboard_request.rs
│ ├── mod.rs
│ └── submission_request.rs
└── models
├── leaderboard.rs
├── mod.rs
└── submission.rs
We're about to develop a feature per time. However we have to set some items like configuration and migrations before jump into the implementations!
/src
├── main.rs
└── config
├── app.rs
├── config.rs
└── mod.rs
Step by step of the configuration files needed on this project:
Here we going store some environment variables to use in in the project. Here's the structure:
// File: src/config/config.rs
use serde::Serialize;
#[derive(Clone, Debug, Serialize)]
pub struct App {
pub name: String,
pub version: String,
pub url: String,
pub port: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct Database {
pub nodes: Vec<String>,
pub username: String,
pub password: String,
pub cached_queries: usize,
pub keyspace: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct Config {
pub app: App,
pub database: Database
}
impl Config {
pub fn new() -> Self {
Config {
app: App {
name: dotenvy::var("APP_NAME").unwrap(),
version: dotenvy::var("APP_VERSION").unwrap(),
url: dotenvy::var("APP_URL").unwrap(),
port: dotenvy::var("APP_PORT").unwrap(),
},
database: Database {
nodes: dotenvy::var("SCYLLA_NODES").unwrap().split(',').map(|s| s.to_string()).collect(),
username: dotenvy::var("SCYLLA_USERNAME").unwrap(),
password: dotenvy::var("SCYLLA_PASSWORD").unwrap(),
cached_queries: dotenvy::var("SCYLLA_CACHED_QUERIES").unwrap().parse::<usize>().unwrap(),
keyspace: dotenvy::var("SCYLLA_KEYSPACE").unwrap()
}
}
}
}
The AppState is the main handler for Actix Web maintain the data during the Rust application Runtime.
So, we'll be setting up the Database Connection for Charybdis, which expect a CachingSession to run queries under the ORM.
// file: src/config/app.rs
use std::sync::Arc;
use std::time::Duration;
use dotenvy::dotenv;
use scylla::{CachingSession, Session, SessionBuilder};
use crate::config::config::Config;
#[derive(Debug, Clone)]
pub struct AppState {
pub config: Config,
pub database: Arc<CachingSession>
}
impl AppState {
pub async fn new() -> Self {
dotenv().expect(".env file not found");
let config = Config::new();
let session: Session = SessionBuilder::new()
.known_nodes(config.database.nodes)
.connection_timeout(Duration::from_secs(5))
.user(config.database.username, config.database.password)
.build()
.await
.expect("Connection Refused. Check your credentials and IP linked on the ScyllaDB Cloud.");
session.use_keyspace("leaderboard", false).await.expect("Keyspace not found");
AppState {
config: Config::new(),
database: Arc::new(CachingSession::from(session, config.database.cached_queries))
}
}
}Under the src/config/mod.rs make sure to set the modules as public.
// file: src/config/mod.rs
pub mod app;
pub mod config;The last part of our configuration is the Web server to start our app. Here we need to call Actix HTTP and tell him what we're going to be passing under the AppState.
use actix_web::{App, HttpServer};
use actix_web::web::Data;
use crate::config::app::AppState;
mod config;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let app_data = AppState::new().await;
println!("Web Server Online!");
println!("Listening on http://{}:{}", app_data.config.app.url, app_data.config.app.port);
HttpServer::new(move || {
App::new()
.app_data(Data::new(AppState::new()))
}).bind((
app_data.config.app.url,
app_data.config.app.port.parse::<u16>().unwrap()
))?.run().await
}Note
At the bind() , you should add the Base URL and Port for your server to run.
Great! Now we're good to work on our Migrations!
Our goal is to make the development of this project in a good shape and easier to maintain. Thinking on that, I decided to use Charybdis ScyllaDB ORM because they have a really good migration tool for migration.
/src
├── main.rs
├── config
│ ├── app.rs
│ ├── config.rs
│ └── mod.rs
└─── models <-
├── leaderboard.rs
├── mod.rs
└── submission.rs
At our project we'll be using two models, which will be: submission.rs and leaderboard.rs. At our design, we set the scope for each query which using Charybdis will be modeled like this:
// file: src/models/submssions.rs
use charybdis::macros::charybdis_model;
use charybdis::types::{Frozen, Int, Set, Text, Timestamp, Uuid};
use serde::{Deserialize, Serialize};
#[charybdis_model(
table_name = submissions,
partition_keys = [id],
clustering_keys = [played_at],
global_secondary_indexes = [],
local_secondary_indexes = [],
table_options = "
CLUSTERING ORDER BY (played_at DESC)
",
)]
#[derive(Serialize, Deserialize, Default, Clone, Debug)]
pub struct Submission {
pub id: Uuid,
pub song_id: Text,
pub player_id: Text,
pub modifiers: Frozen<Set<Text>>,
pub score: Int,
pub difficulty: Text,
pub instrument: Text,
pub played_at: Timestamp,
}// file: src/models/leaderboard.rs
use charybdis::macros::charybdis_model;
use charybdis::types::{Frozen, Int, Set, Text, Timestamp, Uuid};
use serde::{Deserialize, Serialize};
#[charybdis_model(
table_name = song_leaderboard,
partition_keys = [song_id, modifiers, difficulty, instrument],
clustering_keys = [player_id, score],
global_secondary_indexes = [],
local_secondary_indexes = [],
table_options = "
CLUSTERING ORDER BY (score DESC, player_id ASC)
",
)]
#[derive(Serialize, Deserialize, Default, Clone, Debug)]
pub struct Leaderboard {
pub id: Uuid,
pub song_id: Text,
pub player_id: Text,
pub modifiers: Frozen<Set<Text>>,
pub score: Int,
pub difficulty: Text,
pub instrument: Text,
pub played_at: Timestamp,
}Charybdis has the charybdis_model macro that allows you to create all the possible queries and create migrations from it.
To finish the setup, make sure to set as public the structs on src/models/mod.rs
// file: src/models/mod.rs
pub mod leaderboard;
pub mod submission;Now it's time! Get your credentials and migrate the your models to a ScyllaDB Cluster using the command below in the project root!
migrate -u scylla -p your-password --host your-node.clusters.scylla.cloud --keyspace leaderboard -d
# Detected 'src/models' directory
# Detected first migration for: submissions Table!
# Running CQL: CREATE TABLE IF NOT EXISTS submissions
# (
# difficulty Text,
# id Uuid,
# instrument Text,
# modifiers Frozen < Set < Text > >,
# played_at Timestamp,
# player_id Text,
# score Int,
# song_id Text,
# PRIMARY KEY ((id) ,played_at)
# )
# WITH
# CLUSTERING ORDER BY (played_at DESC)
# CQL executed successfully! ✅
# Detected first migration for: song_leaderboard Table!
# Running CQL: CREATE TABLE IF NOT EXISTS song_leaderboard
# (
# difficulty Text,
# id Uuid,
# instrument Text,
# modifiers Frozen < Set < Text > >,
# played_at Timestamp,
# player_id Text,
# score Int,
# song_id Text,
# PRIMARY KEY ((song_id, modifiers, difficulty, instrument) ,player_id, score)
# )
# WITH
# CLUSTERING ORDER BY (player_id ASC, score DESC)
# CQL executed successfully! ✅Now we're good to start developing the Web Features!
With everything set-up, the next step is to develop each endpoint using Actix and Charybdis.
/src
├── main.rs
├── config
│ ├── app.rs
│ ├── config.rs
│ └── mod.rs
├── http <-
│ ├── controllers
│ │ ├── leaderboard_controller.rs
│ │ ├── mod.rs
│ │ └── submissions_controller.rs
│ ├── mod.rs
│ └── requests
│ ├── leaderboard_request.rs
│ ├── mod.rs
│ └── submission_request.rs
└─── models
├── leaderboard.rs
├── mod.rs
└── submission.rs
To persist data into our ScyllaDB Database, we need to shape it with the right fields and types. So, so let's create a DTO to store it for us.
// file: src/http/requests/submission_request.rs
use charybdis::types::{Frozen, Int, Set, Text};
use serde::Deserialize;
use validator::Validate;
#[derive(Deserialize, Debug, Validate)]
pub struct SubmissionDTO {
pub song_id: Text,
pub player_id: Text,
pub modifiers: Frozen<Set<Text>>,
pub score: Int,
pub difficulty: Text,
pub instrument: Text,
}At this DTO, we'll be adding the Validate and Deserialize derives since this fields will be received from a JSON Payload.
Add it to the folder module:
//file: src/http/requests/mod.rs
pub mod submission_request;Let's keep our code well structured and create an Model from our DTO using a brand new function from_request().
// file: src/models/submission.rs
use charybdis::macros::charybdis_model;
use charybdis::types::{Frozen, Int, Set, Text, Timestamp, Uuid};
use serde::{Deserialize, Serialize};
use crate::http::requests::submission_request::SubmissionDTO;
#[charybdis_model(
table_name = submissions,
partition_keys = [id],
clustering_keys = [played_at],
global_secondary_indexes = [],
local_secondary_indexes = [],
table_options = "
CLUSTERING ORDER BY (played_at DESC)
",
)]
#[derive(Serialize, Deserialize, Default, Clone, Debug)]
pub struct Submission {
#[serde(default = "Uuid::new_v4")]
pub id: Uuid,
pub song_id: Text,
pub player_id: Text,
pub modifiers: Frozen<Set<Text>>,
pub score: Int,
pub difficulty: Text,
pub instrument: Text,
pub played_at: Timestamp,
}
impl Submission {
pub fn from_request(payload: &SubmissionDTO) -> Self {
Submission {
id: Uuid::new_v4(),
song_id: payload.song_id.to_string(),
player_id: payload.player_id.to_string(),
difficulty: payload.difficulty.to_string(),
instrument: payload.instrument.to_string(),
modifiers: payload.modifiers.to_owned(),
score: payload.score.to_owned(),
played_at: chrono::Utc::now(),
..Default::default()
}
}
}Actix allows you to parse a payload from JSON requests with a specific struct defined previously. So, we'll be using the SubmissionDTO as our field validation, build a model and insert it on the database.
// file: src/http/controllers/submissions_controller.rs
use actix_web::{HttpResponse, post, Responder, Result, web};
use charybdis::operations::Insert;
use serde_json::json;
use validator::Validate;
use crate::config::app::AppState;
use crate::http::requests::submission_request::SubmissionDTO;
use crate::http::SomeError;
use crate::models::submission::Submission;
#[post("/submissions")]
async fn post_submission(
data: web::Data<AppState>,
payload: web::Json<SubmissionDTO>,
) -> Result<impl Responder, SomeError> {
let validated = payload.validate();
let response = match validated {
Ok(_) => {
let submission = Submission::from_request(&payload);
submission.insert().execute(&data.database).await?;
HttpResponse::Ok().json(json!(submission))
}
Err(err) => HttpResponse::BadRequest().json(json!(err)),
};
Ok(response)
}Add it to the folder module:
//file: src/http/requests/mod.rs
pub mod submission_controller;To persist data into our ScyllaDB Database, we need to shape it with the right fields and types. So, so let's create a DTO to store it for us.
// file: src/http/requests/submission_request.rs
use charybdis::types::{Frozen, Int, Set, Text};
use serde::Deserialize;
use validator::Validate;
#[derive(Deserialize, Debug, Validate)]
pub struct SubmissionDTO {
pub song_id: Text,
pub player_id: Text,
pub modifiers: Frozen<Set<Text>>,
pub score: Int,
pub difficulty: Text,
pub instrument: Text,
}At this DTO, we'll be adding the Validate and Deserialize derives since this fields will be received from a JSON Payload.
Let's keep our code well structured and create an Model from our DTO using a brand new function from_request().
// file: src/models/submission.rs
use charybdis::macros::charybdis_model;
use charybdis::types::{Frozen, Int, Set, Text, Timestamp, Uuid};
use serde::{Deserialize, Serialize};
use crate::http::requests::submission_request::SubmissionDTO;
#[charybdis_model(
table_name = submissions,
partition_keys = [id],
clustering_keys = [played_at],
global_secondary_indexes = [],
local_secondary_indexes = [],
table_options = "
CLUSTERING ORDER BY (played_at DESC)
",
)]
#[derive(Serialize, Deserialize, Default, Clone, Debug)]
pub struct Submission {
#[serde(default = "Uuid::new_v4")]
pub id: Uuid,
pub song_id: Text,
pub player_id: Text,
pub modifiers: Frozen<Set<Text>>,
pub score: Int,
pub difficulty: Text,
pub instrument: Text,
pub played_at: Timestamp,
}
impl Submission {
pub fn from_request(payload: &SubmissionDTO) -> Self {
Submission {
id: Uuid::new_v4(),
song_id: payload.song_id.to_string(),
player_id: payload.player_id.to_string(),
difficulty: payload.difficulty.to_string(),
instrument: payload.instrument.to_string(),
modifiers: payload.modifiers.to_owned(),
score: payload.score.to_owned(),
played_at: chrono::Utc::now(),
..Default::default()
}
}
}Actix allows you to parse a payload from JSON requests with a specific struct defined previously. So, we'll be using the SubmissionDTO as our field validation, build a model and insert it on the database.
// file: src/http/controllers/submissions_controller.rs
use actix_web::{HttpResponse, post, Responder, Result, web};
use charybdis::operations::Insert;
use serde_json::json;
use validator::Validate;
use crate::config::app::AppState;
use crate::http::requests::submission_request::SubmissionDTO;
use crate::http::SomeError;
use crate::models::submission::Submission;
#[post("/submissions")]
async fn post_submission(
data: web::Data<AppState>,
payload: web::Json<SubmissionDTO>,
) -> Result<impl Responder, SomeError> {
let validated = payload.validate();
let response = match validated {
Ok(_) => {
let submission = Submission::from_request(&payload);
submission.insert().execute(&data.database).await?;
HttpResponse::Ok().json(json!(submission))
}
Err(err) => HttpResponse::BadRequest().json(json!(err)),
};
Ok(response)
}