diff --git a/.changes/solution.md b/.changes/solution.md new file mode 100644 index 0000000..182edb7 --- /dev/null +++ b/.changes/solution.md @@ -0,0 +1,5 @@ +--- +"algohub-server": patch:feat +--- + +Support create, delete, get, list, update solutions \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index f30524a..98fb5ca 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ pub mod utils { pub mod organization; pub mod problem; pub mod session; + pub mod solution; pub mod submission; } @@ -22,6 +23,7 @@ pub mod routes { pub mod organization; pub mod problem; + pub mod solution; pub mod submission; } diff --git a/src/models/mod.rs b/src/models/mod.rs index b7feed6..e09a29f 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -7,6 +7,6 @@ pub mod organization; pub mod problem; pub mod response; pub mod shared; +pub mod solution; pub mod submission; - pub use shared::*; diff --git a/src/models/solution.rs b/src/models/solution.rs new file mode 100644 index 0000000..54e5cd5 --- /dev/null +++ b/src/models/solution.rs @@ -0,0 +1,79 @@ +use serde::{Deserialize, Serialize}; +use surrealdb::sql::Thing; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Solution { + pub id: Option, + + pub problem: Thing, + pub creator: Thing, + pub title: String, + pub content: String, + + pub created_at: chrono::NaiveDateTime, + pub updated_at: chrono::NaiveDateTime, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(crate = "rocket::serde")] +pub struct SolutionData<'r> { + pub title: &'r str, + pub content: &'r str, + pub problem: &'r str, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(crate = "rocket::serde")] +pub struct CreateSolution<'r> { + pub id: &'r str, + pub token: &'r str, + pub data: SolutionData<'r>, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct UserSolution { + pub id: String, + + pub problem: String, + pub title: String, + pub content: String, + pub creator: String, + + pub created_at: chrono::NaiveDateTime, + pub updated_at: chrono::NaiveDateTime, +} + +impl From for UserSolution { + fn from(value: Solution) -> Self { + UserSolution { + id: value.id.unwrap().id.to_string(), + problem: value.problem.id.to_string(), + creator: value.creator.id.to_string(), + title: value.title, + content: value.content, + + created_at: value.created_at, + updated_at: value.updated_at, + } + } +} + +impl From> for Solution { + fn from(val: CreateSolution<'_>) -> Self { + Solution { + id: None, + title: val.data.title.to_string(), + content: val.data.content.to_string(), + problem: ("problem", val.data.problem).into(), + creator: ("account", val.id).into(), + + created_at: chrono::Local::now().naive_local(), + updated_at: chrono::Local::now().naive_local(), + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ListSolutions { + pub problem: String, +} diff --git a/src/routes/index.rs b/src/routes/index.rs index ee7638b..a6ea222 100644 --- a/src/routes/index.rs +++ b/src/routes/index.rs @@ -5,6 +5,7 @@ use super::category; use super::contest; use super::organization; use super::problem; +use super::solution; use super::submission; use crate::{cors::CORS, routes::account}; use anyhow::Result; @@ -52,5 +53,6 @@ pub async fn rocket(db: Surreal) -> rocket::Rocket { .mount("/category", category::routes()) .mount("/contest", contest::routes()) .mount("/code", submission::routes()) + .mount("/solution", solution::routes()) .manage(db) } diff --git a/src/routes/solution.rs b/src/routes/solution.rs new file mode 100644 index 0000000..a9c6f37 --- /dev/null +++ b/src/routes/solution.rs @@ -0,0 +1,137 @@ +use crate::{ + models::{ + error::Error, + response::{Empty, Response}, + solution::{CreateSolution, ListSolutions, Solution, UserSolution}, + Credentials, OwnedId, + }, + utils::{session, solution}, + Result, +}; +use rocket::{post, serde::json::Json, State}; +use surrealdb::{engine::remote::ws::Client, Surreal}; + +#[post("/create", data = "")] +pub async fn create(db: &State>, sol: Json>) -> Result { + if !session::verify(db, sol.id, sol.token).await { + return Err(Error::Unauthorized(Json( + "Failed to grant permission".into(), + ))); + } + + let solution = solution::create(db, sol.into_inner()) + .await? + .ok_or(Error::ServerError(Json("Failed to create solution".into())))?; + + Ok(Json(Response { + success: true, + message: "Solution created successfully".to_string(), + data: Some(OwnedId { + id: solution.id.unwrap().id.to_string(), + }), + })) +} + +#[post("/get/", data = "")] +pub async fn get( + db: &State>, + id: &str, + auth: Json>>, +) -> Result { + let solution = solution::get::(db, id) + .await? + .ok_or(Error::NotFound(Json( + "Solution with specified id not found".into(), + )))?; + + let authed_id = if let Some(auth) = auth.into_inner() { + if !session::verify(db, auth.id, auth.token).await { + return Err(Error::Unauthorized(Json("Invalid credentials".into()))); + } else { + Some(auth.id) + } + } else { + None + }; + + if authed_id.is_none() { + return Err(Error::Unauthorized(Json( + "You have no permission to access this solution".into(), + ))); + } + + Ok(Json(Response { + success: true, + message: "Solution found".to_string(), + data: Some(solution.into()), + })) +} + +#[post("/update/", data = "")] +pub async fn update( + db: &State>, + id: &str, + sol: Json>, +) -> Result { + if !session::verify(db, sol.id, sol.token).await { + return Err(Error::Unauthorized(Json("Invalid credentials".into()))); + } + + solution::update(db, id, sol.into_inner()) + .await? + .ok_or(Error::ServerError(Json( + "Failed to update solution, please try again later.".into(), + )))?; + + Ok(Json(Response { + success: true, + message: "Solution updated successful".to_string(), + data: None, + })) +} + +#[post("/list", data = "")] +pub async fn list(db: &State>, data: Json) -> Result> { + let result = solution::list( + db, + ("problem".to_string(), data.into_inner().problem).into(), + ) + .await?; + + if result.is_empty() { + return Err(Error::NotFound(Json("Solution not found".into()))); + } + + Ok(Json(Response { + success: true, + message: "Solution found successfully".to_string(), + data: Some(result), + })) +} + +#[post("/delete/", data = "")] +pub async fn delete( + db: &State>, + id: &str, + sol: Json>, +) -> Result { + if !session::verify(db, sol.id, sol.token).await { + return Err(Error::Unauthorized(Json( + "Failed to grant permission".into(), + ))); + } + + solution::delete(db, id).await?; + + Ok(Response { + success: true, + message: "Solution deleted successfully".to_string(), + data: None, + } + .into()) +} + +pub fn routes() -> Vec { + use rocket::routes; + routes![create, get, update, list, delete] +} diff --git a/src/utils/solution.rs b/src/utils/solution.rs new file mode 100644 index 0000000..c6b3485 --- /dev/null +++ b/src/utils/solution.rs @@ -0,0 +1,42 @@ +use anyhow::Result; +use serde::Deserialize; +use surrealdb::{engine::remote::ws::Client, sql::Thing, Surreal}; + +use crate::models::solution::{CreateSolution, Solution}; + +pub async fn create(db: &Surreal, sol: CreateSolution<'_>) -> Result> { + Ok(db + .create("solution") + .content(Into::::into(sol)) + .await?) +} + +pub async fn delete(db: &Surreal, id: &str) -> Result> { + Ok(db.delete(("solution", id)).await?) +} + +pub async fn get(db: &Surreal, id: &str) -> Result> +where + for<'de> M: Deserialize<'de>, +{ + Ok(db.select(("solution", id)).await?) +} + +pub async fn list(db: &Surreal, problem: Thing) -> Result> { + Ok(db + .query("SELECT * FROM solution WHERE problem = $problem") + .bind(("problem", problem)) + .await? + .take(0)?) +} + +pub async fn update( + db: &Surreal, + id: &str, + solution: CreateSolution<'_>, +) -> Result> { + Ok(db + .update(("solution", id)) + .content(Into::::into(solution)) + .await?) +} diff --git a/tests/solution.rs b/tests/solution.rs new file mode 100644 index 0000000..144deab --- /dev/null +++ b/tests/solution.rs @@ -0,0 +1,202 @@ +mod utils; + +use std::path::Path; + +use algohub_server::{ + models::{ + account::Register, + problem::{CreateProblem, ProblemVisibility}, + response::{Empty, Response}, + solution::{CreateSolution, ListSolutions, Solution, SolutionData, UserSolution}, + Credentials, OwnedCredentials, OwnedId, Token, UserRecordId, + }, + routes::problem::ProblemResponse, +}; +use anyhow::Result; +use rocket::local::asynchronous::Client; +use utils::rocket; + +#[rocket::async_test] +async fn test_solution() -> Result<()> { + let rocket = rocket().await; + let client = Client::tracked(rocket).await?; + + println!("Testing solution"); + + let response = client + .post("/account/create") + .json(&Register { + username: "fu050409".to_string(), + password: "password".to_string(), + email: "email@example.com".to_string(), + }) + .dispatch() + .await; + + assert_eq!(response.status().code, 200); + + let Response { + success, + message: _, + data, + } = response.into_json().await.unwrap(); + let data: OwnedCredentials = data.unwrap(); + + let id = data.id.clone(); + let token = data.token.clone(); + + assert!(success); + + let response = client + .post("/problem/create") + .json(&CreateProblem { + id: &id, + token: &token, + title: "Test Problem", + description: "Test Description".to_string(), + input: Some("Test Input".to_string()), + output: Some("Test Output".to_string()), + samples: vec![], + hint: None, + owner: UserRecordId { + tb: "account".to_string(), + id: id.clone(), + }, + time_limit: 1000, + memory_limit: 128, + test_cases: vec![], + categories: vec![], + tags: vec![], + visibility: ProblemVisibility::Public, + }) + .dispatch() + .await; + + assert_eq!(response.status().code, 200); + + let Response { + success, + message: _, + data, + } = response.into_json().await.unwrap(); + let problem_data: ProblemResponse = data.unwrap(); + + assert!(success); + + let response = client + .post("/solution/create") + .json(&CreateSolution { + id: &id, + token: &token, + data: SolutionData { + title: "test", + content: "test", + problem: &problem_data.id, + }, + }) + .dispatch() + .await; + + assert_eq!(response.status().code, 200); + + let Response { + success, + message: _, + data, + } = response.into_json().await.unwrap(); + let solution_data: OwnedId = data.unwrap(); + + assert!(success); + println!("Created solution: {}", solution_data.id); + + let response = client + .post(format!("/solution/update/{}", solution_data.id)) + .json(&CreateSolution { + id: &id, + token: &token, + data: SolutionData { + title: "test2", + content: "test2", + problem: &problem_data.id, + }, + }) + .dispatch() + .await; + + assert_eq!(response.status().code, 200); + + let Response { + success, + message: _, + data: _, + } = response.into_json::>().await.unwrap(); + + assert!(success); + + println!("Update solution: {}", solution_data.id); + + let response = client + .post(format!("/solution/get/{}", solution_data.id)) + .json(&Credentials { + id: &id, + token: &token, + }) + .dispatch() + .await; + + assert_eq!(response.status().code, 200); + + let Response { + success, + message: _, + data, + } = response.into_json().await.unwrap(); + let list_solution_data: UserSolution = data.unwrap(); + + assert!(success); + println!("get solution: {:#?}", list_solution_data); + + let response = client + .post("/solution/list") + .json(&ListSolutions { + problem: problem_data.id.to_string(), + }) + .dispatch() + .await; + + assert_eq!(response.status().code, 200); + + let Response { + success, + message: _, + data, + } = response.into_json().await.unwrap(); + let list_solution_data: Vec = data.unwrap(); + + assert!(success); + println!("list solution: {:#?}", list_solution_data); + + let response = client + .post(format!("/solution/delete/{}", solution_data.id)) + .json(&Credentials { + id: &id, + token: &token, + }) + .dispatch() + .await; + + response.into_json::>().await.unwrap(); + + assert!(!Path::new("content").join(solution_data.id.clone()).exists()); + + client + .post(format!("/account/delete/{}", id)) + .json(&Token { token: &token }) + .dispatch() + .await + .into_json::>() + .await + .unwrap(); + + Ok(()) +}