From fc8e2a4360952146f722c16a83ddf97d1921dbfc Mon Sep 17 00:00:00 2001 From: Ben Grant Date: Sat, 13 May 2023 13:46:23 +1000 Subject: [PATCH] Set up some routes using new Rust API --- backend/Cargo.lock | 81 ++++++- backend/Cargo.toml | 11 +- backend/adaptors/sql/Cargo.toml | 2 +- backend/adaptors/sql/src/entity/event.rs | 15 +- backend/adaptors/sql/src/entity/person.rs | 13 -- backend/adaptors/sql/src/entity/stats.rs | 10 - backend/adaptors/sql/src/lib.rs | 111 +++++++--- .../{setup_tables.rs => m01_setup_tables.rs} | 2 + backend/adaptors/sql/src/migration/mod.rs | 4 +- backend/common/Cargo.toml | 9 + backend/common/src/adaptor.rs | 29 +++ backend/{data => common}/src/event.rs | 1 + backend/{data => common}/src/lib.rs | 0 backend/{data => common}/src/person.rs | 0 backend/{data => common}/src/stats.rs | 0 backend/data/Cargo.toml | 9 - backend/data/src/adaptor.rs | 37 ---- backend/src/errors.rs | 22 ++ backend/src/main.rs | 36 +++- backend/src/payloads.rs | 43 ++++ backend/src/res/adjectives.json | 201 ++++++++++++++++++ backend/src/res/crabs.json | 47 ++++ backend/src/routes/create_event.rs | 92 ++++++++ backend/src/routes/get_event.rs | 34 +++ backend/src/routes/get_stats.rs | 20 ++ backend/src/routes/mod.rs | 8 + 26 files changed, 703 insertions(+), 134 deletions(-) rename backend/adaptors/sql/src/migration/{setup_tables.rs => m01_setup_tables.rs} (97%) create mode 100644 backend/common/Cargo.toml create mode 100644 backend/common/src/adaptor.rs rename backend/{data => common}/src/event.rs (91%) rename backend/{data => common}/src/lib.rs (100%) rename backend/{data => common}/src/person.rs (100%) rename backend/{data => common}/src/stats.rs (100%) delete mode 100644 backend/data/Cargo.toml delete mode 100644 backend/data/src/adaptor.rs create mode 100644 backend/src/errors.rs create mode 100644 backend/src/payloads.rs create mode 100644 backend/src/res/adjectives.json create mode 100644 backend/src/res/crabs.json create mode 100644 backend/src/routes/create_event.rs create mode 100644 backend/src/routes/get_event.rs create mode 100644 backend/src/routes/get_stats.rs create mode 100644 backend/src/routes/mod.rs diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 6bb802a..f4ada34 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -30,6 +30,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "aho-corasick" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67fc08ce920c31afb70f013dcce1bfc3a3195de6a228474e45e1f145b36f8d04" +dependencies = [ + "memchr", +] + [[package]] name = "aliasable" version = "0.1.3" @@ -492,6 +501,14 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "common" +version = "0.1.0" +dependencies = [ + "async-trait", + "chrono", +] + [[package]] name = "concurrent-queue" version = "2.2.0" @@ -537,11 +554,18 @@ name = "crabfit_backend" version = "1.1.0" dependencies = [ "axum", - "data", + "chrono", + "common", "dotenv", + "punycode", + "rand", + "regex", "serde", + "serde_json", "sql-adaptor", "tokio", + "tracing", + "tracing-subscriber", ] [[package]] @@ -637,14 +661,6 @@ dependencies = [ "syn 2.0.15", ] -[[package]] -name = "data" -version = "0.1.0" -dependencies = [ - "async-trait", - "chrono", -] - [[package]] name = "der" version = "0.5.1" @@ -1305,6 +1321,16 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + [[package]] name = "num-bigint" version = "0.4.3" @@ -1453,6 +1479,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + [[package]] name = "parking" version = "2.1.0" @@ -1649,6 +1681,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "punycode" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e1dcb320d6839f6edb64f7a4a59d39b30480d4d1765b56873f7c858538a5fe" + [[package]] name = "quote" version = "1.0.27" @@ -1723,6 +1761,8 @@ version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af83e617f331cc6ae2da5443c602dfa5af81e517212d9d611a5b3ba1777b5370" dependencies = [ + "aho-corasick", + "memchr", "regex-syntax 0.7.1", ] @@ -2204,7 +2244,7 @@ dependencies = [ "async-std", "async-trait", "chrono", - "data", + "common", "sea-orm", "sea-orm-migration", "serde", @@ -2596,6 +2636,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24eb03ba0eab1fd845050058ce5e616558e8f8d8fca633e6b163fe25c797213a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ddad33d2d10b1ed7eb9d1f518a5674713876e97e5bb9b7345a7984fbb4f922" +dependencies = [ + "lazy_static", + "log", + "tracing-core", ] [[package]] @@ -2605,12 +2657,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a651bc37f915e81f087d86e62a18eec5f79550c7faff886f7090b4ea757c77" dependencies = [ "matchers", + "nu-ansi-term", "once_cell", "regex", "sharded-slab", + "smallvec", "thread_local", "tracing", "tracing-core", + "tracing-log", ] [[package]] @@ -2684,6 +2739,12 @@ dependencies = [ "serde", ] +[[package]] +name = "valuable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" + [[package]] name = "value-bag" version = "1.0.0-alpha.9" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index fefc872..99a15f9 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -4,12 +4,19 @@ version = "1.1.0" edition = "2021" [workspace] -members = ["data", "adaptors/*"] +members = ["common", "adaptors/*"] [dependencies] axum = "0.6.18" serde = { version = "1.0.162", features = ["derive"] } tokio = { version = "1.28.0", features = ["macros", "rt-multi-thread"] } -data = { path = "data" } +common = { path = "common" } sql-adaptor = { path = "adaptors/sql" } dotenv = "0.15.0" +serde_json = "1.0.96" +rand = "0.8.5" +punycode = "0.4.1" +regex = "1.8.1" +tracing = "0.1.37" +tracing-subscriber = "0.3.17" +chrono = "0.4.24" diff --git a/backend/adaptors/sql/Cargo.toml b/backend/adaptors/sql/Cargo.toml index 3fb6ca3..b56724a 100644 --- a/backend/adaptors/sql/Cargo.toml +++ b/backend/adaptors/sql/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" [dependencies] async-trait = "0.1.68" -data = { path = "../../data" } +common = { path = "../../common" } sea-orm = { version = "0.11.3", features = [ "macros", "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", "runtime-tokio-native-tls" ] } serde = { version = "1.0.162", features = [ "derive" ] } async-std = { version = "1", features = ["attributes", "tokio1"] } diff --git a/backend/adaptors/sql/src/entity/event.rs b/backend/adaptors/sql/src/entity/event.rs index 5d3ccd7..72b03e9 100644 --- a/backend/adaptors/sql/src/entity/event.rs +++ b/backend/adaptors/sql/src/entity/event.rs @@ -1,7 +1,5 @@ //! `SeaORM` Entity. Generated by sea-orm-codegen 0.11.3 -use chrono::{DateTime as ChronoDateTime, Utc}; -use data::event::Event; use sea_orm::entity::prelude::*; #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] @@ -11,6 +9,7 @@ pub struct Model { pub id: String, pub name: String, pub created_at: DateTime, + pub visited_at: DateTime, pub times: Json, pub timezone: String, } @@ -28,15 +27,3 @@ impl Related for Entity { } impl ActiveModelBehavior for ActiveModel {} - -impl From for Event { - fn from(value: Model) -> Self { - Self { - id: value.id, - name: value.name, - created_at: ChronoDateTime::::from_utc(value.created_at, Utc), - times: serde_json::from_value(value.times).unwrap_or(vec![]), - timezone: value.timezone, - } - } -} diff --git a/backend/adaptors/sql/src/entity/person.rs b/backend/adaptors/sql/src/entity/person.rs index 82a2664..01f9a37 100644 --- a/backend/adaptors/sql/src/entity/person.rs +++ b/backend/adaptors/sql/src/entity/person.rs @@ -1,7 +1,5 @@ //! `SeaORM` Entity. Generated by sea-orm-codegen 0.11.3 -use chrono::{DateTime as ChronoDateTime, Utc}; -use data::person::Person; use sea_orm::entity::prelude::*; #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] @@ -35,14 +33,3 @@ impl Related for Entity { } impl ActiveModelBehavior for ActiveModel {} - -impl From for Person { - fn from(value: Model) -> Self { - Self { - name: value.name, - password_hash: value.password_hash, - created_at: ChronoDateTime::::from_utc(value.created_at, Utc), - availability: serde_json::from_value(value.availability).unwrap_or(vec![]), - } - } -} diff --git a/backend/adaptors/sql/src/entity/stats.rs b/backend/adaptors/sql/src/entity/stats.rs index 0d8a8c3..6485ff8 100644 --- a/backend/adaptors/sql/src/entity/stats.rs +++ b/backend/adaptors/sql/src/entity/stats.rs @@ -1,6 +1,5 @@ //! `SeaORM` Entity. Generated by sea-orm-codegen 0.11.3 -use data::stats::Stats; use sea_orm::entity::prelude::*; #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] @@ -16,12 +15,3 @@ pub struct Model { pub enum Relation {} impl ActiveModelBehavior for ActiveModel {} - -impl From for Stats { - fn from(value: Model) -> Self { - Self { - event_count: value.event_count, - person_count: value.person_count, - } - } -} diff --git a/backend/adaptors/sql/src/lib.rs b/backend/adaptors/sql/src/lib.rs index f396c1b..ac34104 100644 --- a/backend/adaptors/sql/src/lib.rs +++ b/backend/adaptors/sql/src/lib.rs @@ -1,7 +1,8 @@ use std::{env, error::Error}; use async_trait::async_trait; -use data::{ +use chrono::{DateTime as ChronoDateTime, Utc}; +use common::{ adaptor::Adaptor, event::{Event, EventDeletion}, person::Person, @@ -10,54 +11,48 @@ use data::{ use entity::{event, person, stats}; use migration::{Migrator, MigratorTrait}; use sea_orm::{ + strum::Display, ActiveModelTrait, ActiveValue::{NotSet, Set}, ColumnTrait, Database, DatabaseConnection, DbErr, EntityTrait, ModelTrait, QueryFilter, - TransactionTrait, TryIntoModel, + TransactionError, TransactionTrait, TryIntoModel, }; use serde_json::json; mod entity; mod migration; -pub struct PostgresAdaptor { +pub struct SqlAdaptor { db: DatabaseConnection, } #[async_trait] -impl Adaptor for PostgresAdaptor { - async fn new() -> Self { - let connection_string = env::var("DATABASE_URL").unwrap(); +impl Adaptor for SqlAdaptor { + type Error = SqlAdaptorError; - // Connect to the database - let db = Database::connect(&connection_string).await.unwrap(); - println!("Connected to database at {}", connection_string); - - // Setup tables - Migrator::up(&db, None).await.unwrap(); - - Self { db } + async fn get_stats(&self) -> Result { + let stats_row = get_stats_row(&self.db).await?; + Ok(Stats { + event_count: stats_row.event_count.unwrap(), + person_count: stats_row.person_count.unwrap(), + }) } - async fn get_stats(&self) -> Result> { - Ok(get_stats_row(&self.db).await?.try_into_model()?.into()) - } - - async fn increment_stat_event_count(&self) -> Result> { + async fn increment_stat_event_count(&self) -> Result { let mut current_stats = get_stats_row(&self.db).await?; current_stats.event_count = Set(current_stats.event_count.unwrap() + 1); Ok(current_stats.save(&self.db).await?.event_count.unwrap()) } - async fn increment_stat_person_count(&self) -> Result> { + async fn increment_stat_person_count(&self) -> Result { let mut current_stats = get_stats_row(&self.db).await?; current_stats.person_count = Set(current_stats.person_count.unwrap() + 1); Ok(current_stats.save(&self.db).await?.person_count.unwrap()) } - async fn get_people(&self, event_id: String) -> Result>, Box> { + async fn get_people(&self, event_id: String) -> Result>, Self::Error> { // TODO: optimize into one query let event_row = event::Entity::find_by_id(event_id).one(&self.db).await?; @@ -75,11 +70,7 @@ impl Adaptor for PostgresAdaptor { }) } - async fn upsert_person( - &self, - event_id: String, - person: Person, - ) -> Result> { + async fn upsert_person(&self, event_id: String, person: Person) -> Result { Ok(person::ActiveModel { name: Set(person.name), password_hash: Set(person.password_hash), @@ -93,28 +84,29 @@ impl Adaptor for PostgresAdaptor { .into()) } - async fn get_event(&self, id: String) -> Result, Box> { + async fn get_event(&self, id: String) -> Result, Self::Error> { Ok(event::Entity::find_by_id(id) .one(&self.db) .await? .map(|model| model.into())) } - async fn create_event(&self, event: Event) -> Result> { + async fn create_event(&self, event: Event) -> Result { Ok(event::ActiveModel { id: Set(event.id), name: Set(event.name), created_at: Set(event.created_at.naive_utc()), + visited_at: Set(event.visited_at.naive_utc()), times: Set(serde_json::to_value(event.times).unwrap_or(json!([]))), timezone: Set(event.timezone), } - .save(&self.db) + .insert(&self.db) .await? .try_into_model()? .into()) } - async fn delete_event(&self, id: String) -> Result> { + async fn delete_event(&self, id: String) -> Result { let event_id = id.clone(); let person_count = self .db @@ -141,6 +133,7 @@ impl Adaptor for PostgresAdaptor { // Get the current stats as an ActiveModel async fn get_stats_row(db: &DatabaseConnection) -> Result { let current_stats = stats::Entity::find().one(db).await?; + Ok(match current_stats { Some(model) => model.into(), None => stats::ActiveModel { @@ -150,3 +143,61 @@ async fn get_stats_row(db: &DatabaseConnection) -> Result Self { + let connection_string = env::var("DATABASE_URL").unwrap(); + + // Connect to the database + let db = Database::connect(&connection_string).await.unwrap(); + println!("Connected to database at {}", connection_string); + + // Setup tables + Migrator::up(&db, None).await.unwrap(); + + Self { db } + } +} + +impl From for Event { + fn from(value: event::Model) -> Self { + Self { + id: value.id, + name: value.name, + created_at: ChronoDateTime::::from_utc(value.created_at, Utc), + visited_at: ChronoDateTime::::from_utc(value.visited_at, Utc), + times: serde_json::from_value(value.times).unwrap_or(vec![]), + timezone: value.timezone, + } + } +} + +impl From for Person { + fn from(value: person::Model) -> Self { + Self { + name: value.name, + password_hash: value.password_hash, + created_at: ChronoDateTime::::from_utc(value.created_at, Utc), + availability: serde_json::from_value(value.availability).unwrap_or(vec![]), + } + } +} + +#[derive(Display, Debug)] +pub enum SqlAdaptorError { + DbErr(DbErr), + TransactionError(TransactionError), +} + +impl Error for SqlAdaptorError {} + +impl From for SqlAdaptorError { + fn from(value: DbErr) -> Self { + Self::DbErr(value) + } +} +impl From> for SqlAdaptorError { + fn from(value: TransactionError) -> Self { + Self::TransactionError(value) + } +} diff --git a/backend/adaptors/sql/src/migration/setup_tables.rs b/backend/adaptors/sql/src/migration/m01_setup_tables.rs similarity index 97% rename from backend/adaptors/sql/src/migration/setup_tables.rs rename to backend/adaptors/sql/src/migration/m01_setup_tables.rs index 5ce5478..1285b8c 100644 --- a/backend/adaptors/sql/src/migration/setup_tables.rs +++ b/backend/adaptors/sql/src/migration/m01_setup_tables.rs @@ -36,6 +36,7 @@ impl MigrationTrait for Migration { .col(ColumnDef::new(Event::Id).string().not_null().primary_key()) .col(ColumnDef::new(Event::Name).string().not_null()) .col(ColumnDef::new(Event::CreatedAt).timestamp().not_null()) + .col(ColumnDef::new(Event::VisitedAt).timestamp().not_null()) .col(ColumnDef::new(Event::Times).json().not_null()) .col(ColumnDef::new(Event::Timezone).string().not_null()) .to_owned(), @@ -105,6 +106,7 @@ enum Event { Id, Name, CreatedAt, + VisitedAt, Times, Timezone, } diff --git a/backend/adaptors/sql/src/migration/mod.rs b/backend/adaptors/sql/src/migration/mod.rs index 08be57d..08464e8 100644 --- a/backend/adaptors/sql/src/migration/mod.rs +++ b/backend/adaptors/sql/src/migration/mod.rs @@ -1,12 +1,12 @@ pub use sea_orm_migration::prelude::*; -mod setup_tables; +mod m01_setup_tables; pub struct Migrator; #[async_trait::async_trait] impl MigratorTrait for Migrator { fn migrations() -> Vec> { - vec![Box::new(setup_tables::Migration)] + vec![Box::new(m01_setup_tables::Migration)] } } diff --git a/backend/common/Cargo.toml b/backend/common/Cargo.toml new file mode 100644 index 0000000..5f96e36 --- /dev/null +++ b/backend/common/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "common" +description = "Shared structs and traits for the data storage and transfer of Crab Fit" +version = "0.1.0" +edition = "2021" + +[dependencies] +async-trait = "0.1.68" +chrono = "0.4.24" diff --git a/backend/common/src/adaptor.rs b/backend/common/src/adaptor.rs new file mode 100644 index 0000000..55f3544 --- /dev/null +++ b/backend/common/src/adaptor.rs @@ -0,0 +1,29 @@ +use std::error::Error; + +use async_trait::async_trait; + +use crate::{ + event::{Event, EventDeletion}, + person::Person, + stats::Stats, +}; + +/// Data storage adaptor, all methods on an adaptor can return an error if +/// something goes wrong, or potentially None if the data requested was not found. +#[async_trait] +pub trait Adaptor: Send + Sync { + type Error: Error; + + async fn get_stats(&self) -> Result; + async fn increment_stat_event_count(&self) -> Result; + async fn increment_stat_person_count(&self) -> Result; + + async fn get_people(&self, event_id: String) -> Result>, Self::Error>; + async fn upsert_person(&self, event_id: String, person: Person) -> Result; + + async fn get_event(&self, id: String) -> Result, Self::Error>; + async fn create_event(&self, event: Event) -> Result; + + /// Delete an event as well as all related people + async fn delete_event(&self, id: String) -> Result; +} diff --git a/backend/data/src/event.rs b/backend/common/src/event.rs similarity index 91% rename from backend/data/src/event.rs rename to backend/common/src/event.rs index 09129ee..4584183 100644 --- a/backend/data/src/event.rs +++ b/backend/common/src/event.rs @@ -4,6 +4,7 @@ pub struct Event { pub id: String, pub name: String, pub created_at: DateTime, + pub visited_at: DateTime, pub times: Vec, pub timezone: String, } diff --git a/backend/data/src/lib.rs b/backend/common/src/lib.rs similarity index 100% rename from backend/data/src/lib.rs rename to backend/common/src/lib.rs diff --git a/backend/data/src/person.rs b/backend/common/src/person.rs similarity index 100% rename from backend/data/src/person.rs rename to backend/common/src/person.rs diff --git a/backend/data/src/stats.rs b/backend/common/src/stats.rs similarity index 100% rename from backend/data/src/stats.rs rename to backend/common/src/stats.rs diff --git a/backend/data/Cargo.toml b/backend/data/Cargo.toml deleted file mode 100644 index 1a3a21f..0000000 --- a/backend/data/Cargo.toml +++ /dev/null @@ -1,9 +0,0 @@ -[package] -name = "data" -description = "Structs and traits for the data storage and transfer of Crab Fit" -version = "0.1.0" -edition = "2021" - -[dependencies] -async-trait = "0.1.68" -chrono = "0.4.24" diff --git a/backend/data/src/adaptor.rs b/backend/data/src/adaptor.rs deleted file mode 100644 index 9064453..0000000 --- a/backend/data/src/adaptor.rs +++ /dev/null @@ -1,37 +0,0 @@ -use std::error::Error; - -use async_trait::async_trait; - -use crate::{ - event::{Event, EventDeletion}, - person::Person, - stats::Stats, -}; - -/// Data storage adaptor, all methods on an adaptor can return an error if -/// something goes wrong, or potentially None if the data requested was not found. -#[async_trait] -pub trait Adaptor { - /// Creates a new adaptor and performs all setup required - /// - /// # Panics - /// If an error occurs while setting up the adaptor - async fn new() -> Self; - - async fn get_stats(&self) -> Result>; - async fn increment_stat_event_count(&self) -> Result>; - async fn increment_stat_person_count(&self) -> Result>; - - async fn get_people(&self, event_id: String) -> Result>, Box>; - async fn upsert_person( - &self, - event_id: String, - person: Person, - ) -> Result>; - - async fn get_event(&self, id: String) -> Result, Box>; - async fn create_event(&self, event: Event) -> Result>; - - /// Delete an event as well as all related people - async fn delete_event(&self, id: String) -> Result>; -} diff --git a/backend/src/errors.rs b/backend/src/errors.rs new file mode 100644 index 0000000..f4cb7f8 --- /dev/null +++ b/backend/src/errors.rs @@ -0,0 +1,22 @@ +use axum::{http::StatusCode, response::IntoResponse}; +use common::adaptor::Adaptor; + +pub enum ApiError { + AdaptorError(A::Error), + NotFound, + // NotAuthorized, +} + +// Define what the error types above should return +impl IntoResponse for ApiError { + fn into_response(self) -> axum::response::Response { + match self { + ApiError::AdaptorError(e) => { + tracing::error!(?e); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + ApiError::NotFound => StatusCode::NOT_FOUND.into_response(), + // ApiError::NotAuthorized => StatusCode::UNAUTHORIZED.into_response(), + } + } +} diff --git a/backend/src/main.rs b/backend/src/main.rs index 5ff6304..2864391 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,8 +1,17 @@ -use std::net::SocketAddr; +use std::{net::SocketAddr, sync::Arc}; -use axum::{routing::get, Router, Server}; -use data::adaptor::Adaptor; -use sql_adaptor::PostgresAdaptor; +use axum::{ + extract, + routing::{get, post}, + Router, Server, +}; +use routes::*; +use sql_adaptor::SqlAdaptor; +use tokio::sync::Mutex; + +mod errors; +mod payloads; +mod routes; #[cfg(debug_assertions)] const MODE: &str = "debug"; @@ -10,14 +19,29 @@ const MODE: &str = "debug"; #[cfg(not(debug_assertions))] const MODE: &str = "release"; +pub struct ApiState { + adaptor: A, +} + +pub type State = extract::State>>>; + #[tokio::main] async fn main() { + tracing_subscriber::fmt::init(); + // Load env dotenv::dotenv().ok(); - PostgresAdaptor::new().await; + let shared_state = Arc::new(Mutex::new(ApiState { + adaptor: SqlAdaptor::new().await, + })); - let app = Router::new().route("/", get(get_root)); + let app = Router::new() + .route("/", get(get_root)) + .route("/stats", get(get_stats)) + .route("/event/:event_id", get(get_event)) + .route("/event", post(create_event)) + .with_state(shared_state); let addr = SocketAddr::from(([127, 0, 0, 1], 3000)); diff --git a/backend/src/payloads.rs b/backend/src/payloads.rs new file mode 100644 index 0000000..3a44398 --- /dev/null +++ b/backend/src/payloads.rs @@ -0,0 +1,43 @@ +use axum::Json; +use common::event::Event; +use serde::{Deserialize, Serialize}; + +use crate::errors::ApiError; + +pub type ApiResult = Result, ApiError>; + +#[derive(Deserialize)] +pub struct EventInput { + pub name: String, + pub times: Vec, + pub timezone: String, +} + +#[derive(Serialize)] +pub struct EventResponse { + pub id: String, + pub name: String, + pub times: Vec, + pub timezone: String, + pub created: i64, +} + +impl From for EventResponse { + fn from(value: Event) -> Self { + Self { + id: value.id, + name: value.name, + times: value.times, + timezone: value.timezone, + created: value.created_at.timestamp(), + } + } +} + +#[derive(Serialize)] +#[serde(rename_all(serialize = "camelCase"))] +pub struct StatsResponse { + pub event_count: i32, + pub person_count: i32, + pub version: String, +} diff --git a/backend/src/res/adjectives.json b/backend/src/res/adjectives.json new file mode 100644 index 0000000..48860c5 --- /dev/null +++ b/backend/src/res/adjectives.json @@ -0,0 +1,201 @@ +[ + "Adorable", + "Adventurous", + "Aggressive", + "Agreeable", + "Alert", + "Alive", + "Amused", + "Angry", + "Annoyed", + "Annoying", + "Anxious", + "Arrogant", + "Ashamed", + "Attractive", + "Average", + "Beautiful", + "Better", + "Bewildered", + "Blue", + "Blushing", + "Bored", + "Brainy", + "Brave", + "Breakable", + "Bright", + "Busy", + "Calm", + "Careful", + "Cautious", + "Charming", + "Cheerful", + "Clean", + "Clear", + "Clever", + "Cloudy", + "Clumsy", + "Colorful", + "Comfortable", + "Concerned", + "Confused", + "Cooperative", + "Courageous", + "Crazy", + "Creepy", + "Crowded", + "Curious", + "Cute", + "Dangerous", + "Dark", + "Defiant", + "Delightful", + "Depressed", + "Determined", + "Different", + "Difficult", + "Disgusted", + "Distinct", + "Disturbed", + "Dizzy", + "Doubtful", + "Drab", + "Dull", + "Eager", + "Easy", + "Elated", + "Elegant", + "Embarrassed", + "Enchanting", + "Encouraging", + "Energetic", + "Enthusiastic", + "Envious", + "Evil", + "Excited", + "Expensive", + "Exuberant", + "Fair", + "Faithful", + "Famous", + "Fancy", + "Fantastic", + "Fierce", + "Fine", + "Foolish", + "Fragile", + "Frail", + "Frantic", + "Friendly", + "Frightened", + "Funny", + "Gentle", + "Gifted", + "Glamorous", + "Gleaming", + "Glorious", + "Good", + "Gorgeous", + "Graceful", + "Grumpy", + "Handsome", + "Happy", + "Healthy", + "Helpful", + "Hilarious", + "Homely", + "Hungry", + "Important", + "Impossible", + "Inexpensive", + "Innocent", + "Inquisitive", + "Itchy", + "Jealous", + "Jittery", + "Jolly", + "Joyous", + "Kind", + "Lazy", + "Light", + "Lively", + "Lonely", + "Long", + "Lovely", + "Lucky", + "Magnificent", + "Misty", + "Modern", + "Motionless", + "Muddy", + "Mushy", + "Mysterious", + "Naughty", + "Nervous", + "Nice", + "Nutty", + "Obedient", + "Obnoxious", + "Odd", + "Old-fashioned", + "Open", + "Outrageous", + "Outstanding", + "Panicky", + "Perfect", + "Plain", + "Pleasant", + "Poised", + "Powerful", + "Precious", + "Prickly", + "Proud", + "Puzzled", + "Quaint", + "Real", + "Relieved", + "Scary", + "Selfish", + "Shiny", + "Shy", + "Silly", + "Sleepy", + "Smiling", + "Smoggy", + "Sparkling", + "Splendid", + "Spotless", + "Stormy", + "Strange", + "Successful", + "Super", + "Talented", + "Tame", + "Tasty", + "Tender", + "Tense", + "Terrible", + "Thankful", + "Thoughtful", + "Thoughtless", + "Tired", + "Tough", + "Uninterested", + "Unsightly", + "Unusual", + "Upset", + "Uptight", + "Vast", + "Victorious", + "Vivacious", + "Wandering", + "Weary", + "Wicked", + "Wide-eyed", + "Wild", + "Witty", + "Worried", + "Worrisome", + "Zany", + "Zealous" +] diff --git a/backend/src/res/crabs.json b/backend/src/res/crabs.json new file mode 100644 index 0000000..62bbe15 --- /dev/null +++ b/backend/src/res/crabs.json @@ -0,0 +1,47 @@ +[ + "American Horseshoe", + "Atlantic Ghost", + "Baja Elbow", + "Big Claw Purple Hermit", + "Coldwater Mole", + "Cuata Swim", + "Deepwater Frog", + "Dwarf Teardrop", + "Elegant Hermit", + "Flat Spider", + "Ghost", + "Globe Purse", + "Green", + "Halloween", + "Harbor Spider", + "Inflated Spider", + "Left Clawed Hermit", + "Lumpy Claw", + "Magnificent Hermit", + "Mexican Spider", + "Mouthless Land", + "Northern Lemon Rock", + "Pacific Arrow", + "Pacific Mole", + "Paco Box", + "Panamic Spider", + "Purple Shore", + "Red Rock", + "Red Swim", + "Red-leg Hermit", + "Robust Swim", + "Rough Swim", + "Sand Swim", + "Sally Lightfoot", + "Shamed-face Box", + "Shamed-face Heart Box", + "Shell", + "Small Arched Box", + "Southern Kelp", + "Spotted Box", + "Striated Mole", + "Striped Shore", + "Tropical Mole", + "Walking Rock", + "Yellow Shore" +] diff --git a/backend/src/routes/create_event.rs b/backend/src/routes/create_event.rs new file mode 100644 index 0000000..74bc66f --- /dev/null +++ b/backend/src/routes/create_event.rs @@ -0,0 +1,92 @@ +use axum::{extract, Json}; +use common::{adaptor::Adaptor, event::Event}; +use rand::{seq::SliceRandom, thread_rng, Rng}; +use regex::Regex; + +use crate::{ + errors::ApiError, + payloads::{ApiResult, EventInput, EventResponse}, + State, +}; + +pub async fn create_event( + extract::State(state): State, + Json(input): Json, +) -> ApiResult { + let adaptor = &state.lock().await.adaptor; + + // Get the current timestamp + let now = chrono::offset::Utc::now(); + + // Generate a name if none provided + let name = match input.name.trim() { + "" => generate_name(), + x => x.to_string(), + }; + + // Generate an ID + let mut id = generate_id(&name); + + // Check the ID doesn't already exist + while (adaptor + .get_event(id.clone()) + .await + .map_err(ApiError::AdaptorError)?) + .is_some() + { + id = generate_id(&name); + } + + let event = adaptor + .create_event(Event { + id, + name, + created_at: now, + visited_at: now, + times: input.times, + timezone: input.timezone, + }) + .await + .map_err(ApiError::AdaptorError)?; + + // Update stats + adaptor + .increment_stat_event_count() + .await + .map_err(ApiError::AdaptorError)?; + + Ok(Json(event.into())) +} + +// Generate a random name based on an adjective and a crab species +fn generate_name() -> String { + let adjectives: Vec = + serde_json::from_slice(include_bytes!("../res/adjectives.json")).unwrap(); + let crabs: Vec = serde_json::from_slice(include_bytes!("../res/crabs.json")).unwrap(); + + format!( + "{} {} Crab", + adjectives.choose(&mut thread_rng()).unwrap(), + crabs.choose(&mut thread_rng()).unwrap() + ) +} + +// Generate a slug for the crab fit +fn generate_id(name: &str) -> String { + let mut id = encode_name(name.to_string()); + if id.replace('-', "").is_empty() { + id = encode_name(generate_name()); + } + let number = thread_rng().gen_range(100000..=999999); + format!("{}-{}", id, number) +} + +// Use punycode to encode the name +fn encode_name(name: String) -> String { + let pc = punycode::encode(&name.trim().to_lowercase()) + .unwrap_or(String::from("")) + .trim() + .replace(|c: char| !c.is_ascii_alphanumeric() && c != ' ', ""); + let re = Regex::new(r"\s+").unwrap(); + re.replace_all(&pc, "-").to_string() +} diff --git a/backend/src/routes/get_event.rs b/backend/src/routes/get_event.rs new file mode 100644 index 0000000..98ad2a6 --- /dev/null +++ b/backend/src/routes/get_event.rs @@ -0,0 +1,34 @@ +use axum::{ + extract::{self, Path}, + Json, +}; +use common::adaptor::Adaptor; + +use crate::{ + errors::ApiError, + payloads::{ApiResult, EventResponse}, + State, +}; + +pub async fn get_event( + extract::State(state): State, + Path(event_id): Path, +) -> ApiResult { + let adaptor = &state.lock().await.adaptor; + + let event = adaptor + .get_event(event_id) + .await + .map_err(ApiError::AdaptorError)?; + + match event { + Some(event) => Ok(Json(EventResponse { + id: event.id, + name: event.name, + times: event.times, + timezone: event.timezone, + created: event.created_at.timestamp(), + })), + None => Err(ApiError::NotFound), + } +} diff --git a/backend/src/routes/get_stats.rs b/backend/src/routes/get_stats.rs new file mode 100644 index 0000000..71a0d81 --- /dev/null +++ b/backend/src/routes/get_stats.rs @@ -0,0 +1,20 @@ +use axum::{extract, Json}; +use common::adaptor::Adaptor; + +use crate::{ + errors::ApiError, + payloads::{ApiResult, StatsResponse}, + State, +}; + +pub async fn get_stats(extract::State(state): State) -> ApiResult { + let adaptor = &state.lock().await.adaptor; + + let stats = adaptor.get_stats().await.map_err(ApiError::AdaptorError)?; + + Ok(Json(StatsResponse { + event_count: stats.event_count, + person_count: stats.person_count, + version: env!("CARGO_PKG_VERSION").to_string(), + })) +} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs new file mode 100644 index 0000000..f1fb456 --- /dev/null +++ b/backend/src/routes/mod.rs @@ -0,0 +1,8 @@ +mod get_event; +pub use get_event::get_event; + +mod get_stats; +pub use get_stats::get_stats; + +mod create_event; +pub use create_event::create_event;