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

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()))
}