#[macro_use] extern crate rocket; use futures::{future::TryFutureExt, stream::TryStreamExt}; use rocket::fairing::{self, AdHoc}; use rocket::response::status::NotFound; use rocket::serde::json::Json; use rocket::{futures, Build, Rocket}; use rocket_db_pools::{sqlx, Connection, Database}; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; mod api_model; mod auth; use auth::AuthApiAddon; #[derive(Database)] #[database("eyeballs")] struct Db(sqlx::MySqlPool); #[derive(OpenApi)] #[openapi( paths(projects, project, reviews, review,), modifiers(&AuthApiAddon), )] pub struct MainApi; enum Role { Reviewer, Watcher, } struct UserRole { user: api_model::User, role: Role, } impl TryFrom for Role { type Error = &'static str; fn try_from(value: u8) -> Result { match value { 0 => Ok(Role::Reviewer), 1 => Ok(Role::Watcher), _ => Err("Invalid role"), } } } impl TryFrom for api_model::ReviewState { type Error = &'static str; fn try_from(value: u8) -> Result { match value { 0 => Ok(api_model::ReviewState::Draft), 1 => Ok(api_model::ReviewState::Open), 2 => Ok(api_model::ReviewState::Dropped), 3 => Ok(api_model::ReviewState::Closed), _ => Err("Invalid review state"), } } } #[utoipa::path( responses( (status = 200, description = "Get all projects", body = api_model::Projects), ), security( ("session" = []), ), )] #[get("/projects?&")] async fn projects<'r>( mut db: Connection, _session: auth::Session, limit: Option, offset: Option, ) -> Json { let uw_offset = offset.unwrap_or(0); let uw_limit = limit.unwrap_or(10); let entries = sqlx::query!( "SELECT id,title FROM projects ORDER BY title,id LIMIT ? OFFSET ?", uw_limit, uw_offset ) .fetch(&mut **db) .map_ok(|r| api_model::ProjectEntry { id: r.id, title: r.title, }) .try_collect::>() .await .unwrap(); let count = sqlx::query!("SELECT COUNT(id) AS count FROM projects") .fetch_one(&mut **db) .map_ok(|r| r.count) .await .unwrap(); let u32_count = u32::try_from(count).unwrap(); Json(api_model::Projects { offset: uw_offset, limit: uw_limit, total_count: u32_count, more: uw_offset + uw_limit < u32_count, projects: entries, }) } #[utoipa::path( responses( (status = 200, description = "Get project", body = api_model::Project), (status = 404, description = "No such project"), ), security( ("session" = []), ), )] #[get("/project/")] async fn project<'r>( mut db: Connection, _session: auth::Session, projectid: u64, ) -> Result, NotFound<&'static str>> { let members = sqlx::query!( "SELECT id, username, name, active FROM users JOIN project_users ON project_users.user=users.id WHERE project_users.project=?", projectid) .fetch(&mut **db) .map_ok(|r| api_model::User { id: r.id, username: r.username, name: r.name, active: r.active != 0, }) .try_collect::>() .await .unwrap(); let project = sqlx::query!( "SELECT id,title,description FROM projects WHERE id=?", projectid ) .fetch_one(&mut **db) .map_ok(|r| api_model::Project { id: r.id, title: r.title, description: r.description, members, }) .map_err(|_| NotFound("No such project")) .await?; Ok(Json(project)) } #[utoipa::path( responses( (status = 200, description = "Get all reviews for project", body = api_model::Reviews), ), security( ("session" = []), ), )] #[get("/project//reviews?&")] async fn reviews<'r>( mut db: Connection, _session: auth::Session, projectid: u64, limit: Option, offset: Option, ) -> Json { let uw_offset = offset.unwrap_or(0); let uw_limit = limit.unwrap_or(10); let entries = sqlx::query!( "SELECT reviews.id AS id,title,state,progress,users.id AS user_id,users.username AS username,users.name AS name,users.active AS user_active FROM reviews JOIN users ON users.id=owner WHERE project=? ORDER BY id DESC LIMIT ? OFFSET ?", projectid, uw_limit, uw_offset) .fetch(&mut **db) .map_ok(|r| api_model::ReviewEntry { id: r.id, title: r.title, owner: api_model::User { id: r.user_id, username: r.username, name: r.name, active: r.user_active != 0, }, state: api_model::ReviewState::try_from(r.state).unwrap(), progress: r.progress, }) .try_collect::>() .await .unwrap(); let count = sqlx::query!( "SELECT COUNT(id) AS count FROM reviews WHERE project=?", projectid ) .fetch_one(&mut **db) .map_ok(|r| r.count) .await .unwrap(); let u32_count = u32::try_from(count).unwrap(); Json(api_model::Reviews { offset: uw_offset, limit: uw_limit, total_count: u32_count, more: uw_offset + uw_limit < u32_count, reviews: entries, }) } #[utoipa::path( responses( (status = 200, description = "Get review", body = api_model::Review), (status = 404, description = "No such review"), ), security( ("session" = []), ), )] #[get("/review/")] async fn review<'r>( mut db: Connection, _session: auth::Session, reviewid: u64, ) -> Result, NotFound<&'static str>> { let mut users = sqlx::query!( "SELECT id,username,name,active,review_users.role AS role FROM users JOIN review_users ON review_users.user=id WHERE review_users.review=? ORDER BY role,username,id", reviewid) .fetch(&mut **db) .map_ok(|r| UserRole { user: api_model::User { id: r.id, username: r.username, name: r.name, active: r.active != 0, }, role: Role::try_from(r.role).unwrap(), }) .try_collect::>() .await .unwrap(); let first_reviewer = users .iter() .position(|u| matches!(u.role, Role::Reviewer)) .unwrap_or(users.len()); let mut reviewers: Vec = Vec::with_capacity(first_reviewer); for user_role in users.drain(0..first_reviewer) { reviewers.push(user_role.user); } let mut watchers: Vec = Vec::with_capacity(users.len()); for user_role in users.drain(0..) { watchers.push(user_role.user); } let review = sqlx::query!( "SELECT reviews.id AS id,title,description,state,progress,users.id AS user_id,users.username AS username,users.name AS name,users.active AS user_active FROM reviews JOIN users ON users.id=owner WHERE reviews.id=?", reviewid) .fetch_one(&mut **db) .map_ok(|r| api_model::Review { id: r.id, title: r.title, description: r.description, owner: api_model::User { id: r.user_id, username: r.username, name: r.name, active: r.user_active != 0, }, reviewers, watchers, state: api_model::ReviewState::try_from(r.state).unwrap(), progress: r.progress, }) .map_err(|_| NotFound("No such review")) .await?; Ok(Json(review)) } async fn run_migrations(rocket: Rocket) -> fairing::Result { match Db::fetch(&rocket) { Some(db) => match sqlx::migrate!("./migrations").run(&**db).await { Ok(_) => Ok(rocket), Err(e) => { error!("Failed to initialize database: {}", e); Err(rocket) } }, None => Err(rocket), } } #[rocket::main] async fn main() -> Result<(), rocket::Error> { let basepath = "/api/v1"; let mut api = MainApi::openapi(); api.merge(auth::AuthApi::openapi()); api.servers = Some(vec![utoipa::openapi::ServerBuilder::new() .url(basepath) .build()]); let _rocket = rocket::build() .attach(Db::init()) .attach(AdHoc::try_on_ignite("Database Migrations", run_migrations)) .mount(basepath, routes![projects, project, reviews, review]) .mount( "/", SwaggerUi::new("/openapi/ui/<_..>").url("/openapi/openapi.json", api), ) .attach(auth::stage(basepath)) .launch() .await?; Ok(()) }