Include documentation for API and subcrates
This commit is contained in:
parent
dfdfc24ee5
commit
3e770a337b
40 changed files with 89 additions and 9 deletions
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