#[macro_use] extern crate rocket; use futures::{future::TryFutureExt, stream::TryStreamExt}; use rocket::fairing::{self, AdHoc}; use rocket::figment::Figment; use rocket::http::Status; use rocket::response::status::{Conflict, Custom, NotFound}; use rocket::serde::json::Json; use rocket::{futures, Build, Rocket}; use rocket_db_pools::{sqlx, Connection, Database}; use sqlx::Acquire; use std::path::PathBuf; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; #[cfg(test)] mod tests; mod api_model; mod auth; mod db_utils; use auth::AuthApiAddon; #[derive(Database)] #[database("eyeballs")] struct Db(sqlx::MySqlPool); #[derive(OpenApi)] #[openapi( paths( projects, project, project_new, project_update, project_user_add, project_user_update, project_user_del, reviews, review, users, ), modifiers(&AuthApiAddon), )] pub struct MainApi; impl TryFrom for api_model::UserReviewRole { type Error = &'static str; fn try_from(value: u8) -> Result { match value { 0 => Ok(api_model::UserReviewRole::None), 1 => Ok(api_model::UserReviewRole::Reviewer), 2 => Ok(api_model::UserReviewRole::Watcher), _ => Err("Invalid role"), } } } impl From for u8 { fn from(value: api_model::UserReviewRole) -> u8 { match value { api_model::UserReviewRole::None => 0, api_model::UserReviewRole::Reviewer => 1, api_model::UserReviewRole::Watcher => 2, } } } 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( 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, }) } async fn get_project( db: &mut Connection, projectid: &str, ) -> Result, NotFound<&'static str>> { let users = sqlx::query!( "SELECT id, name, dn, default_role, maintainer FROM users JOIN project_users ON project_users.user=users.id WHERE project_users.project=?", projectid) .fetch(&mut ***db) .map_ok(|r| api_model::ProjectUserEntry { user: api_model::User { id: r.id, name: r.name, active: r.dn.is_some(), }, default_role: api_model::UserReviewRole::try_from(r.default_role).unwrap(), maintainer: r.maintainer != 0, }) .try_collect::>() .await .unwrap(); let project = sqlx::query!( "SELECT id,title,description,remote,main_branch FROM projects WHERE id=?", projectid ) .fetch_one(&mut ***db) .map_ok(|r| api_model::Project { id: r.id, title: r.title, description: r.description, remote: r.remote, main_branch: r.main_branch, users, }) .map_err(|_| NotFound("No such project")) .await?; Ok(Json(project)) } #[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( mut db: Connection, _session: auth::Session, projectid: &str, ) -> Result, NotFound<&'static str>> { get_project(&mut db, projectid).await } #[utoipa::path( responses( (status = 200, description = "Project created", body = api_model::Project), (status = 409, description = "Project with id already exists"), ), security( ("session" = []), ), )] #[post("/project//new", data = "")] async fn project_new( mut db: Connection, session: auth::Session, projectid: &str, data: Json>, ) -> Result, Conflict<&'static str>> { { let mut tx = db.begin().await.unwrap(); sqlx::query!( "INSERT INTO projects (id, title, description, remote, main_branch) VALUES (?, ?, ?, ?, ?)", projectid, data.title.unwrap_or("Unnamed"), data.description.unwrap_or(""), data.remote.unwrap_or(""), data.main_branch.unwrap_or("main"), ) .execute(&mut *tx) .map_err(|_| Conflict("Project with id already exists")) .await?; sqlx::query!( "INSERT INTO project_users (project, user, default_role, maintainer) VALUES (?, ?, ?, ?)", projectid, session.user_id, u8::from(api_model::UserReviewRole::Reviewer), true) .execute(&mut *tx) .await .unwrap(); tx.commit().await.unwrap(); } Ok(get_project(&mut db, projectid).await.unwrap()) } async fn project_check_maintainer( db: &mut Connection, session: auth::Session, projectid: &str, ) -> Result<&'static str, Custom<&'static str>> { let is_maintainer = sqlx::query!( "SELECT COUNT(user) AS count FROM project_users WHERE project=? AND user=? AND maintainer=TRUE", projectid, session.user_id) .fetch_one(&mut ***db) .map_ok(|r| r.count) .await .unwrap(); if is_maintainer == 0 { // Check if it is because user is not maintainer or project just doesn't exist sqlx::query!("SELECT id AS count FROM projects WHERE id=?", projectid) .fetch_one(&mut ***db) .map_err(|_| Custom(Status::NotFound, "No such project")) .await?; Err(Custom(Status::Unauthorized, "Not maintainer of project")) } else { Ok("") } } #[utoipa::path( responses( (status = 200, description = "Project updated"), (status = 401, description = "Not maintainer of project"), (status = 404, description = "No such project"), ), security( ("session" = []), ), )] #[post("/project/", data = "")] async fn project_update( mut db: Connection, session: auth::Session, projectid: &str, data: Json>, ) -> Result<&'static str, Custom<&'static str>> { project_check_maintainer(&mut db, session, projectid).await?; let mut update_builder: db_utils::UpdateBuilder = db_utils::UpdateBuilder::new(); update_builder.table("projects"); if let Some(title) = &data.title { update_builder.set("title", title); } if let Some(description) = &data.description { update_builder.set("description", description); } if let Some(remote) = &data.remote { update_builder.set("remote", remote); } if let Some(main_branch) = &data.main_branch { update_builder.set("main_branch", main_branch); } update_builder.and_where("id", "=", projectid); if update_builder.ok() { let (query, args) = update_builder.build(); let mut query_builder: sqlx::QueryBuilder = sqlx::QueryBuilder::with_arguments(query, args); query_builder.build().execute(&mut **db).await.unwrap(); } Ok("") } #[utoipa::path( responses( (status = 200, description = "User added to project"), (status = 401, description = "Not maintainer of project"), (status = 404, description = "No such project"), (status = 409, description = "User already in project"), ), security( ("session" = []), ), )] #[post("/project//user//new", data = "")] async fn project_user_add( mut db: Connection, session: auth::Session, projectid: &str, userid: &str, data: Json, ) -> Result<&'static str, Custom<&'static str>> { project_check_maintainer(&mut db, session, projectid).await?; sqlx::query!( "INSERT INTO project_users (project, user, default_role, maintainer) VALUES (?, ?, ?, ?)", projectid, userid, u8::from( data.default_role .unwrap_or(api_model::UserReviewRole::Reviewer) ), data.maintainer.unwrap_or(false) ) .execute(&mut **db) .map_err(|_| Custom(Status::Conflict, "User already in project")) .await?; Ok("") } #[utoipa::path( responses( (status = 200, description = "User updated in project"), (status = 401, description = "Not maintainer of project"), (status = 404, description = "No such project, no such user or user not in project"), ), security( ("session" = []), ), )] #[post("/project//user/", data = "")] async fn project_user_update( mut db: Connection, session: auth::Session, projectid: &str, userid: &str, data: Json, ) -> Result<&'static str, Custom<&'static str>> { let need_maintainer = data.maintainer.is_some() || userid != session.user_id; if need_maintainer { project_check_maintainer(&mut db, session, projectid).await?; } let mut update_builder: db_utils::UpdateBuilder = db_utils::UpdateBuilder::new(); update_builder.table("project_users"); if let Some(default_role) = &data.default_role { update_builder.set("default_role", u8::from(*default_role)); } if let Some(maintainer) = &data.maintainer { update_builder.set("maintainer", maintainer); } update_builder.and_where("project", "=", projectid); update_builder.and_where("user", "=", userid); if update_builder.ok() { let (query, args) = update_builder.build(); let mut query_builder: sqlx::QueryBuilder = sqlx::QueryBuilder::with_arguments(query, args); let result = query_builder.build().execute(&mut **db).await.unwrap(); if result.rows_affected() == 0 { return Err(Custom(Status::NotFound, "No such project or no such user")); } } Ok("") } #[utoipa::path( responses( (status = 200, description = "User removed from project"), (status = 401, description = "Not maintainer of project"), (status = 404, description = "No such project, no such user or user not in project"), ), security( ("session" = []), ), )] #[delete("/project//user/")] async fn project_user_del( mut db: Connection, session: auth::Session, projectid: &str, userid: &str, ) -> Result<&'static str, Custom<&'static str>> { let need_maintainer = userid != session.user_id; if need_maintainer { project_check_maintainer(&mut db, session, projectid).await?; } let result = sqlx::query!( "DELETE FROM project_users WHERE project=? AND user=?", projectid, userid ) .execute(&mut **db) .await .unwrap(); if result.rows_affected() == 0 { return Err(Custom(Status::NotFound, "No such project or no such user")); } Ok("") } #[utoipa::path( responses( (status = 200, description = "Get all reviews for project", body = api_model::Reviews), (status = 404, description = "No such project"), ), security( ("session" = []), ), )] #[get("/project//reviews?&")] async fn reviews( mut db: Connection, _session: auth::Session, projectid: &str, limit: Option, offset: Option, ) -> Result, NotFound<&'static str>> { 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.name AS name,users.dn AS user_dn 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, name: r.name, active: r.user_dn.is_some(), }, 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(); if count == 0 { let projects = sqlx::query!( "SELECT COUNT(id) AS count FROM projects WHERE id=?", projectid ) .fetch_one(&mut **db) .map_ok(|r| r.count) .await .unwrap(); if projects == 0 { return Err(NotFound("No such project")); } } let u32_count = u32::try_from(count).unwrap(); Ok(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( mut db: Connection, _session: auth::Session, projectid: &str, branch: PathBuf, ) -> Result, NotFound<&'static str>> { let mut review = sqlx::query!( "SELECT reviews.id AS id,title,description,state,progress,branch,archived,users.id AS user_id,users.name AS name,users.dn AS user_dn FROM reviews JOIN users ON users.id=owner WHERE project=? AND branch=?", projectid, branch.as_path().to_str().unwrap()) .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, name: r.name, active: r.user_dn.is_some(), }, users: Vec::new(), state: api_model::ReviewState::try_from(r.state).unwrap(), progress: r.progress, branch: r.branch, archived: r.archived != 0, } }) .map_err(|_| NotFound("No such review")) .await?; let mut users = sqlx::query!( "SELECT id,name,dn,project_users.default_role AS role FROM users JOIN project_users ON project_users.user=id WHERE project_users.project=? ORDER BY role,id", projectid) .fetch(&mut **db) .map_ok(|r| api_model::ReviewUserEntry { user: api_model::User { id: r.id, name: r.name, active: r.dn.is_some(), }, role: api_model::UserReviewRole::try_from(r.role).unwrap(), }) .try_collect::>() .await .unwrap(); let override_users = sqlx::query!( "SELECT id,name,dn,review_users.role AS role FROM users JOIN review_users ON review_users.user=id WHERE review_users.review=? ORDER BY role,id", review.id) .fetch(&mut **db) .map_ok(|r| api_model::ReviewUserEntry { user: api_model::User { id: r.id, name: r.name, active: r.dn.is_some(), }, role: api_model::UserReviewRole::try_from(r.role).unwrap(), }) .try_collect::>() .await .unwrap(); for override_user in override_users { if let Some(user) = users .iter_mut() .find(|ue| ue.user.id == override_user.user.id) { user.role = override_user.role; } else { users.push(override_user); } } review.users = users; Ok(Json(review)) } #[utoipa::path( responses( (status = 200, description = "Get all users", body = api_model::Users), ), security( ("session" = []), ), )] #[get("/users?&")] async fn users( 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,name,dn FROM users ORDER BY id LIMIT ? OFFSET ?", uw_limit, uw_offset ) .fetch(&mut **db) .map_ok(|r| api_model::User { id: r.id, name: r.name, active: r.dn.is_some(), }) .try_collect::>() .await .unwrap(); let count = sqlx::query!("SELECT COUNT(id) AS count FROM users") .fetch_one(&mut **db) .map_ok(|r| r.count) .await .unwrap(); let u32_count = u32::try_from(count).unwrap(); Json(api_model::Users { offset: uw_offset, limit: uw_limit, total_count: u32_count, more: uw_offset + uw_limit < u32_count, users: entries, }) } async fn run_migrations(rocket: Rocket) -> fairing::Result { match Db::fetch(&rocket) { Some(db) => match sqlx::migrate!().run(&**db).await { Ok(_) => Ok(rocket), Err(e) => { error!("Failed to initialize database: {}", e); Err(rocket) } }, None => Err(rocket), } } fn rocket_from_config(figment: Figment) -> Rocket { let basepath = "/api/v1"; rocket::custom(figment) .attach(Db::init()) .attach(AdHoc::try_on_ignite("Database Migrations", run_migrations)) .mount( basepath, // Remember to update openapi paths when you add something here. routes![ projects, project, project_new, project_update, project_user_add, project_user_update, project_user_del, reviews, review, users, ], ) .attach(auth::stage(basepath)) } #[rocket::main] async fn main() -> Result<(), rocket::Error> { let mut api = MainApi::openapi(); api.merge(auth::AuthApi::openapi()); api.servers = Some(vec![utoipa::openapi::ServerBuilder::new() .url("/api/v1") .build()]); let _rocket = rocket_from_config(rocket::Config::figment()) .mount( "/", SwaggerUi::new("/openapi/ui/<_..>").url("/openapi/openapi.json", api), ) .launch() .await?; Ok(()) }