Include documentation for API and subcrates

This commit is contained in:
Ben Grant 2023-05-15 23:51:12 +10:00
parent dfdfc24ee5
commit 3e770a337b
40 changed files with 89 additions and 9 deletions

2
api/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
target
.env

3973
api/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

37
api/Cargo.toml Normal file
View file

@ -0,0 +1,37 @@
[package]
name = "crabfit-api"
description = "API for Crab Fit"
license = "GPL-3.0-only"
version = "2.0.0"
edition = "2021"
[features]
sql-adaptor = []
datastore-adaptor = []
[workspace]
members = ["common", "adaptors/*"]
[dependencies]
axum = { version = "0.6.18", features = ["headers"] }
serde = { version = "1.0.162", features = ["derive"] }
tokio = { version = "1.28.0", features = ["macros", "rt-multi-thread"] }
common = { path = "common" }
sql-adaptor = { path = "adaptors/sql" }
datastore-adaptor = { path = "adaptors/datastore" }
memory-adaptor = { path = "adaptors/memory" }
dotenvy = "0.15.7"
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"
bcrypt = "0.14.0"
tower-http = { version = "0.4.0", features = ["cors", "trace"] }
tower_governor = "0.0.4"
tower = "0.4.13"
utoipa = { version = "3.3.0", features = ["axum_extras", "preserve_order"] }
utoipa-swagger-ui = { version = "3.1.3", features = ["axum"] }
base64 = "0.21.0"

26
api/README.md Normal file
View file

@ -0,0 +1,26 @@
# Crab Fit API
This is the API for Crab Fit, written in Rust. It uses the [axum](https://crates.io/crates/axum) framework to run a HTTP server, and supports multiple storage adaptors.
## API docs
OpenAPI compatible API docs are generated using [utoipa](https://crates.io/crates/utoipa). You can visit them at [https://api.crab.fit/docs](https://api.crab.fit/docs).
## Storage adaptors
| Adaptor | Works with |
| ------- | ---------- |
| `memory-adaptor` | Stores data in memory |
| `sql-adaptor` | Postgres, MySQL, SQLite |
| `datastore-adaptor` | Google Datastore |
To choose an adaptor, specify it in the `features` when compiling, e.g. `cargo run --features sql-adaptor`.
Some adaptors require environment variables to be set. You can specify them in a `.env` file and they'll be loaded in using [dotenvy](https://crates.io/crates/dotenvy). See a specific adaptor's readme for more information.
> **Note**
> `memory-adaptor` is the default if no features are specified. Ensure you specify a different adaptor when deploying.
### Adding an adaptor
See [adding an adaptor](adaptors/README.md#adding-an-adaptor) in the adaptors readme.

21
api/adaptors/README.md Normal file
View file

@ -0,0 +1,21 @@
# Crab Fit Storage Adaptors
This directory contains sub-crates that connect Crab Fit to a database of some sort. For a list of available adaptors, see the [api readme](../README.md).
## Adding an adaptor
The suggested flow is copying an existing adaptor, such as `memory`, and altering the code to work with your chosen database.
Note, you will need to have the following crates as dependencies in your adaptor:
- `common`<br>Includes a trait for implementing your adaptor, as well as structs your adaptor needs to return.
- `async-trait`<br>Required because the trait from `common` uses async functions, make sure you include `#[async_trait]` above your trait implementation.
Once you've created the adaptor, you'll need to make sure it's included as a dependency in the root [`Cargo.toml`](../Cargo.toml), and add a feature flag with the same name. Make sure you also document the new adaptor in the [api readme](../README.md).
Finally, add a new version of the `create_adaptor` function in the [`adaptors.rs`](../src/adaptors.rs) file that will only compile if the specific feature flag you added is set. Don't forget to add a `not` version of the feature to the default memory adaptor function at the bottom of the file.
## FAQ
Why is it spelt "adaptor" and not "adapter"?
> The maintainer lives in Australia, where it's usually spelt "adaptor" 😎

View file

@ -0,0 +1,14 @@
[package]
name = "datastore-adaptor"
version = "0.1.0"
edition = "2021"
[dependencies]
async-trait = "0.1.68"
chrono = "0.4.24"
common = { path = "../../common" }
# Uses custom version of google-cloud that has support for NULL values
google-cloud = { git = "https://github.com/GRA0007/google-cloud-rs.git", features = ["datastore", "derive"] }
serde = "1.0.163"
serde_json = "1.0.96"
tokio = { version = "1.28.1", features = ["rt-multi-thread"] }

View file

@ -0,0 +1,13 @@
# Google Datastore Adaptor
This adaptor works with [Google Cloud Datastore](https://cloud.google.com/datastore). Please note that it's compatible with Firestore in Datastore mode, but not with Firestore.
## Environment
To use this adaptor, make sure you have the `GCP_CREDENTIALS` environment variable set to your service account credentials in JSON format. See [this page](https://developers.google.com/workspace/guides/create-credentials#service-account) for info on setting up a service account and generating credentials.
Example:
```env
GCP_CREDENTIALS='{"type":"service_account","project_id":"my-project"}'
```

View file

@ -0,0 +1,300 @@
use std::{env, error::Error, fmt::Display};
use async_trait::async_trait;
use chrono::{DateTime, NaiveDateTime, Utc};
use common::{
adaptor::Adaptor,
event::{Event, EventDeletion},
person::Person,
stats::Stats,
};
use google_cloud::{
authorize::ApplicationCredentials,
datastore::{Client, Filter, FromValue, IntoValue, Key, Query},
};
use tokio::sync::Mutex;
pub struct DatastoreAdaptor {
client: Mutex<Client>,
}
// Keys
const STATS_KIND: &str = "Stats";
const EVENT_KIND: &str = "Event";
const PERSON_KIND: &str = "Person";
const STATS_EVENTS_ID: &str = "eventCount";
const STATS_PEOPLE_ID: &str = "personCount";
#[async_trait]
impl Adaptor for DatastoreAdaptor {
type Error = DatastoreAdaptorError;
async fn get_stats(&self) -> Result<Stats, Self::Error> {
let mut client = self.client.lock().await;
let event_key = Key::new(STATS_KIND).id(STATS_EVENTS_ID);
let event_stats: DatastoreStats = client.get(event_key).await?.unwrap_or_default();
let person_key = Key::new(STATS_KIND).id(STATS_PEOPLE_ID);
let person_stats: DatastoreStats = client.get(person_key).await?.unwrap_or_default();
Ok(Stats {
event_count: event_stats.value,
person_count: person_stats.value,
})
}
async fn increment_stat_event_count(&self) -> Result<i64, Self::Error> {
let mut client = self.client.lock().await;
let key = Key::new(STATS_KIND).id(STATS_EVENTS_ID);
let mut event_stats: DatastoreStats = client.get(key.clone()).await?.unwrap_or_default();
event_stats.value += 1;
client.put((key, event_stats.clone())).await?;
Ok(event_stats.value)
}
async fn increment_stat_person_count(&self) -> Result<i64, Self::Error> {
let mut client = self.client.lock().await;
let key = Key::new(STATS_KIND).id(STATS_PEOPLE_ID);
let mut person_stats: DatastoreStats = client.get(key.clone()).await?.unwrap_or_default();
person_stats.value += 1;
client.put((key, person_stats.clone())).await?;
Ok(person_stats.value)
}
async fn get_people(&self, event_id: String) -> Result<Option<Vec<Person>>, Self::Error> {
let mut client = self.client.lock().await;
// Check the event exists
if client
.get::<DatastoreEvent, _>(Key::new(EVENT_KIND).id(event_id.clone()))
.await?
.is_none()
{
return Ok(None);
}
Ok(Some(
client
.query(
Query::new(PERSON_KIND)
.filter(Filter::Equal("eventId".into(), event_id.into_value())),
)
.await?
.into_iter()
.filter_map(|entity| {
DatastorePerson::from_value(entity.properties().clone())
.ok()
.map(|ds_person| ds_person.into())
})
.collect(),
))
}
async fn upsert_person(&self, event_id: String, person: Person) -> Result<Person, Self::Error> {
let mut client = self.client.lock().await;
// Check if person exists
let existing_person = client
.query(
Query::new(PERSON_KIND)
.filter(Filter::Equal(
"eventId".into(),
event_id.clone().into_value(),
))
.filter(Filter::Equal(
"name".into(),
person.name.clone().into_value(),
)),
)
.await?;
let mut key = Key::new(PERSON_KIND);
if let Some(entity) = existing_person.first() {
key = entity.key().clone();
}
client
.put((key, DatastorePerson::from_person(person.clone(), event_id)))
.await?;
Ok(person)
}
async fn get_event(&self, id: String) -> Result<Option<Event>, Self::Error> {
let mut client = self.client.lock().await;
let key = Key::new(EVENT_KIND).id(id.clone());
let existing_event = client.get::<DatastoreEvent, _>(key.clone()).await?;
// Mark as visited if it exists
if let Some(mut event) = existing_event.clone() {
event.visited = Utc::now().timestamp();
client.put((key, event)).await?;
}
Ok(existing_event.map(|e| e.to_event(id)))
}
async fn create_event(&self, event: Event) -> Result<Event, Self::Error> {
let mut client = self.client.lock().await;
let key = Key::new(EVENT_KIND).id(event.id.clone());
let ds_event: DatastoreEvent = event.clone().into();
client.put((key, ds_event)).await?;
Ok(event)
}
async fn delete_event(&self, id: String) -> Result<EventDeletion, Self::Error> {
let mut client = self.client.lock().await;
let mut keys_to_delete: Vec<Key> = client
.query(
Query::new(PERSON_KIND)
.filter(Filter::Equal("eventId".into(), id.clone().into_value())),
)
.await?
.iter()
.map(|entity| entity.key().clone())
.collect();
let person_count = keys_to_delete.len().try_into().unwrap();
keys_to_delete.insert(0, Key::new(EVENT_KIND).id(id.clone()));
client.delete_all(keys_to_delete).await?;
Ok(EventDeletion { id, person_count })
}
}
impl DatastoreAdaptor {
pub async fn new() -> Self {
// Load credentials
let credentials: ApplicationCredentials = serde_json::from_str(
&env::var("GCP_CREDENTIALS").expect("Expected GCP_CREDENTIALS environment variable"),
)
.expect("GCP_CREDENTIALS environment variable is not valid JSON");
// Connect to datastore
let client = Client::from_credentials(credentials.project_id.clone(), credentials.clone())
.await
.expect("Failed to setup datastore client");
let client = Mutex::new(client);
println!(
"🎛️ Connected to datastore in project {}",
credentials.project_id
);
Self { client }
}
}
#[derive(FromValue, IntoValue, Default, Clone)]
struct DatastoreStats {
value: i64,
}
#[derive(FromValue, IntoValue, Clone)]
struct DatastoreEvent {
name: String,
created: i64,
visited: i64,
times: Vec<String>,
timezone: String,
}
#[derive(FromValue, IntoValue)]
#[allow(non_snake_case)]
struct DatastorePerson {
name: String,
password: Option<String>,
created: i64,
eventId: String,
availability: Vec<String>,
}
impl From<DatastorePerson> for Person {
fn from(value: DatastorePerson) -> Self {
Self {
name: value.name,
password_hash: value.password,
created_at: unix_to_date(value.created),
availability: value.availability,
}
}
}
impl DatastorePerson {
fn from_person(person: Person, event_id: String) -> Self {
Self {
name: person.name,
password: person.password_hash,
created: person.created_at.timestamp(),
eventId: event_id,
availability: person.availability,
}
}
}
impl From<Event> for DatastoreEvent {
fn from(value: Event) -> Self {
Self {
name: value.name,
created: value.created_at.timestamp(),
visited: value.visited_at.timestamp(),
times: value.times,
timezone: value.timezone,
}
}
}
impl DatastoreEvent {
fn to_event(&self, event_id: String) -> Event {
Event {
id: event_id,
name: self.name.clone(),
created_at: unix_to_date(self.created),
visited_at: unix_to_date(self.visited),
times: self.times.clone(),
timezone: self.timezone.clone(),
}
}
}
fn unix_to_date(unix: i64) -> DateTime<Utc> {
DateTime::from_utc(NaiveDateTime::from_timestamp_opt(unix, 0).unwrap(), Utc)
}
#[derive(Debug)]
pub enum DatastoreAdaptorError {
DatastoreError(google_cloud::error::Error),
}
impl Display for DatastoreAdaptorError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DatastoreAdaptorError::DatastoreError(e) => write!(f, "Datastore Error: {}", e),
}
}
}
impl Error for DatastoreAdaptorError {}
impl From<google_cloud::error::Error> for DatastoreAdaptorError {
fn from(value: google_cloud::error::Error) -> Self {
Self::DatastoreError(value)
}
}
impl From<google_cloud::error::ConvertError> for DatastoreAdaptorError {
fn from(value: google_cloud::error::ConvertError) -> Self {
Self::DatastoreError(google_cloud::error::Error::Convert(value))
}
}

View file

@ -0,0 +1,10 @@
[package]
name = "memory-adaptor"
version = "0.1.0"
edition = "2021"
[dependencies]
async-trait = "0.1.68"
chrono = "0.4.24"
common = { path = "../../common" }
tokio = { version = "1.28.1", features = ["rt-multi-thread"] }

View file

@ -0,0 +1,6 @@
# Memory Adaptor
This adaptor stores everything in memory, and all data is lost when the API is stopped. Useful for testing.
> **Warning**
> Do not use this adaptor in production!

View file

@ -0,0 +1,146 @@
use std::{collections::HashMap, error::Error, fmt::Display};
use async_trait::async_trait;
use chrono::Utc;
use common::{
adaptor::Adaptor,
event::{Event, EventDeletion},
person::Person,
stats::Stats,
};
use tokio::sync::Mutex;
struct State {
stats: Stats,
events: HashMap<String, Event>,
people: HashMap<(String, String), Person>,
}
pub struct MemoryAdaptor {
state: Mutex<State>,
}
#[async_trait]
impl Adaptor for MemoryAdaptor {
type Error = MemoryAdaptorError;
async fn get_stats(&self) -> Result<Stats, Self::Error> {
let state = self.state.lock().await;
Ok(state.stats.clone())
}
async fn increment_stat_event_count(&self) -> Result<i64, Self::Error> {
let mut state = self.state.lock().await;
state.stats.event_count += 1;
Ok(state.stats.event_count)
}
async fn increment_stat_person_count(&self) -> Result<i64, Self::Error> {
let mut state = self.state.lock().await;
state.stats.person_count += 1;
Ok(state.stats.person_count)
}
async fn get_people(&self, event_id: String) -> Result<Option<Vec<Person>>, Self::Error> {
let state = self.state.lock().await;
// Event doesn't exist
if state.events.get(&event_id).is_none() {
return Ok(None);
}
Ok(Some(
state
.people
.clone()
.into_iter()
.filter_map(|((p_event_id, _), p)| {
if p_event_id == event_id {
Some(p)
} else {
None
}
})
.collect(),
))
}
async fn upsert_person(&self, event_id: String, person: Person) -> Result<Person, Self::Error> {
let mut state = self.state.lock().await;
state
.people
.insert((event_id, person.name.clone()), person.clone());
Ok(person)
}
async fn get_event(&self, id: String) -> Result<Option<Event>, Self::Error> {
let mut state = self.state.lock().await;
let event = state.events.get(&id).cloned();
if let Some(mut event) = event.clone() {
event.visited_at = Utc::now();
state.events.insert(id, event);
}
Ok(event)
}
async fn create_event(&self, event: Event) -> Result<Event, Self::Error> {
let mut state = self.state.lock().await;
state.events.insert(event.id.clone(), event.clone());
Ok(event)
}
async fn delete_event(&self, id: String) -> Result<EventDeletion, Self::Error> {
let mut state = self.state.lock().await;
let mut person_count: u64 = state.people.len() as u64;
state.people = state
.people
.clone()
.into_iter()
.filter(|((event_id, _), _)| event_id != &id)
.collect();
person_count -= state.people.len() as u64;
state.events.remove(&id);
Ok(EventDeletion { id, person_count })
}
}
impl MemoryAdaptor {
pub async fn new() -> Self {
println!("🧠 Using in-memory storage");
println!("🚨 WARNING: All data will be lost when the process ends. Make sure you choose a database adaptor before deploying.");
let state = Mutex::new(State {
stats: Stats {
event_count: 0,
person_count: 0,
},
events: HashMap::new(),
people: HashMap::new(),
});
Self { state }
}
}
#[derive(Debug)]
pub enum MemoryAdaptorError {}
impl Display for MemoryAdaptorError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Memory adaptor error")
}
}
impl Error for MemoryAdaptorError {}

View file

@ -0,0 +1,14 @@
[package]
name = "sql-adaptor"
version = "0.1.0"
edition = "2021"
[dependencies]
async-trait = "0.1.68"
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"] }
sea-orm-migration = "0.11.0"
serde_json = "1.0.96"
chrono = "0.4.24"

View file

@ -0,0 +1,13 @@
# SQL Adaptor
This adaptor works with [Postgres](https://www.postgresql.org/), [MySQL](https://www.mysql.com/) or [SQLite](https://sqlite.org/index.html) databases.
## Environment
To use this adaptor, make sure you have the `DATABASE_URL` environment variable set to the database url for your chosen database.
Example:
```env
DATABASE_URL="postgresql://username:password@localhost:5432/crabfit"
```

View file

@ -0,0 +1,29 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.11.3
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "event")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: String,
pub name: String,
pub created_at: DateTime,
pub visited_at: DateTime,
pub times: Json,
pub timezone: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::person::Entity")]
Person,
}
impl Related<super::person::Entity> for Entity {
fn to() -> RelationDef {
Relation::Person.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -0,0 +1,7 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.11.3
pub mod prelude;
pub mod event;
pub mod person;
pub mod stats;

View file

@ -0,0 +1,35 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.11.3
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "person")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub name: String,
pub password_hash: Option<String>,
pub created_at: DateTime,
pub availability: Json,
#[sea_orm(primary_key, auto_increment = false)]
pub event_id: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::event::Entity",
from = "Column::EventId",
to = "super::event::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
Event,
}
impl Related<super::event::Entity> for Entity {
fn to() -> RelationDef {
Relation::Event.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -0,0 +1,5 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.11.3
pub use super::event::Entity as Event;
pub use super::person::Entity as Person;
pub use super::stats::Entity as Stats;

View file

@ -0,0 +1,17 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.11.3
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "stats")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub event_count: i32,
pub person_count: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

229
api/adaptors/sql/src/lib.rs Normal file
View file

@ -0,0 +1,229 @@
use std::{env, error::Error};
use async_trait::async_trait;
use chrono::{DateTime as ChronoDateTime, Utc};
use common::{
adaptor::Adaptor,
event::{Event, EventDeletion},
person::Person,
stats::Stats,
};
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,
TransactionError, TransactionTrait, TryIntoModel,
};
use serde_json::json;
mod entity;
mod migration;
pub struct SqlAdaptor {
db: DatabaseConnection,
}
#[async_trait]
impl Adaptor for SqlAdaptor {
type Error = SqlAdaptorError;
async fn get_stats(&self) -> Result<Stats, Self::Error> {
let stats_row = get_stats_row(&self.db).await?;
Ok(Stats {
event_count: stats_row.event_count.unwrap() as i64,
person_count: stats_row.person_count.unwrap() as i64,
})
}
async fn increment_stat_event_count(&self) -> Result<i64, Self::Error> {
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() as i64)
}
async fn increment_stat_person_count(&self) -> Result<i64, Self::Error> {
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() as i64)
}
async fn get_people(&self, event_id: String) -> Result<Option<Vec<Person>>, Self::Error> {
// TODO: optimize into one query
let event_row = event::Entity::find_by_id(event_id).one(&self.db).await?;
Ok(match event_row {
Some(event) => Some(
event
.find_related(person::Entity)
.all(&self.db)
.await?
.into_iter()
.map(|model| model.into())
.collect(),
),
None => None,
})
}
async fn upsert_person(&self, event_id: String, person: Person) -> Result<Person, Self::Error> {
let data = person::ActiveModel {
name: Set(person.name.clone()),
password_hash: Set(person.password_hash),
created_at: Set(person.created_at.naive_utc()),
availability: Set(serde_json::to_value(person.availability).unwrap_or(json!([]))),
event_id: Set(event_id.clone()),
};
Ok(
match person::Entity::find_by_id((person.name, event_id))
.one(&self.db)
.await?
{
Some(_) => data.update(&self.db).await?.try_into_model()?.into(),
None => data.insert(&self.db).await?.try_into_model()?.into(),
},
)
}
async fn get_event(&self, id: String) -> Result<Option<Event>, Self::Error> {
let existing_event = event::Entity::find_by_id(id).one(&self.db).await?;
// Mark as visited
if let Some(event) = existing_event.clone() {
let mut event: event::ActiveModel = event.into();
event.visited_at = Set(Utc::now().naive_utc());
event.save(&self.db).await?;
}
Ok(existing_event.map(|model| model.into()))
}
async fn create_event(&self, event: Event) -> Result<Event, Self::Error> {
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),
}
.insert(&self.db)
.await?
.try_into_model()?
.into())
}
async fn delete_event(&self, id: String) -> Result<EventDeletion, Self::Error> {
let event_id = id.clone();
let person_count = self
.db
.transaction::<_, u64, DbErr>(|t| {
Box::pin(async move {
// Delete people
let people_delete_result = person::Entity::delete_many()
.filter(person::Column::EventId.eq(&event_id))
.exec(t)
.await?;
// Delete event
event::Entity::delete_by_id(event_id).exec(t).await?;
Ok(people_delete_result.rows_affected)
})
})
.await?;
Ok(EventDeletion { id, person_count })
}
}
// Get the current stats as an ActiveModel
async fn get_stats_row(db: &DatabaseConnection) -> Result<stats::ActiveModel, DbErr> {
let current_stats = stats::Entity::find().one(db).await?;
Ok(match current_stats {
Some(model) => model.into(),
None => stats::ActiveModel {
id: NotSet,
event_count: Set(0),
person_count: Set(0),
},
})
}
impl SqlAdaptor {
pub async fn new() -> Self {
let connection_string =
env::var("DATABASE_URL").expect("Expected DATABASE_URL environment variable");
// Connect to the database
let db = Database::connect(&connection_string)
.await
.expect("Failed to connect to SQL database");
println!(
"{} Connected to database at {}",
match db {
DatabaseConnection::SqlxMySqlPoolConnection(_) => "🐬",
DatabaseConnection::SqlxPostgresPoolConnection(_) => "🐘",
DatabaseConnection::SqlxSqlitePoolConnection(_) => "🪶",
DatabaseConnection::Disconnected => panic!("Failed to connect to SQL database"),
},
connection_string
);
// Setup tables
Migrator::up(&db, None)
.await
.expect("Failed to set up tables in the database");
Self { db }
}
}
impl From<event::Model> for Event {
fn from(value: event::Model) -> Self {
Self {
id: value.id,
name: value.name,
created_at: ChronoDateTime::<Utc>::from_utc(value.created_at, Utc),
visited_at: ChronoDateTime::<Utc>::from_utc(value.visited_at, Utc),
times: serde_json::from_value(value.times).unwrap_or(vec![]),
timezone: value.timezone,
}
}
}
impl From<person::Model> for Person {
fn from(value: person::Model) -> Self {
Self {
name: value.name,
password_hash: value.password_hash,
created_at: ChronoDateTime::<Utc>::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<DbErr>),
}
impl Error for SqlAdaptorError {}
impl From<DbErr> for SqlAdaptorError {
fn from(value: DbErr) -> Self {
Self::DbErr(value)
}
}
impl From<TransactionError<DbErr>> for SqlAdaptorError {
fn from(value: TransactionError<DbErr>) -> Self {
Self::TransactionError(value)
}
}

View file

@ -0,0 +1,122 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
print!("Setting up database...");
// Stats table
manager
.create_table(
Table::create()
.table(Stats::Table)
.if_not_exists()
.col(
ColumnDef::new(Stats::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(Stats::EventCount).integer().not_null())
.col(ColumnDef::new(Stats::PersonCount).integer().not_null())
.to_owned(),
)
.await?;
// Events table
manager
.create_table(
Table::create()
.table(Event::Table)
.if_not_exists()
.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(),
)
.await?;
// People table
manager
.create_table(
Table::create()
.table(Person::Table)
.if_not_exists()
.col(ColumnDef::new(Person::Name).string().not_null())
.col(ColumnDef::new(Person::PasswordHash).string())
.col(ColumnDef::new(Person::CreatedAt).timestamp().not_null())
.col(ColumnDef::new(Person::Availability).json().not_null())
.col(ColumnDef::new(Person::EventId).string().not_null())
.primary_key(Index::create().col(Person::EventId).col(Person::Name))
.to_owned(),
)
.await?;
// Relation
manager
.create_foreign_key(
ForeignKey::create()
.name("FK_person_event")
.from(Person::Table, Person::EventId)
.to(Event::Table, Event::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
println!(" done");
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(Stats::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(Person::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(Event::Table).to_owned())
.await?;
Ok(())
}
}
/// Learn more at https://docs.rs/sea-query#iden
#[derive(Iden)]
enum Stats {
Table,
Id,
EventCount,
PersonCount,
}
#[derive(Iden)]
enum Event {
Table,
Id,
Name,
CreatedAt,
VisitedAt,
Times,
Timezone,
}
#[derive(Iden)]
enum Person {
Table,
Name,
PasswordHash,
CreatedAt,
Availability,
EventId,
}

View file

@ -0,0 +1,12 @@
pub use sea_orm_migration::prelude::*;
mod m01_setup_tables;
pub struct Migrator;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![Box::new(m01_setup_tables::Migration)]
}
}

9
api/common/Cargo.toml Normal file
View file

@ -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"

3
api/common/README.md Normal file
View file

@ -0,0 +1,3 @@
# Common
This crate contains the [adaptor trait](./src/adaptor.rs), and structs that are used by it. These are separated into their own crate so that the root crate and the adaptors can import from it without causing a circular dependency.

30
api/common/src/adaptor.rs Normal file
View file

@ -0,0 +1,30 @@
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<Stats, Self::Error>;
async fn increment_stat_event_count(&self) -> Result<i64, Self::Error>;
async fn increment_stat_person_count(&self) -> Result<i64, Self::Error>;
async fn get_people(&self, event_id: String) -> Result<Option<Vec<Person>>, Self::Error>;
async fn upsert_person(&self, event_id: String, person: Person) -> Result<Person, Self::Error>;
/// Get an event and update visited date to current time
async fn get_event(&self, id: String) -> Result<Option<Event>, Self::Error>;
async fn create_event(&self, event: Event) -> Result<Event, Self::Error>;
/// Delete an event as well as all related people
async fn delete_event(&self, id: String) -> Result<EventDeletion, Self::Error>;
}

19
api/common/src/event.rs Normal file
View file

@ -0,0 +1,19 @@
use chrono::{DateTime, Utc};
#[derive(Clone)]
pub struct Event {
pub id: String,
pub name: String,
pub created_at: DateTime<Utc>,
pub visited_at: DateTime<Utc>,
pub times: Vec<String>,
pub timezone: String,
}
#[derive(Clone)]
/// Info about a deleted event
pub struct EventDeletion {
pub id: String,
/// The amount of people that were in this event that were also deleted
pub person_count: u64,
}

4
api/common/src/lib.rs Normal file
View file

@ -0,0 +1,4 @@
pub mod adaptor;
pub mod event;
pub mod person;
pub mod stats;

9
api/common/src/person.rs Normal file
View file

@ -0,0 +1,9 @@
use chrono::{DateTime, Utc};
#[derive(Clone)]
pub struct Person {
pub name: String,
pub password_hash: Option<String>,
pub created_at: DateTime<Utc>,
pub availability: Vec<String>,
}

5
api/common/src/stats.rs Normal file
View file

@ -0,0 +1,5 @@
#[derive(Clone)]
pub struct Stats {
pub event_count: i64,
pub person_count: i64,
}

15
api/src/adaptors.rs Normal file
View file

@ -0,0 +1,15 @@
#[cfg(feature = "sql-adaptor")]
pub async fn create_adaptor() -> sql_adaptor::SqlAdaptor {
sql_adaptor::SqlAdaptor::new().await
}
#[cfg(feature = "datastore-adaptor")]
pub async fn create_adaptor() -> datastore_adaptor::DatastoreAdaptor {
datastore_adaptor::DatastoreAdaptor::new().await
}
#[cfg(not(feature = "sql-adaptor"))]
#[cfg(not(feature = "datastore-adaptor"))]
pub async fn create_adaptor() -> memory_adaptor::MemoryAdaptor {
memory_adaptor::MemoryAdaptor::new().await
}

52
api/src/docs.rs Normal file
View file

@ -0,0 +1,52 @@
use crate::payloads;
use crate::routes;
use utoipa::{
openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme},
Modify, OpenApi,
};
// OpenAPI documentation
#[derive(OpenApi)]
#[openapi(
info(title = "Crab Fit API"),
paths(
routes::stats::get_stats,
routes::event::create_event,
routes::event::get_event,
routes::person::get_people,
routes::person::get_person,
routes::person::update_person,
),
components(schemas(
payloads::StatsResponse,
payloads::EventResponse,
payloads::PersonResponse,
payloads::EventInput,
payloads::PersonInput,
)),
tags(
(name = "info"),
(name = "event"),
(name = "person"),
),
modifiers(&SecurityAddon),
)]
pub struct ApiDoc;
struct SecurityAddon;
// Add password auth spec
impl Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
openapi.components.as_mut().unwrap().add_security_scheme(
"password",
SecurityScheme::Http(
HttpBuilder::new()
.scheme(HttpAuthScheme::Bearer)
.bearer_format("base64")
.build(),
),
);
}
}

22
api/src/errors.rs Normal file
View file

@ -0,0 +1,22 @@
use axum::{http::StatusCode, response::IntoResponse};
use common::adaptor::Adaptor;
pub enum ApiError<A: Adaptor> {
AdaptorError(A::Error),
NotFound,
NotAuthorized,
}
// Define what the error types above should return
impl<A: Adaptor> IntoResponse for ApiError<A> {
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(),
}
}
}

109
api/src/main.rs Normal file
View file

@ -0,0 +1,109 @@
use std::{env, net::SocketAddr, sync::Arc};
use axum::{
error_handling::HandleErrorLayer,
extract,
http::{HeaderValue, Method},
routing::{get, patch, post},
BoxError, Router, Server,
};
use routes::*;
use tokio::sync::Mutex;
use tower::ServiceBuilder;
use tower_governor::{errors::display_error, governor::GovernorConfigBuilder, GovernorLayer};
use tower_http::{cors::CorsLayer, trace::TraceLayer};
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
use crate::adaptors::create_adaptor;
use crate::docs::ApiDoc;
mod adaptors;
mod docs;
mod errors;
mod payloads;
mod routes;
pub struct ApiState<A> {
adaptor: A,
}
pub type State<A> = extract::State<Arc<Mutex<ApiState<A>>>>;
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
// Load env
dotenvy::dotenv().ok();
let shared_state = Arc::new(Mutex::new(ApiState {
adaptor: create_adaptor().await,
}));
// CORS configuration
let cors = CorsLayer::new()
.allow_methods([Method::GET, Method::POST, Method::PATCH])
.allow_origin(
if cfg!(debug_assertions) {
"http://localhost:1234".to_owned()
} else {
env::var("FRONTEND_URL").expect("Missing FRONTEND_URL environment variable")
}
.parse::<HeaderValue>()
.unwrap(),
);
// Rate limiting configuration (using tower_governor)
// From the docs: Allows bursts with up to eight requests and replenishes
// one element after 500ms, based on peer IP.
let governor_config = Box::new(GovernorConfigBuilder::default().finish().unwrap());
let rate_limit = ServiceBuilder::new()
// Handle errors from governor and convert into HTTP responses
.layer(HandleErrorLayer::new(|e: BoxError| async move {
display_error(e)
}))
.layer(GovernorLayer {
config: Box::leak(governor_config),
});
let app = Router::new()
.merge(SwaggerUi::new("/docs").url("/docs/openapi.json", ApiDoc::openapi()))
.route("/", get(get_root))
.route("/stats", get(stats::get_stats))
.route("/event", post(event::create_event))
.route("/event/:event_id", get(event::get_event))
.route("/event/:event_id/people", get(person::get_people))
.route(
"/event/:event_id/people/:person_name",
get(person::get_person),
)
.route(
"/event/:event_id/people/:person_name",
patch(person::update_person),
)
.with_state(shared_state)
.layer(cors)
.layer(rate_limit)
.layer(TraceLayer::new_for_http());
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
println!(
"🦀 Crab Fit API listening at http://{} in {} mode",
addr,
if cfg!(debug_assertions) {
"debug"
} else {
"release"
}
);
Server::bind(&addr)
.serve(app.into_make_service_with_connect_info::<SocketAddr>())
.await
.unwrap();
}
async fn get_root() -> String {
format!("Crab Fit API v{}", env!("CARGO_PKG_VERSION"))
}

75
api/src/payloads.rs Normal file
View file

@ -0,0 +1,75 @@
use axum::Json;
use common::{event::Event, person::Person, stats::Stats};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::errors::ApiError;
pub type ApiResult<T, A> = Result<Json<T>, ApiError<A>>;
#[derive(Deserialize, ToSchema)]
pub struct EventInput {
pub name: Option<String>,
pub times: Vec<String>,
pub timezone: String,
}
#[derive(Serialize, ToSchema)]
pub struct EventResponse {
pub id: String,
pub name: String,
pub times: Vec<String>,
pub timezone: String,
pub created_at: i64,
}
impl From<Event> for EventResponse {
fn from(value: Event) -> Self {
Self {
id: value.id,
name: value.name,
times: value.times,
timezone: value.timezone,
created_at: value.created_at.timestamp(),
}
}
}
#[derive(Serialize, ToSchema)]
pub struct StatsResponse {
pub event_count: i64,
pub person_count: i64,
pub version: String,
}
impl From<Stats> for StatsResponse {
fn from(value: Stats) -> Self {
Self {
event_count: value.event_count,
person_count: value.person_count,
version: env!("CARGO_PKG_VERSION").to_string(),
}
}
}
#[derive(Serialize, ToSchema)]
pub struct PersonResponse {
pub name: String,
pub availability: Vec<String>,
pub created_at: i64,
}
impl From<Person> for PersonResponse {
fn from(value: Person) -> Self {
Self {
name: value.name,
availability: value.availability,
created_at: value.created_at.timestamp(),
}
}
}
#[derive(Deserialize, ToSchema)]
pub struct PersonInput {
pub availability: Vec<String>,
}

201
api/src/res/adjectives.json Normal file
View file

@ -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"
]

47
api/src/res/crabs.json Normal file
View file

@ -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"
]

140
api/src/routes/event.rs Normal file
View file

@ -0,0 +1,140 @@
use axum::{
extract::{self, Path},
http::StatusCode,
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,
};
#[utoipa::path(
get,
path = "/event/{event_id}",
params(
("event_id", description = "The ID of the event"),
),
responses(
(status = 200, description = "Ok", body = EventResponse),
(status = 404, description = "Not found"),
(status = 429, description = "Too many requests"),
),
tag = "event",
)]
/// Get details about an event
pub async fn get_event<A: Adaptor>(
extract::State(state): State<A>,
Path(event_id): Path<String>,
) -> ApiResult<EventResponse, A> {
let adaptor = &state.lock().await.adaptor;
let event = adaptor
.get_event(event_id)
.await
.map_err(ApiError::AdaptorError)?;
match event {
Some(event) => Ok(Json(event.into())),
None => Err(ApiError::NotFound),
}
}
#[utoipa::path(
post,
path = "/event",
request_body(content = EventInput, description = "New event details"),
responses(
(status = 201, description = "Created", body = EventResponse),
(status = 415, description = "Unsupported input format"),
(status = 422, description = "Invalid input provided"),
(status = 429, description = "Too many requests"),
),
tag = "event",
)]
/// Create a new event
pub async fn create_event<A: Adaptor>(
extract::State(state): State<A>,
Json(input): Json<EventInput>,
) -> Result<(StatusCode, Json<EventResponse>), ApiError<A>> {
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 {
Some(x) if !x.is_empty() => x.trim().to_string(),
_ => generate_name(),
};
// 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((StatusCode::CREATED, Json(event.into())))
}
// Generate a random name based on an adjective and a crab species
fn generate_name() -> String {
let adjectives: Vec<String> =
serde_json::from_slice(include_bytes!("../res/adjectives.json")).unwrap();
let crabs: Vec<String> = 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()
}

3
api/src/routes/mod.rs Normal file
View file

@ -0,0 +1,3 @@
pub mod event;
pub mod person;
pub mod stats;

214
api/src/routes/person.rs Normal file
View file

@ -0,0 +1,214 @@
use axum::{
extract::{self, Path},
headers::{authorization::Bearer, Authorization},
Json, TypedHeader,
};
use base64::{engine::general_purpose, Engine};
use common::{adaptor::Adaptor, person::Person};
use crate::{
errors::ApiError,
payloads::{ApiResult, PersonInput, PersonResponse},
State,
};
#[utoipa::path(
get,
path = "/event/{event_id}/people",
params(
("event_id", description = "The ID of the event"),
),
responses(
(status = 200, description = "Ok", body = [PersonResponse]),
(status = 404, description = "Event not found"),
(status = 429, description = "Too many requests"),
),
tag = "person",
)]
/// Get availabilities for an event
pub async fn get_people<A: Adaptor>(
extract::State(state): State<A>,
Path(event_id): Path<String>,
) -> ApiResult<Vec<PersonResponse>, A> {
let adaptor = &state.lock().await.adaptor;
let people = adaptor
.get_people(event_id)
.await
.map_err(ApiError::AdaptorError)?;
match people {
Some(people) => Ok(Json(people.into_iter().map(|p| p.into()).collect())),
None => Err(ApiError::NotFound),
}
}
#[utoipa::path(
get,
path = "/event/{event_id}/people/{person_name}",
params(
("event_id", description = "The ID of the event"),
("person_name", description = "The name of the person"),
),
security((), ("password" = [])),
responses(
(status = 200, description = "Ok", body = PersonResponse),
(status = 401, description = "Incorrect password"),
(status = 404, description = "Event not found"),
(status = 415, description = "Unsupported input format"),
(status = 422, description = "Invalid input provided"),
(status = 429, description = "Too many requests"),
),
tag = "person",
)]
/// Login or create a person for an event
pub async fn get_person<A: Adaptor>(
extract::State(state): State<A>,
Path((event_id, person_name)): Path<(String, String)>,
bearer: Option<TypedHeader<Authorization<Bearer>>>,
) -> ApiResult<PersonResponse, A> {
let adaptor = &state.lock().await.adaptor;
// Get inputted password
let password = parse_password(bearer);
let existing_people = adaptor
.get_people(event_id.clone())
.await
.map_err(ApiError::AdaptorError)?;
// Event not found
if existing_people.is_none() {
return Err(ApiError::NotFound);
}
// Check if the user already exists
let existing_person = existing_people
.unwrap()
.into_iter()
.find(|p| p.name.to_lowercase() == person_name.to_lowercase());
match existing_person {
// Login
Some(p) => {
// Verify password (if set)
if verify_password(&p, password) {
Ok(Json(p.into()))
} else {
Err(ApiError::NotAuthorized)
}
}
// Signup
None => {
// Update stats
adaptor
.increment_stat_person_count()
.await
.map_err(ApiError::AdaptorError)?;
Ok(Json(
adaptor
.upsert_person(
event_id,
Person {
name: person_name,
password_hash: password
.map(|raw| bcrypt::hash(raw, 10).unwrap_or(String::from(""))),
created_at: chrono::offset::Utc::now(),
availability: vec![],
},
)
.await
.map_err(ApiError::AdaptorError)?
.into(),
))
}
}
}
#[utoipa::path(
patch,
path = "/event/{event_id}/people/{person_name}",
params(
("event_id", description = "The ID of the event"),
("person_name", description = "The name of the person"),
),
security((), ("password" = [])),
request_body(content = PersonInput, description = "Person details"),
responses(
(status = 200, description = "Ok", body = PersonResponse),
(status = 401, description = "Incorrect password"),
(status = 404, description = "Event or person not found"),
(status = 415, description = "Unsupported input format"),
(status = 422, description = "Invalid input provided"),
(status = 429, description = "Too many requests"),
),
tag = "person",
)]
/// Update a person's availabilities
pub async fn update_person<A: Adaptor>(
extract::State(state): State<A>,
Path((event_id, person_name)): Path<(String, String)>,
bearer: Option<TypedHeader<Authorization<Bearer>>>,
Json(input): Json<PersonInput>,
) -> ApiResult<PersonResponse, A> {
let adaptor = &state.lock().await.adaptor;
let existing_people = adaptor
.get_people(event_id.clone())
.await
.map_err(ApiError::AdaptorError)?;
// Event not found
if existing_people.is_none() {
return Err(ApiError::NotFound);
}
// Check if the user exists
let existing_person = existing_people
.unwrap()
.into_iter()
.find(|p| p.name.to_lowercase() == person_name.to_lowercase())
.ok_or(ApiError::NotFound)?;
// Verify password (if set)
if !verify_password(&existing_person, parse_password(bearer)) {
return Err(ApiError::NotAuthorized);
}
Ok(Json(
adaptor
.upsert_person(
event_id,
Person {
name: existing_person.name,
password_hash: existing_person.password_hash,
created_at: existing_person.created_at,
availability: input.availability,
},
)
.await
.map_err(ApiError::AdaptorError)?
.into(),
))
}
pub fn parse_password(bearer: Option<TypedHeader<Authorization<Bearer>>>) -> Option<String> {
bearer.map(|TypedHeader(Authorization(b))| {
String::from_utf8(
general_purpose::STANDARD
.decode(b.token().trim())
.unwrap_or(vec![]),
)
.unwrap_or("".to_owned())
})
}
pub fn verify_password(person: &Person, raw: Option<String>) -> bool {
match &person.password_hash {
Some(hash) => bcrypt::verify(raw.unwrap_or("".to_owned()), hash).unwrap_or(false),
// Specifically allow a user who doesn't have a password
// set to log in with or without any password input
None => true,
}
}

26
api/src/routes/stats.rs Normal file
View file

@ -0,0 +1,26 @@
use axum::{extract, Json};
use common::adaptor::Adaptor;
use crate::{
errors::ApiError,
payloads::{ApiResult, StatsResponse},
State,
};
#[utoipa::path(
get,
path = "/stats",
responses(
(status = 200, description = "Ok", body = StatsResponse),
(status = 429, description = "Too many requests"),
),
tag = "info",
)]
/// Get current stats
pub async fn get_stats<A: Adaptor>(extract::State(state): State<A>) -> ApiResult<StatsResponse, A> {
let adaptor = &state.lock().await.adaptor;
let stats = adaptor.get_stats().await.map_err(ApiError::AdaptorError)?;
Ok(Json(stats.into()))
}