Include documentation for API and subcrates
This commit is contained in:
parent
dfdfc24ee5
commit
3e770a337b
40 changed files with 89 additions and 9 deletions
2
api/.gitignore
vendored
Normal file
2
api/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
target
|
||||
.env
|
||||
3973
api/Cargo.lock
generated
Normal file
3973
api/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
37
api/Cargo.toml
Normal file
37
api/Cargo.toml
Normal 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
26
api/README.md
Normal 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
21
api/adaptors/README.md
Normal 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" 😎
|
||||
14
api/adaptors/datastore/Cargo.toml
Normal file
14
api/adaptors/datastore/Cargo.toml
Normal 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"] }
|
||||
13
api/adaptors/datastore/README.md
Normal file
13
api/adaptors/datastore/README.md
Normal 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"}'
|
||||
```
|
||||
300
api/adaptors/datastore/src/lib.rs
Normal file
300
api/adaptors/datastore/src/lib.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
10
api/adaptors/memory/Cargo.toml
Normal file
10
api/adaptors/memory/Cargo.toml
Normal 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"] }
|
||||
6
api/adaptors/memory/README.md
Normal file
6
api/adaptors/memory/README.md
Normal 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!
|
||||
146
api/adaptors/memory/src/lib.rs
Normal file
146
api/adaptors/memory/src/lib.rs
Normal 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 {}
|
||||
14
api/adaptors/sql/Cargo.toml
Normal file
14
api/adaptors/sql/Cargo.toml
Normal 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"
|
||||
13
api/adaptors/sql/README.md
Normal file
13
api/adaptors/sql/README.md
Normal 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"
|
||||
```
|
||||
29
api/adaptors/sql/src/entity/event.rs
Normal file
29
api/adaptors/sql/src/entity/event.rs
Normal 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 {}
|
||||
7
api/adaptors/sql/src/entity/mod.rs
Normal file
7
api/adaptors/sql/src/entity/mod.rs
Normal 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;
|
||||
35
api/adaptors/sql/src/entity/person.rs
Normal file
35
api/adaptors/sql/src/entity/person.rs
Normal 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 {}
|
||||
5
api/adaptors/sql/src/entity/prelude.rs
Normal file
5
api/adaptors/sql/src/entity/prelude.rs
Normal 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;
|
||||
17
api/adaptors/sql/src/entity/stats.rs
Normal file
17
api/adaptors/sql/src/entity/stats.rs
Normal 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
229
api/adaptors/sql/src/lib.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
122
api/adaptors/sql/src/migration/m01_setup_tables.rs
Normal file
122
api/adaptors/sql/src/migration/m01_setup_tables.rs
Normal 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,
|
||||
}
|
||||
12
api/adaptors/sql/src/migration/mod.rs
Normal file
12
api/adaptors/sql/src/migration/mod.rs
Normal 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
9
api/common/Cargo.toml
Normal 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
3
api/common/README.md
Normal 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
30
api/common/src/adaptor.rs
Normal 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
19
api/common/src/event.rs
Normal 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
4
api/common/src/lib.rs
Normal 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
9
api/common/src/person.rs
Normal 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
5
api/common/src/stats.rs
Normal 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
15
api/src/adaptors.rs
Normal 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
52
api/src/docs.rs
Normal 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
22
api/src/errors.rs
Normal 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
109
api/src/main.rs
Normal 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
75
api/src/payloads.rs
Normal 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
201
api/src/res/adjectives.json
Normal 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
47
api/src/res/crabs.json
Normal 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
140
api/src/routes/event.rs
Normal 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
3
api/src/routes/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod event;
|
||||
pub mod person;
|
||||
pub mod stats;
|
||||
214
api/src/routes/person.rs
Normal file
214
api/src/routes/person.rs
Normal 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
26
api/src/routes/stats.rs
Normal 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()))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue