From 958c7da9cd4087e460e0d6ecdabef9d22d359e4b Mon Sep 17 00:00:00 2001 From: reddevilmidzy Date: Thu, 26 Jun 2025 12:48:25 +0900 Subject: [PATCH 01/17] feat: Implement file upload --- .gitignore | 3 + Cargo.toml | 8 +- src/file_manager/handlers.rs | 62 +++++++++++++++ src/file_manager/mod.rs | 5 ++ src/file_manager/models.rs | 96 +++++++++++++++++++++++ src/lib.rs | 13 +++- tests/file_manager.rs | 145 +++++++++++++++++++++++++++++++++++ 7 files changed, 328 insertions(+), 4 deletions(-) create mode 100644 src/file_manager/handlers.rs create mode 100644 src/file_manager/mod.rs create mode 100644 src/file_manager/models.rs create mode 100644 tests/file_manager.rs diff --git a/.gitignore b/.gitignore index 6e701f0..db60e2e 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ Cargo.lock # Per Editor Configuration .idea/ .vscode/ + +# Uploads directory +uploads/ diff --git a/Cargo.toml b/Cargo.toml index 118a157..57957e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,8 +11,12 @@ path = "src/main.rs" name = "coduck-backend" [dependencies] -axum = "0.8.4" +axum = { version = "0.8.4", features = ["json", "multipart"] } +chrono = { version = "0.4.38", features = ["serde"] } +reqwest = { version = "0.12.19", features = ["json", "rustls-tls"] } +serde = { version = "1.0.219", features = ["derive"] } +serde_json = "1.0.133" tokio = { version = "1.45.1", features = ["full"] } +uuid = { version = "1.17.0", features = ["v4"] } [dev-dependencies] -reqwest = { version = "0.12.19", features = ["json", "rustls-tls"] } diff --git a/src/file_manager/handlers.rs b/src/file_manager/handlers.rs new file mode 100644 index 0000000..08d0cd0 --- /dev/null +++ b/src/file_manager/handlers.rs @@ -0,0 +1,62 @@ +use crate::file_manager::{FileMetadata, Language}; + +use axum::{ + extract::{Multipart, Path}, + response::IntoResponse, + Json, +}; +use chrono::Utc; +use reqwest::StatusCode; +use std::path::PathBuf; +use tokio::fs; +use uuid::Uuid; + +pub async fn upload_file( + Path((problem_id, category)): Path<(u32, String)>, + mut multipart: Multipart, +) -> impl IntoResponse { + let field = multipart.next_field().await.unwrap().unwrap(); + let file_name = field.file_name().unwrap().to_string(); + let data = field.bytes().await.unwrap(); + + let file_id = Uuid::new_v4().to_string(); + let upload_dir = PathBuf::from(format!("uploads/{}/{}", problem_id, category)); + fs::create_dir_all(&upload_dir).await.unwrap(); + + let file_path = upload_dir.join(&file_name); + + if file_path.exists() { + return ( + StatusCode::CONFLICT, + Json(serde_json::json!({ + "error": format!("File '{}' already exists in this category", file_name) + })), + ).into_response(); + } + + let now = Utc::now(); + let language = match Language::from_filename(&file_name) { + Ok(lang) => lang, + Err(e) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": e.to_string() + })), + ) + .into_response(); + } + }; + + fs::write(&file_path, &data).await.unwrap(); + let metadata = FileMetadata { + id: file_id, + filename: file_name, + language: language, + category: category, + size: data.len() as u64, + created_at: now, + updated_at: now, + }; + (StatusCode::CREATED, Json(metadata)).into_response() +} diff --git a/src/file_manager/mod.rs b/src/file_manager/mod.rs new file mode 100644 index 0000000..cd09d88 --- /dev/null +++ b/src/file_manager/mod.rs @@ -0,0 +1,5 @@ +pub mod handlers; +pub mod models; + +pub use handlers::*; +pub use models::*; diff --git a/src/file_manager/models.rs b/src/file_manager/models.rs new file mode 100644 index 0000000..8c39a31 --- /dev/null +++ b/src/file_manager/models.rs @@ -0,0 +1,96 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct FileMetadata { + pub id: String, + pub filename: String, + pub language: Language, + pub category: String, + pub size: u64, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +pub enum LanguageError { + UnsupportedExtension(String), + InvalidFilename, +} + +impl std::fmt::Display for LanguageError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LanguageError::UnsupportedExtension(extension) => { + write!(f, "Unsupported file extension: {}", extension) + } + LanguageError::InvalidFilename => write!(f, "Invalid filename"), + } + } +} + +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Language { + C, + Cpp, + Csharp, + D, + Go, + Java, + Kotlin, + Ocaml, + Pascal, + Perl, + Php, + Python, + Ruby, + Rust, + Scala, + Javascript, + Typescript, + Text, + Markdown, +} + +impl Language { + pub fn from_filename(filename: &str) -> Result { + let extension = filename + .split('.') + .last() + .ok_or(LanguageError::InvalidFilename)? + .to_lowercase(); + + if extension.is_empty() { + return Err(LanguageError::InvalidFilename); + } + + Self::from_extension(&extension) + } + + // TODO .io 와 .out 파일은 어떻게 처리할지 + pub fn from_extension(extension: &str) -> Result { + match extension { + "c" => Ok(Language::C), + "cpp" => Ok(Language::Cpp), + "cs" => Ok(Language::Csharp), + "d" => Ok(Language::D), + "go" => Ok(Language::Go), + "java" => Ok(Language::Java), + "kt" => Ok(Language::Kotlin), + "ml" => Ok(Language::Ocaml), + "pas" => Ok(Language::Pascal), + "pl" => Ok(Language::Perl), + "php" => Ok(Language::Php), + "py" => Ok(Language::Python), + "rb" => Ok(Language::Ruby), + "rs" => Ok(Language::Rust), + "scala" => Ok(Language::Scala), + "js" => Ok(Language::Javascript), + "ts" => Ok(Language::Typescript), + "txt" => Ok(Language::Text), + "md" => Ok(Language::Markdown), + _ => Err(LanguageError::UnsupportedExtension(extension.to_string())), + } + } +} diff --git a/src/lib.rs b/src/lib.rs index f482b98..5e881f1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,9 +1,18 @@ -use axum::{routing::get, Router}; +pub mod file_manager; + +use axum::{ + routing::{get, post}, + Router, +}; + +use crate::file_manager::upload_file; async fn health_check() -> &'static str { "OK" } pub fn build_router() -> Router { - Router::new().route("/health", get(health_check)) + Router::new() + .route("/health", get(health_check)) + .route("/upload/{problem_id}/{category}", post(upload_file)) } diff --git a/tests/file_manager.rs b/tests/file_manager.rs new file mode 100644 index 0000000..3f5814f --- /dev/null +++ b/tests/file_manager.rs @@ -0,0 +1,145 @@ +use std::path::Path; +use tokio::fs; + +struct TestSetup { + problem_id: u32, + file_content: Vec, + file_name: String, + boundary: String, + multipart_body: String, + port: u16, +} + +async fn setup_test( + problem_id: u32, + file_content: &[u8], + file_name: &str, + boundary: &str, +) -> TestSetup { + // 테스트용 디렉토리만 정리 (uploads 전체가 아닌 특정 problem_id만) + let test_upload_dir = format!("uploads/{}", problem_id); + if Path::new(&test_upload_dir).exists() { + fs::remove_dir_all(&test_upload_dir).await.unwrap(); + } + + let multipart_body = format!( + "--{boundary}\r\n\ + Content-Disposition: form-data; name=\"file\"; filename=\"{file_name}\"\r\n\ + Content-Type: text/plain\r\n\r\n\ + {}\r\n\ + --{boundary}--\r\n", + String::from_utf8_lossy(file_content) + ); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + + tokio::spawn(async move { + let app = coduck_backend::build_router(); + axum::serve(listener, app) + .await + .expect("Server failed to start"); + }); + + TestSetup { + problem_id, + file_content: file_content.to_vec(), + file_name: file_name.to_string(), + boundary: boundary.to_string(), + multipart_body, + port, + } +} + +async fn upload_file_request(setup: &TestSetup) -> reqwest::Response { + let client = reqwest::Client::new(); + client + .post(&format!( + "http://127.0.0.1:{port}/upload/{problem_id}/solution", + port = setup.port, + problem_id = setup.problem_id + )) + .header( + "Content-Type", + format!("multipart/form-data; boundary={}", setup.boundary), + ) + .body(setup.multipart_body.clone()) + .send() + .await + .unwrap() +} + +#[tokio::test] +async fn upload_file_success() { + let setup = setup_test( + 0, + b"print(int(input()) + int(input()))", + "aplusb-solution.py", + "boundary123", + ) + .await; + + let response = upload_file_request(&setup).await; + + assert_eq!(response.status(), reqwest::StatusCode::CREATED); + let response_json = response + .json::() + .await + .unwrap(); + assert_eq!(response_json.filename, setup.file_name); + assert_eq!( + response_json.language, + coduck_backend::file_manager::Language::Python + ); + assert_eq!(response_json.category, "solution"); + + let expected_file_path = format!("uploads/{}/solution/{}", setup.problem_id, setup.file_name); + assert!(Path::new(&expected_file_path).exists()); + + let saved_content = fs::read(&expected_file_path).await.unwrap(); + assert_eq!(saved_content, setup.file_content); + + // 테스트 후 정리 (특정 problem_id만) + let test_upload_dir = format!("uploads/{}", setup.problem_id); + if Path::new(&test_upload_dir).exists() { + fs::remove_dir_all(&test_upload_dir).await.unwrap(); + } +} + +#[tokio::test] +async fn upload_file_handles_duplicate_filename() { + let setup = setup_test( + 1, + b"print('Hello, World!')", + "duplicate-test.py", + "boundary456", + ) + .await; + + // 첫 번째 파일 업로드 (성공) + let response1 = upload_file_request(&setup).await; + assert_eq!(response1.status(), reqwest::StatusCode::CREATED); + + // 두 번째 파일 업로드 (같은 파일명, 실패해야 함) + let response2 = upload_file_request(&setup).await; + + // 중복 파일 에러 확인 + assert_eq!(response2.status(), reqwest::StatusCode::CONFLICT); + + let error_response = response2.json::().await.unwrap(); + assert!(error_response.get("error").is_some()); + assert!(error_response["error"] + .as_str() + .unwrap() + .contains("already exists")); + + // 파일이 실제로 하나만 존재하는지 확인 + let expected_file_path = format!("uploads/{}/solution/{}", setup.problem_id, setup.file_name); + assert!(Path::new(&expected_file_path).exists()); + + // 테스트 후 정리 (특정 problem_id만) + let test_upload_dir = format!("uploads/{}", setup.problem_id); + if Path::new(&test_upload_dir).exists() { + fs::remove_dir_all(&test_upload_dir).await.unwrap(); + } +} From 95ea6ffde10bd4e138b102cf71b083bb12af498d Mon Sep 17 00:00:00 2001 From: reddevilmidzy Date: Sat, 28 Jun 2025 17:46:10 +0900 Subject: [PATCH 02/17] feat: Implement file search function --- src/file_manager/handlers.rs | 81 +++++++++++++++++++++++++- src/lib.rs | 6 +- tests/file_manager.rs | 106 +++++++++++++++++++++++++++-------- 3 files changed, 167 insertions(+), 26 deletions(-) diff --git a/src/file_manager/handlers.rs b/src/file_manager/handlers.rs index 08d0cd0..3e10c60 100644 --- a/src/file_manager/handlers.rs +++ b/src/file_manager/handlers.rs @@ -31,7 +31,8 @@ pub async fn upload_file( Json(serde_json::json!({ "error": format!("File '{}' already exists in this category", file_name) })), - ).into_response(); + ) + .into_response(); } let now = Utc::now(); @@ -60,3 +61,81 @@ pub async fn upload_file( }; (StatusCode::CREATED, Json(metadata)).into_response() } + +pub async fn get_file( + Path((problem_id, category, filename)): Path<(u32, String, String)>, +) -> impl IntoResponse { + let file_path = PathBuf::from(format!("uploads/{}/{}/{}", problem_id, category, filename)); + + if !file_path.exists() { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "error": format!("File '{}' not found in category '{}'", filename, category) + })), + ) + .into_response(); + } + + match fs::read(&file_path).await { + Ok(content) => { + let content_str = String::from_utf8_lossy(&content); + + ( + StatusCode::OK, + [("Content-Type", "text/html; charset=UTF-8")], + content_str.to_string(), + ) + .into_response() + } + Err(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "Failed to read file content" + })), + ) + .into_response(), + } +} + +pub async fn get_files_by_category( + Path((problem_id, category)): Path<(u32, String)>, +) -> impl IntoResponse { + let category_dir = PathBuf::from(format!("uploads/{}/{}", problem_id, category)); + + if !category_dir.exists() { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "error": format!("Category '{}' not found for problem {}", category, problem_id) + })), + ) + .into_response(); + } + + let mut files = Vec::new(); + + match fs::read_dir(&category_dir).await { + Ok(mut entries) => { + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + if path.is_file() { + if let Some(filename) = path.file_name().and_then(|f| f.to_str()) { + files.push(filename.to_string()); + } + } + } + } + Err(_) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "Failed to read directory" + })), + ) + .into_response(); + } + } + + (StatusCode::OK, Json(files)).into_response() +} diff --git a/src/lib.rs b/src/lib.rs index 5e881f1..e70a4dd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,7 +5,7 @@ use axum::{ Router, }; -use crate::file_manager::upload_file; +use crate::file_manager::{upload_file, get_file, get_files_by_category}; async fn health_check() -> &'static str { "OK" @@ -14,5 +14,7 @@ async fn health_check() -> &'static str { pub fn build_router() -> Router { Router::new() .route("/health", get(health_check)) - .route("/upload/{problem_id}/{category}", post(upload_file)) + .route("/problems/{problem_id}/{category}", post(upload_file)) + .route("/problems/{problem_id}/{category}/{filename}", get(get_file)) + .route("/problems/{problem_id}/{category}", get(get_files_by_category)) } diff --git a/tests/file_manager.rs b/tests/file_manager.rs index 3f5814f..cc9c3fe 100644 --- a/tests/file_manager.rs +++ b/tests/file_manager.rs @@ -5,17 +5,11 @@ struct TestSetup { problem_id: u32, file_content: Vec, file_name: String, - boundary: String, multipart_body: String, port: u16, } -async fn setup_test( - problem_id: u32, - file_content: &[u8], - file_name: &str, - boundary: &str, -) -> TestSetup { +async fn setup_test(problem_id: u32, file_content: &[u8], file_name: &str) -> TestSetup { // 테스트용 디렉토리만 정리 (uploads 전체가 아닌 특정 problem_id만) let test_upload_dir = format!("uploads/{}", problem_id); if Path::new(&test_upload_dir).exists() { @@ -23,11 +17,11 @@ async fn setup_test( } let multipart_body = format!( - "--{boundary}\r\n\ + "--boundary\r\n\ Content-Disposition: form-data; name=\"file\"; filename=\"{file_name}\"\r\n\ Content-Type: text/plain\r\n\r\n\ {}\r\n\ - --{boundary}--\r\n", + --boundary--\r\n", String::from_utf8_lossy(file_content) ); @@ -45,7 +39,6 @@ async fn setup_test( problem_id, file_content: file_content.to_vec(), file_name: file_name.to_string(), - boundary: boundary.to_string(), multipart_body, port, } @@ -55,14 +48,11 @@ async fn upload_file_request(setup: &TestSetup) -> reqwest::Response { let client = reqwest::Client::new(); client .post(&format!( - "http://127.0.0.1:{port}/upload/{problem_id}/solution", + "http://127.0.0.1:{port}/problems/{problem_id}/solution", port = setup.port, problem_id = setup.problem_id )) - .header( - "Content-Type", - format!("multipart/form-data; boundary={}", setup.boundary), - ) + .header("Content-Type", "multipart/form-data; boundary=boundary") .body(setup.multipart_body.clone()) .send() .await @@ -75,7 +65,6 @@ async fn upload_file_success() { 0, b"print(int(input()) + int(input()))", "aplusb-solution.py", - "boundary123", ) .await; @@ -108,13 +97,7 @@ async fn upload_file_success() { #[tokio::test] async fn upload_file_handles_duplicate_filename() { - let setup = setup_test( - 1, - b"print('Hello, World!')", - "duplicate-test.py", - "boundary456", - ) - .await; + let setup = setup_test(1, b"print('Hello, World!')", "duplicate-test.py").await; // 첫 번째 파일 업로드 (성공) let response1 = upload_file_request(&setup).await; @@ -143,3 +126,80 @@ async fn upload_file_handles_duplicate_filename() { fs::remove_dir_all(&test_upload_dir).await.unwrap(); } } + +#[tokio::test] +async fn get_file_success() { + let setup = setup_test(2, b"fn main() { println!(\"Hello, World!\"); }", "hello.rs").await; + + // 먼저 파일 업로드 + let upload_response = upload_file_request(&setup).await; + assert_eq!(upload_response.status(), reqwest::StatusCode::CREATED); + + // 파일 내용 조회 + let client = reqwest::Client::new(); + let response = client + .get(&format!( + "http://127.0.0.1:{port}/problems/{problem_id}/solution/{filename}", + port = setup.port, + problem_id = setup.problem_id, + filename = setup.file_name + )) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::OK); + + // Content-Type 확인 + let content_type = response + .headers() + .get("content-type") + .unwrap() + .to_str() + .unwrap(); + assert_eq!(content_type, "text/html; charset=UTF-8"); + + // 파일 내용 확인 + let file_content = response.text().await.unwrap(); + assert_eq!(file_content, "fn main() { println!(\"Hello, World!\"); }"); + + // 테스트 후 정리 + let test_upload_dir = format!("uploads/{}", setup.problem_id); + if Path::new(&test_upload_dir).exists() { + fs::remove_dir_all(&test_upload_dir).await.unwrap(); + } +} + +#[tokio::test] +async fn get_files_by_category_success() { + let setup = setup_test(3, b"print('Hello, World!')", "hello.py").await; + + // 파일 업로드 + let upload_response = upload_file_request(&setup).await; + assert_eq!(upload_response.status(), reqwest::StatusCode::CREATED); + + // 카테고리별 파일 목록 조회 + let client = reqwest::Client::new(); + let response = client + .get(&format!( + "http://127.0.0.1:{port}/problems/{problem_id}/{category}", + port = setup.port, + problem_id = setup.problem_id, + category = "solution" + )) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::OK); + let files: Vec = response.json().await.unwrap(); + + assert_eq!(files.len(), 1); + assert_eq!(files[0], setup.file_name); + + // 테스트 후 정리 + let test_upload_dir = format!("uploads/{}", setup.problem_id); + if Path::new(&test_upload_dir).exists() { + fs::remove_dir_all(&test_upload_dir).await.unwrap(); + } +} From 1b74206724c1e88f299f93254875d6dbe730592b Mon Sep 17 00:00:00 2001 From: reddevilmidzy Date: Sat, 28 Jun 2025 18:48:15 +0900 Subject: [PATCH 03/17] feat: Implement file deletion --- src/file_manager/handlers.rs | 35 ++++++++++++ src/lib.rs | 14 +++-- tests/file_manager.rs | 100 ++++++++++++++++++++++++++++------- 3 files changed, 124 insertions(+), 25 deletions(-) diff --git a/src/file_manager/handlers.rs b/src/file_manager/handlers.rs index 3e10c60..51c665b 100644 --- a/src/file_manager/handlers.rs +++ b/src/file_manager/handlers.rs @@ -11,6 +11,8 @@ use std::path::PathBuf; use tokio::fs; use uuid::Uuid; +//TODO upload path 숨기거나 설정 파일로 관리 + pub async fn upload_file( Path((problem_id, category)): Path<(u32, String)>, mut multipart: Multipart, @@ -139,3 +141,36 @@ pub async fn get_files_by_category( (StatusCode::OK, Json(files)).into_response() } + +pub async fn delete_file( + Path((problem_id, category, filename)): Path<(u32, String, String)>, +) -> impl IntoResponse { + let file_path = PathBuf::from(format!("uploads/{}/{}/{}", problem_id, category, filename)); + + if !file_path.exists() { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "error": format!("File '{}' not found in category '{}'", filename, category) + })), + ) + .into_response(); + } + + match fs::remove_file(&file_path).await { + Ok(_) => ( + StatusCode::OK, + Json(serde_json::json!({ + "message": format!("File '{}' deleted successfully", filename) + })), + ) + .into_response(), + Err(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "Failed to delete file" + })), + ) + .into_response(), + } +} diff --git a/src/lib.rs b/src/lib.rs index e70a4dd..2e0c6a4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,20 +1,24 @@ pub mod file_manager; use axum::{ - routing::{get, post}, + routing::{delete, get, post}, Router, }; -use crate::file_manager::{upload_file, get_file, get_files_by_category}; +use crate::file_manager::{delete_file, get_file, get_files_by_category, upload_file}; async fn health_check() -> &'static str { "OK" } pub fn build_router() -> Router { + let problems_router = Router::new() + .route("/{problem_id}/{category}", post(upload_file)) + .route("/{problem_id}/{category}/{filename}", get(get_file)) + .route("/{problem_id}/{category}", get(get_files_by_category)) + .route("/{problem_id}/{category}/{filename}", delete(delete_file)); + Router::new() .route("/health", get(health_check)) - .route("/problems/{problem_id}/{category}", post(upload_file)) - .route("/problems/{problem_id}/{category}/{filename}", get(get_file)) - .route("/problems/{problem_id}/{category}", get(get_files_by_category)) + .nest("/problems", problems_router) } diff --git a/tests/file_manager.rs b/tests/file_manager.rs index cc9c3fe..446495a 100644 --- a/tests/file_manager.rs +++ b/tests/file_manager.rs @@ -59,6 +59,13 @@ async fn upload_file_request(setup: &TestSetup) -> reqwest::Response { .unwrap() } +async fn cleanup_test(problem_id: u32) { + let test_upload_dir = format!("uploads/{}", problem_id); + if Path::new(&test_upload_dir).exists() { + fs::remove_dir_all(&test_upload_dir).await.unwrap(); + } +} + #[tokio::test] async fn upload_file_success() { let setup = setup_test( @@ -88,11 +95,7 @@ async fn upload_file_success() { let saved_content = fs::read(&expected_file_path).await.unwrap(); assert_eq!(saved_content, setup.file_content); - // 테스트 후 정리 (특정 problem_id만) - let test_upload_dir = format!("uploads/{}", setup.problem_id); - if Path::new(&test_upload_dir).exists() { - fs::remove_dir_all(&test_upload_dir).await.unwrap(); - } + cleanup_test(setup.problem_id).await; } #[tokio::test] @@ -120,11 +123,7 @@ async fn upload_file_handles_duplicate_filename() { let expected_file_path = format!("uploads/{}/solution/{}", setup.problem_id, setup.file_name); assert!(Path::new(&expected_file_path).exists()); - // 테스트 후 정리 (특정 problem_id만) - let test_upload_dir = format!("uploads/{}", setup.problem_id); - if Path::new(&test_upload_dir).exists() { - fs::remove_dir_all(&test_upload_dir).await.unwrap(); - } + cleanup_test(setup.problem_id).await; } #[tokio::test] @@ -163,11 +162,7 @@ async fn get_file_success() { let file_content = response.text().await.unwrap(); assert_eq!(file_content, "fn main() { println!(\"Hello, World!\"); }"); - // 테스트 후 정리 - let test_upload_dir = format!("uploads/{}", setup.problem_id); - if Path::new(&test_upload_dir).exists() { - fs::remove_dir_all(&test_upload_dir).await.unwrap(); - } + cleanup_test(setup.problem_id).await; } #[tokio::test] @@ -197,9 +192,74 @@ async fn get_files_by_category_success() { assert_eq!(files.len(), 1); assert_eq!(files[0], setup.file_name); - // 테스트 후 정리 - let test_upload_dir = format!("uploads/{}", setup.problem_id); - if Path::new(&test_upload_dir).exists() { - fs::remove_dir_all(&test_upload_dir).await.unwrap(); - } + cleanup_test(setup.problem_id).await; +} + +#[tokio::test] +async fn delete_file_success() { + let setup = setup_test(5, b"print('Hello, World!')", "delete-test.py").await; + + // 먼저 파일 업로드 + let upload_response = upload_file_request(&setup).await; + assert_eq!(upload_response.status(), reqwest::StatusCode::CREATED); + + // 파일이 존재하는지 확인 + let expected_file_path = format!("uploads/{}/solution/{}", setup.problem_id, setup.file_name); + assert!(Path::new(&expected_file_path).exists()); + + // 파일 삭제 + let client = reqwest::Client::new(); + let response = client + .delete(&format!( + "http://127.0.0.1:{port}/problems/{problem_id}/solution/{filename}", + port = setup.port, + problem_id = setup.problem_id, + filename = setup.file_name + )) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::OK); + + let delete_response = response.json::().await.unwrap(); + assert!(delete_response.get("message").is_some()); + assert!(delete_response["message"] + .as_str() + .unwrap() + .contains("deleted successfully")); + + // 파일이 실제로 삭제되었는지 확인 + assert!(!Path::new(&expected_file_path).exists()); + + cleanup_test(setup.problem_id).await; +} + +#[tokio::test] +async fn delete_file_not_found() { + let setup = setup_test(6, b"print('Hello, World!')", "not-found-test.py").await; + + // 파일을 업로드하지 않고 바로 삭제 시도 + let client = reqwest::Client::new(); + let response = client + .delete(&format!( + "http://127.0.0.1:{port}/problems/{problem_id}/solution/{filename}", + port = setup.port, + problem_id = setup.problem_id, + filename = "non-existent-file.py" + )) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::NOT_FOUND); + + let error_response = response.json::().await.unwrap(); + assert!(error_response.get("error").is_some()); + assert!(error_response["error"] + .as_str() + .unwrap() + .contains("not found")); + + cleanup_test(setup.problem_id).await; } From 061f51009970938868c3c0148a3d1771c2c94376 Mon Sep 17 00:00:00 2001 From: reddevilmidzy Date: Sat, 28 Jun 2025 19:19:03 +0900 Subject: [PATCH 04/17] feat: Implement file content and filename update function --- src/file_manager/handlers.rs | 87 ++++++++++++++++++- src/file_manager/models.rs | 11 +++ src/lib.rs | 13 ++- tests/file_manager.rs | 158 ++++++++++++++++++++++++++++++++++- 4 files changed, 262 insertions(+), 7 deletions(-) diff --git a/src/file_manager/handlers.rs b/src/file_manager/handlers.rs index 51c665b..f7335a2 100644 --- a/src/file_manager/handlers.rs +++ b/src/file_manager/handlers.rs @@ -1,4 +1,6 @@ -use crate::file_manager::{FileMetadata, Language}; +use crate::file_manager::{ + FileMetadata, Language, UpdateFileContentRequest, UpdateFilenameRequest, +}; use axum::{ extract::{Multipart, Path}, @@ -12,7 +14,6 @@ use tokio::fs; use uuid::Uuid; //TODO upload path 숨기거나 설정 파일로 관리 - pub async fn upload_file( Path((problem_id, category)): Path<(u32, String)>, mut multipart: Multipart, @@ -174,3 +175,85 @@ pub async fn delete_file( .into_response(), } } + +pub async fn update_file_content( + Path((problem_id, category, filename)): Path<(u32, String, String)>, + Json(update_request): Json, +) -> impl IntoResponse { + let file_path = PathBuf::from(format!("uploads/{}/{}/{}", problem_id, category, filename)); + + if !file_path.exists() { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "error": format!("File '{}' not found in category '{}'", filename, category) + })), + ) + .into_response(); + } + + match fs::write(&file_path, &update_request.content).await { + Ok(_) => ( + StatusCode::OK, + Json(serde_json::json!({ + "message": format!("File '{}' updated successfully", filename) + })), + ) + .into_response(), + Err(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "Failed to update file" + })), + ) + .into_response(), + } +} + +pub async fn update_filename( + Path((problem_id, category)): Path<(u32, String)>, + Json(update_request): Json, +) -> impl IntoResponse { + let file_path = PathBuf::from(format!( + "{}/{}/{}/{}", + UPLOAD_DIR, problem_id, category, update_request.old_filename + )); + + if !file_path.exists() { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "error": format!( + "File '{}' not found in category '{}'", + update_request.old_filename, category + ) + })), + ) + .into_response(); + } + + match fs::rename( + &file_path, + &file_path.with_file_name(&update_request.new_filename), + ) + .await + { + Ok(_) => ( + StatusCode::OK, + Json(serde_json::json!({ + "message": format!( + "File '{}' updated successfully to '{}'", + update_request.old_filename, update_request.new_filename + ) + })), + ) + .into_response(), + Err(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "Failed to update filename" + })), + ) + .into_response(), + } +} diff --git a/src/file_manager/models.rs b/src/file_manager/models.rs index 8c39a31..11f1dcf 100644 --- a/src/file_manager/models.rs +++ b/src/file_manager/models.rs @@ -12,6 +12,17 @@ pub struct FileMetadata { pub updated_at: DateTime, } +#[derive(Debug, Deserialize)] +pub struct UpdateFileContentRequest { + pub content: String, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateFilenameRequest { + pub old_filename: String, + pub new_filename: String, +} + #[derive(Debug, PartialEq, Serialize, Deserialize)] pub enum LanguageError { UnsupportedExtension(String), diff --git a/src/lib.rs b/src/lib.rs index 2e0c6a4..cc5b915 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,11 +1,13 @@ pub mod file_manager; use axum::{ - routing::{delete, get, post}, + routing::{delete, get, post, put}, Router, }; -use crate::file_manager::{delete_file, get_file, get_files_by_category, upload_file}; +use crate::file_manager::{ + delete_file, get_file, get_files_by_category, update_file_content, update_filename, upload_file, +}; async fn health_check() -> &'static str { "OK" @@ -16,7 +18,12 @@ pub fn build_router() -> Router { .route("/{problem_id}/{category}", post(upload_file)) .route("/{problem_id}/{category}/{filename}", get(get_file)) .route("/{problem_id}/{category}", get(get_files_by_category)) - .route("/{problem_id}/{category}/{filename}", delete(delete_file)); + .route("/{problem_id}/{category}/{filename}", delete(delete_file)) + .route( + "/{problem_id}/{category}/{filename}", + put(update_file_content), + ) + .route("/{problem_id}/{category}", put(update_filename)); Router::new() .route("/health", get(health_check)) diff --git a/tests/file_manager.rs b/tests/file_manager.rs index 446495a..27052c3 100644 --- a/tests/file_manager.rs +++ b/tests/file_manager.rs @@ -45,8 +45,7 @@ async fn setup_test(problem_id: u32, file_content: &[u8], file_name: &str) -> Te } async fn upload_file_request(setup: &TestSetup) -> reqwest::Response { - let client = reqwest::Client::new(); - client + reqwest::Client::new() .post(&format!( "http://127.0.0.1:{port}/problems/{problem_id}/solution", port = setup.port, @@ -263,3 +262,158 @@ async fn delete_file_not_found() { cleanup_test(setup.problem_id).await; } + +#[tokio::test] +async fn update_file_content_success() { + let setup = setup_test(7, b"print(int(input()) - int(input()))", "update-test.py").await; + + // 먼저 파일 업로드 + let upload_response = upload_file_request(&setup).await; + assert_eq!(upload_response.status(), reqwest::StatusCode::CREATED); + + // 파일 수정 + let client = reqwest::Client::new(); + let update_data = serde_json::json!({ + "content": "print(int(input()) + int(input()))" + }); + + let response = client + .put(&format!( + "http://127.0.0.1:{port}/problems/{problem_id}/solution/{filename}", + port = setup.port, + problem_id = setup.problem_id, + filename = setup.file_name + )) + .json(&update_data) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::OK); + + let update_response = response.json::().await.unwrap(); + assert!(update_response.get("message").is_some()); + assert!(update_response["message"] + .as_str() + .unwrap() + .contains("updated successfully")); + + // 파일 내용이 실제로 업데이트되었는지 확인 + let expected_file_path = format!("uploads/{}/solution/{}", setup.problem_id, setup.file_name); + let updated_content = fs::read_to_string(&expected_file_path).await.unwrap(); + assert_eq!(updated_content, "print(int(input()) + int(input()))"); + + cleanup_test(setup.problem_id).await; +} + +#[tokio::test] +async fn update_file_not_found() { + let setup = setup_test( + 8, + b"print(int(input()) - int(input()))", + "not-found-update-test.py", + ) + .await; + + // 파일을 업로드하지 않고 바로 수정 시도 + let client = reqwest::Client::new(); + let update_data = serde_json::json!({ + "content": "print('Updated content!')" + }); + + let response = client + .put(&format!( + "http://127.0.0.1:{port}/problems/{problem_id}/solution/{filename}", + port = setup.port, + problem_id = setup.problem_id, + filename = "non-existent-file.py" + )) + .json(&update_data) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::NOT_FOUND); + + let error_response = response.json::().await.unwrap(); + assert!(error_response.get("error").is_some()); + assert!(error_response["error"] + .as_str() + .unwrap() + .contains("not found")); + + cleanup_test(setup.problem_id).await; +} + +#[tokio::test] +async fn update_file_missing_content() { + let setup = setup_test(9, b"print('Hello, World!')", "missing-content-test.py").await; + + // 파일 업로드 + let upload_response = upload_file_request(&setup).await; + assert_eq!(upload_response.status(), reqwest::StatusCode::CREATED); + + // content 필드 없이 수정 시도 + let client = reqwest::Client::new(); + let update_data = serde_json::json!({ + "other_field": "some value" + }); + + let response = client + .put(&format!( + "http://127.0.0.1:{port}/problems/{problem_id}/solution/{filename}", + port = setup.port, + problem_id = setup.problem_id, + filename = setup.file_name + )) + .json(&update_data) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY); + cleanup_test(setup.problem_id).await; +} + +#[tokio::test] +async fn update_file_filename_success() { + let setup = setup_test(10, b"print(int(input()) + int(input()))", "aplusb.py").await; + + // 먼저 파일 업로드 + let upload_response = upload_file_request(&setup).await; + assert_eq!(upload_response.status(), reqwest::StatusCode::CREATED); + + // 파일 수정 + let client = reqwest::Client::new(); + let new_filename = "aplusb-AC.py"; + let update_data = serde_json::json!({ + "old_filename": setup.file_name, + "new_filename": new_filename + }); + + let response = client + .put(&format!( + "http://127.0.0.1:{port}/problems/{problem_id}/solution", + port = setup.port, + problem_id = setup.problem_id + )) + .json(&update_data) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::OK); + + let update_response = response.json::().await.unwrap(); + assert!(update_response.get("message").is_some()); + assert!(update_response["message"] + .as_str() + .unwrap() + .contains("updated successfully")); + + // 파일 이름이 실제로 업데이트되었는지 확인 + let expected_file_path = format!("uploads/{}/solution/{}", setup.problem_id, new_filename); + assert!(Path::new(&expected_file_path).exists()); + + cleanup_test(setup.problem_id).await; +} From 73874ffde17d0004ac8419a1092cd33d000c56fc Mon Sep 17 00:00:00 2001 From: reddevilmidzy Date: Sun, 29 Jun 2025 16:01:11 +0900 Subject: [PATCH 05/17] refactor: Change upload directory to constant --- src/file_manager/handlers.rs | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/file_manager/handlers.rs b/src/file_manager/handlers.rs index f7335a2..2683bd0 100644 --- a/src/file_manager/handlers.rs +++ b/src/file_manager/handlers.rs @@ -13,7 +13,8 @@ use std::path::PathBuf; use tokio::fs; use uuid::Uuid; -//TODO upload path 숨기거나 설정 파일로 관리 +const UPLOAD_DIR: &str = "uploads"; + pub async fn upload_file( Path((problem_id, category)): Path<(u32, String)>, mut multipart: Multipart, @@ -23,7 +24,7 @@ pub async fn upload_file( let data = field.bytes().await.unwrap(); let file_id = Uuid::new_v4().to_string(); - let upload_dir = PathBuf::from(format!("uploads/{}/{}", problem_id, category)); + let upload_dir = PathBuf::from(format!("{}/{}/{}", UPLOAD_DIR, problem_id, category)); fs::create_dir_all(&upload_dir).await.unwrap(); let file_path = upload_dir.join(&file_name); @@ -68,7 +69,10 @@ pub async fn upload_file( pub async fn get_file( Path((problem_id, category, filename)): Path<(u32, String, String)>, ) -> impl IntoResponse { - let file_path = PathBuf::from(format!("uploads/{}/{}/{}", problem_id, category, filename)); + let file_path = PathBuf::from(format!( + "{}/{}/{}/{}", + UPLOAD_DIR, problem_id, category, filename + )); if !file_path.exists() { return ( @@ -104,7 +108,7 @@ pub async fn get_file( pub async fn get_files_by_category( Path((problem_id, category)): Path<(u32, String)>, ) -> impl IntoResponse { - let category_dir = PathBuf::from(format!("uploads/{}/{}", problem_id, category)); + let category_dir = PathBuf::from(format!("{}/{}/{}", UPLOAD_DIR, problem_id, category)); if !category_dir.exists() { return ( @@ -146,7 +150,10 @@ pub async fn get_files_by_category( pub async fn delete_file( Path((problem_id, category, filename)): Path<(u32, String, String)>, ) -> impl IntoResponse { - let file_path = PathBuf::from(format!("uploads/{}/{}/{}", problem_id, category, filename)); + let file_path = PathBuf::from(format!( + "{}/{}/{}/{}", + UPLOAD_DIR, problem_id, category, filename + )); if !file_path.exists() { return ( @@ -180,7 +187,10 @@ pub async fn update_file_content( Path((problem_id, category, filename)): Path<(u32, String, String)>, Json(update_request): Json, ) -> impl IntoResponse { - let file_path = PathBuf::from(format!("uploads/{}/{}/{}", problem_id, category, filename)); + let file_path = PathBuf::from(format!( + "{}/{}/{}/{}", + UPLOAD_DIR, problem_id, category, filename + )); if !file_path.exists() { return ( From 2c791c4d15b6e1e8cf10e462816117fb1cd610f0 Mon Sep 17 00:00:00 2001 From: reddevilmidzy Date: Sun, 29 Jun 2025 17:05:37 +0900 Subject: [PATCH 06/17] feat: Implement to allow .in and .out --- src/file_manager/handlers.rs | 3 +-- src/file_manager/models.rs | 17 ++++++++++++----- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/file_manager/handlers.rs b/src/file_manager/handlers.rs index 2683bd0..80867b1 100644 --- a/src/file_manager/handlers.rs +++ b/src/file_manager/handlers.rs @@ -22,10 +22,8 @@ pub async fn upload_file( let field = multipart.next_field().await.unwrap().unwrap(); let file_name = field.file_name().unwrap().to_string(); let data = field.bytes().await.unwrap(); - let file_id = Uuid::new_v4().to_string(); let upload_dir = PathBuf::from(format!("{}/{}/{}", UPLOAD_DIR, problem_id, category)); - fs::create_dir_all(&upload_dir).await.unwrap(); let file_path = upload_dir.join(&file_name); @@ -53,6 +51,7 @@ pub async fn upload_file( } }; + fs::create_dir_all(&upload_dir).await.unwrap(); fs::write(&file_path, &data).await.unwrap(); let metadata = FileMetadata { id: file_id, diff --git a/src/file_manager/models.rs b/src/file_manager/models.rs index 11f1dcf..08a59a8 100644 --- a/src/file_manager/models.rs +++ b/src/file_manager/models.rs @@ -48,9 +48,13 @@ pub enum Language { Csharp, D, Go, + Input, Java, + Javascript, Kotlin, + Markdown, Ocaml, + Output, Pascal, Perl, Php, @@ -58,14 +62,16 @@ pub enum Language { Ruby, Rust, Scala, - Javascript, Typescript, Text, - Markdown, } impl Language { pub fn from_filename(filename: &str) -> Result { + if !filename.contains('.') || filename.starts_with('.') { + return Err(LanguageError::InvalidFilename); + } + let extension = filename .split('.') .last() @@ -79,7 +85,6 @@ impl Language { Self::from_extension(&extension) } - // TODO .io 와 .out 파일은 어떻게 처리할지 pub fn from_extension(extension: &str) -> Result { match extension { "c" => Ok(Language::C), @@ -87,9 +92,13 @@ impl Language { "cs" => Ok(Language::Csharp), "d" => Ok(Language::D), "go" => Ok(Language::Go), + "in" => Ok(Language::Input), "java" => Ok(Language::Java), + "js" => Ok(Language::Javascript), "kt" => Ok(Language::Kotlin), + "md" => Ok(Language::Markdown), "ml" => Ok(Language::Ocaml), + "out" => Ok(Language::Output), "pas" => Ok(Language::Pascal), "pl" => Ok(Language::Perl), "php" => Ok(Language::Php), @@ -97,10 +106,8 @@ impl Language { "rb" => Ok(Language::Ruby), "rs" => Ok(Language::Rust), "scala" => Ok(Language::Scala), - "js" => Ok(Language::Javascript), "ts" => Ok(Language::Typescript), "txt" => Ok(Language::Text), - "md" => Ok(Language::Markdown), _ => Err(LanguageError::UnsupportedExtension(extension.to_string())), } } From 8260f962be9c5504b77c54b580471c36e77f595d Mon Sep 17 00:00:00 2001 From: reddevilmidzy Date: Sun, 29 Jun 2025 17:23:42 +0900 Subject: [PATCH 07/17] test: reorganize test structure with separate modules --- .../handlers.rs} | 0 tests/file_manager/language.rs | 89 +++++++++++++++++++ tests/file_manager/mod.rs | 2 + tests/mod.rs | 1 + 4 files changed, 92 insertions(+) rename tests/{file_manager.rs => file_manager/handlers.rs} (100%) create mode 100644 tests/file_manager/language.rs create mode 100644 tests/file_manager/mod.rs create mode 100644 tests/mod.rs diff --git a/tests/file_manager.rs b/tests/file_manager/handlers.rs similarity index 100% rename from tests/file_manager.rs rename to tests/file_manager/handlers.rs diff --git a/tests/file_manager/language.rs b/tests/file_manager/language.rs new file mode 100644 index 0000000..8e7c441 --- /dev/null +++ b/tests/file_manager/language.rs @@ -0,0 +1,89 @@ +use coduck_backend::file_manager::{Language, LanguageError}; + +#[test] +fn test_language_from_filename_success() { + assert_eq!( + Language::from_filename("solution.py").unwrap(), + Language::Python + ); + assert_eq!( + Language::from_filename("test.PY").unwrap(), + Language::Python + ); + + assert_eq!( + Language::from_filename("Solution.java").unwrap(), + Language::Java + ); + + assert_eq!(Language::from_filename("code.cpp").unwrap(), Language::Cpp); + + assert_eq!( + Language::from_filename("input.in").unwrap(), + Language::Input + ); + + assert_eq!( + Language::from_filename("output.out").unwrap(), + Language::Output + ); +} + +#[test] +fn test_language_from_filename_errors() { + assert_eq!( + Language::from_filename(".java").unwrap_err(), + LanguageError::InvalidFilename + ); + + assert_eq!( + Language::from_filename("py").unwrap_err(), + LanguageError::InvalidFilename + ); + + assert_eq!( + Language::from_filename("file.").unwrap_err(), + LanguageError::InvalidFilename + ); + + assert_eq!( + Language::from_filename("run.exe").unwrap_err(), + LanguageError::UnsupportedExtension("exe".to_string()) + ); + + assert_eq!( + Language::from_filename("").unwrap_err(), + LanguageError::InvalidFilename + ); +} + +#[test] +fn test_language_from_extension_success() { + assert_eq!(Language::from_extension("py").unwrap(), Language::Python); + assert_eq!(Language::from_extension("rs").unwrap(), Language::Rust); + assert_eq!(Language::from_extension("java").unwrap(), Language::Java); + assert_eq!(Language::from_extension("cpp").unwrap(), Language::Cpp); + assert_eq!( + Language::from_extension("js").unwrap(), + Language::Javascript + ); + assert_eq!( + Language::from_extension("ts").unwrap(), + Language::Typescript + ); + assert_eq!(Language::from_extension("c").unwrap(), Language::C); + assert_eq!(Language::from_extension("cs").unwrap(), Language::Csharp); + assert_eq!(Language::from_extension("go").unwrap(), Language::Go); + assert_eq!(Language::from_extension("kt").unwrap(), Language::Kotlin); + assert_eq!(Language::from_extension("rb").unwrap(), Language::Ruby); + assert_eq!(Language::from_extension("php").unwrap(), Language::Php); + assert_eq!(Language::from_extension("pl").unwrap(), Language::Perl); + assert_eq!(Language::from_extension("pas").unwrap(), Language::Pascal); + assert_eq!(Language::from_extension("ml").unwrap(), Language::Ocaml); + assert_eq!(Language::from_extension("scala").unwrap(), Language::Scala); + assert_eq!(Language::from_extension("d").unwrap(), Language::D); + assert_eq!(Language::from_extension("txt").unwrap(), Language::Text); + assert_eq!(Language::from_extension("md").unwrap(), Language::Markdown); + assert_eq!(Language::from_extension("in").unwrap(), Language::Input); + assert_eq!(Language::from_extension("out").unwrap(), Language::Output); +} diff --git a/tests/file_manager/mod.rs b/tests/file_manager/mod.rs new file mode 100644 index 0000000..6d22f4a --- /dev/null +++ b/tests/file_manager/mod.rs @@ -0,0 +1,2 @@ +pub mod handlers; +pub mod language; diff --git a/tests/mod.rs b/tests/mod.rs new file mode 100644 index 0000000..fa70974 --- /dev/null +++ b/tests/mod.rs @@ -0,0 +1 @@ +pub mod file_manager; From 3f6ca334781ce556b3408c7db6f026a66b188fdf Mon Sep 17 00:00:00 2001 From: reddevilmidzy Date: Sun, 29 Jun 2025 18:11:07 +0900 Subject: [PATCH 08/17] refactor: improve error handling with anyhow --- Cargo.toml | 1 + src/file_manager/handlers.rs | 121 ++++++++++++++++++++++++++--------- 2 files changed, 91 insertions(+), 31 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 57957e5..a0e4c60 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ path = "src/main.rs" name = "coduck-backend" [dependencies] +anyhow = "1.0" axum = { version = "0.8.4", features = ["json", "multipart"] } chrono = { version = "0.4.38", features = ["serde"] } reqwest = { version = "0.12.19", features = ["json", "rustls-tls"] } diff --git a/src/file_manager/handlers.rs b/src/file_manager/handlers.rs index 80867b1..32f8605 100644 --- a/src/file_manager/handlers.rs +++ b/src/file_manager/handlers.rs @@ -2,32 +2,49 @@ use crate::file_manager::{ FileMetadata, Language, UpdateFileContentRequest, UpdateFilenameRequest, }; +use anyhow::{anyhow, Result}; use axum::{ extract::{Multipart, Path}, + http::StatusCode, response::IntoResponse, Json, }; use chrono::Utc; -use reqwest::StatusCode; use std::path::PathBuf; use tokio::fs; use uuid::Uuid; const UPLOAD_DIR: &str = "uploads"; +async fn file_exists(file_path: &PathBuf) -> bool { + tokio::fs::metadata(file_path).await.is_ok() +} + pub async fn upload_file( Path((problem_id, category)): Path<(u32, String)>, - mut multipart: Multipart, + multipart: Multipart, ) -> impl IntoResponse { - let field = multipart.next_field().await.unwrap().unwrap(); - let file_name = field.file_name().unwrap().to_string(); - let data = field.bytes().await.unwrap(); + let (file_name, data) = match extract_multipart_data(multipart).await { + Ok(data) => data, + Err(e) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": e.to_string() + })), + ) + .into_response(); + } + }; + let file_id = Uuid::new_v4().to_string(); - let upload_dir = PathBuf::from(format!("{}/{}/{}", UPLOAD_DIR, problem_id, category)); + let upload_dir = PathBuf::from(UPLOAD_DIR) + .join(problem_id.to_string()) + .join(&category); let file_path = upload_dir.join(&file_name); - if file_path.exists() { + if file_exists(&file_path).await { return ( StatusCode::CONFLICT, Json(serde_json::json!({ @@ -51,8 +68,16 @@ pub async fn upload_file( } }; - fs::create_dir_all(&upload_dir).await.unwrap(); - fs::write(&file_path, &data).await.unwrap(); + if let Err(e) = save_file(&upload_dir, &file_path, &data).await { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": e.to_string() + })), + ) + .into_response(); + } + let metadata = FileMetadata { id: file_id, filename: file_name, @@ -65,15 +90,47 @@ pub async fn upload_file( (StatusCode::CREATED, Json(metadata)).into_response() } +async fn save_file( + upload_dir: &PathBuf, + file_path: &PathBuf, + data: &axum::body::Bytes, +) -> Result<()> { + fs::create_dir_all(upload_dir) + .await + .map_err(|_| anyhow!("Failed to create directory"))?; + + fs::write(file_path, data) + .await + .map_err(|_| anyhow!("Failed to write file"))?; + + Ok(()) +} + +async fn extract_multipart_data(mut multipart: Multipart) -> Result<(String, axum::body::Bytes)> { + let field = multipart + .next_field() + .await? + .ok_or_else(|| anyhow!("Missing multipart field"))?; + + let file_name = field + .file_name() + .ok_or_else(|| anyhow!("Missing file name in multipart field"))? + .to_string(); + + let data = field.bytes().await?; + + Ok((file_name, data)) +} + pub async fn get_file( Path((problem_id, category, filename)): Path<(u32, String, String)>, ) -> impl IntoResponse { - let file_path = PathBuf::from(format!( - "{}/{}/{}/{}", - UPLOAD_DIR, problem_id, category, filename - )); + let file_path = PathBuf::from(UPLOAD_DIR) + .join(problem_id.to_string()) + .join(&category) + .join(&filename); - if !file_path.exists() { + if !file_exists(&file_path).await { return ( StatusCode::NOT_FOUND, Json(serde_json::json!({ @@ -107,9 +164,11 @@ pub async fn get_file( pub async fn get_files_by_category( Path((problem_id, category)): Path<(u32, String)>, ) -> impl IntoResponse { - let category_dir = PathBuf::from(format!("{}/{}/{}", UPLOAD_DIR, problem_id, category)); + let category_dir = PathBuf::from(UPLOAD_DIR) + .join(problem_id.to_string()) + .join(&category); - if !category_dir.exists() { + if !file_exists(&category_dir).await { return ( StatusCode::NOT_FOUND, Json(serde_json::json!({ @@ -149,12 +208,12 @@ pub async fn get_files_by_category( pub async fn delete_file( Path((problem_id, category, filename)): Path<(u32, String, String)>, ) -> impl IntoResponse { - let file_path = PathBuf::from(format!( - "{}/{}/{}/{}", - UPLOAD_DIR, problem_id, category, filename - )); + let file_path = PathBuf::from(UPLOAD_DIR) + .join(problem_id.to_string()) + .join(&category) + .join(&filename); - if !file_path.exists() { + if !file_exists(&file_path).await { return ( StatusCode::NOT_FOUND, Json(serde_json::json!({ @@ -186,12 +245,12 @@ pub async fn update_file_content( Path((problem_id, category, filename)): Path<(u32, String, String)>, Json(update_request): Json, ) -> impl IntoResponse { - let file_path = PathBuf::from(format!( - "{}/{}/{}/{}", - UPLOAD_DIR, problem_id, category, filename - )); + let file_path = PathBuf::from(UPLOAD_DIR) + .join(problem_id.to_string()) + .join(&category) + .join(&filename); - if !file_path.exists() { + if !file_exists(&file_path).await { return ( StatusCode::NOT_FOUND, Json(serde_json::json!({ @@ -223,12 +282,12 @@ pub async fn update_filename( Path((problem_id, category)): Path<(u32, String)>, Json(update_request): Json, ) -> impl IntoResponse { - let file_path = PathBuf::from(format!( - "{}/{}/{}/{}", - UPLOAD_DIR, problem_id, category, update_request.old_filename - )); + let file_path = PathBuf::from(UPLOAD_DIR) + .join(problem_id.to_string()) + .join(&category) + .join(&update_request.old_filename); - if !file_path.exists() { + if !file_exists(&file_path).await { return ( StatusCode::NOT_FOUND, Json(serde_json::json!({ From 83daacf0e4d5dd1465d621f4312372d1d2b40b32 Mon Sep 17 00:00:00 2001 From: Redddy Date: Thu, 3 Jul 2025 16:38:38 +0900 Subject: [PATCH 09/17] fix: Modify variable names and error messages Co-authored-by: Chris Ohk --- src/file_manager/handlers.rs | 32 ++++++++++++++++---------------- src/file_manager/models.rs | 2 +- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/file_manager/handlers.rs b/src/file_manager/handlers.rs index 32f8605..5e52530 100644 --- a/src/file_manager/handlers.rs +++ b/src/file_manager/handlers.rs @@ -24,7 +24,7 @@ pub async fn upload_file( Path((problem_id, category)): Path<(u32, String)>, multipart: Multipart, ) -> impl IntoResponse { - let (file_name, data) = match extract_multipart_data(multipart).await { + let (filename, data) = match extract_multipart_data(multipart).await { Ok(data) => data, Err(e) => { return ( @@ -42,20 +42,20 @@ pub async fn upload_file( .join(problem_id.to_string()) .join(&category); - let file_path = upload_dir.join(&file_name); + let file_path = upload_dir.join(&filename); if file_exists(&file_path).await { return ( StatusCode::CONFLICT, Json(serde_json::json!({ - "error": format!("File '{}' already exists in this category", file_name) + "error": format!("File '{filename}' already exists in this category") })), ) .into_response(); } let now = Utc::now(); - let language = match Language::from_filename(&file_name) { + let language = match Language::from_filename(&filename) { Ok(lang) => lang, Err(e) => { return ( @@ -80,7 +80,7 @@ pub async fn upload_file( let metadata = FileMetadata { id: file_id, - filename: file_name, + filename: filename, language: language, category: category, size: data.len() as u64, @@ -112,14 +112,14 @@ async fn extract_multipart_data(mut multipart: Multipart) -> Result<(String, axu .await? .ok_or_else(|| anyhow!("Missing multipart field"))?; - let file_name = field + let filename = field .file_name() - .ok_or_else(|| anyhow!("Missing file name in multipart field"))? + .ok_or_else(|| anyhow!("Missing filename in multipart field"))? .to_string(); let data = field.bytes().await?; - Ok((file_name, data)) + Ok((filename, data)) } pub async fn get_file( @@ -134,7 +134,7 @@ pub async fn get_file( return ( StatusCode::NOT_FOUND, Json(serde_json::json!({ - "error": format!("File '{}' not found in category '{}'", filename, category) + "error": format!("File '{filename}' not found in category '{category}'") })), ) .into_response(); @@ -172,7 +172,7 @@ pub async fn get_files_by_category( return ( StatusCode::NOT_FOUND, Json(serde_json::json!({ - "error": format!("Category '{}' not found for problem {}", category, problem_id) + "error": format!("Category '{category}' not found for problem {problem_id}") })), ) .into_response(); @@ -217,7 +217,7 @@ pub async fn delete_file( return ( StatusCode::NOT_FOUND, Json(serde_json::json!({ - "error": format!("File '{}' not found in category '{}'", filename, category) + "error": format!("File '{filename}' not found in category '{category}'") })), ) .into_response(); @@ -227,7 +227,7 @@ pub async fn delete_file( Ok(_) => ( StatusCode::OK, Json(serde_json::json!({ - "message": format!("File '{}' deleted successfully", filename) + "message": format!("File '{filename}' deleted successfully") })), ) .into_response(), @@ -254,7 +254,7 @@ pub async fn update_file_content( return ( StatusCode::NOT_FOUND, Json(serde_json::json!({ - "error": format!("File '{}' not found in category '{}'", filename, category) + "error": format!("File '{filename}' not found in category '{category}'") })), ) .into_response(); @@ -264,7 +264,7 @@ pub async fn update_file_content( Ok(_) => ( StatusCode::OK, Json(serde_json::json!({ - "message": format!("File '{}' updated successfully", filename) + "message": format!("File '{filename}' updated successfully") })), ) .into_response(), @@ -292,8 +292,8 @@ pub async fn update_filename( StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": format!( - "File '{}' not found in category '{}'", - update_request.old_filename, category + "File '{}' not found in category '{category}'", + update_request.old_filename ) })), ) diff --git a/src/file_manager/models.rs b/src/file_manager/models.rs index 08a59a8..9c35769 100644 --- a/src/file_manager/models.rs +++ b/src/file_manager/models.rs @@ -33,7 +33,7 @@ impl std::fmt::Display for LanguageError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { LanguageError::UnsupportedExtension(extension) => { - write!(f, "Unsupported file extension: {}", extension) + write!(f, "Unsupported file extension: {extension}") } LanguageError::InvalidFilename => write!(f, "Invalid filename"), } From b8716432883880071785410f635a982930f67b8d Mon Sep 17 00:00:00 2001 From: reddevilmidzy Date: Thu, 3 Jul 2025 18:06:35 +0900 Subject: [PATCH 10/17] test: Move unit tests for Language into models.rs --- src/file_manager/models.rs | 105 +++++++++++++++++++++++++++++++++ tests/file_manager/language.rs | 89 ---------------------------- tests/file_manager/mod.rs | 1 - 3 files changed, 105 insertions(+), 90 deletions(-) delete mode 100644 tests/file_manager/language.rs diff --git a/src/file_manager/models.rs b/src/file_manager/models.rs index 9c35769..895b3cf 100644 --- a/src/file_manager/models.rs +++ b/src/file_manager/models.rs @@ -112,3 +112,108 @@ impl Language { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_language_from_filename_success() { + assert_eq!( + Language::from_filename("solution.py").unwrap(), + Language::Python + ); + assert_eq!( + Language::from_filename("test.PY").unwrap(), + Language::Python + ); + + assert_eq!( + Language::from_filename("Solution.java").unwrap(), + Language::Java + ); + + assert_eq!(Language::from_filename("code.cpp").unwrap(), Language::Cpp); + + assert_eq!( + Language::from_filename("input.in").unwrap(), + Language::Input + ); + + assert_eq!( + Language::from_filename("output.out").unwrap(), + Language::Output + ); + } + + #[test] + fn test_language_from_filename_errors() { + assert_eq!( + Language::from_filename(".java").unwrap_err(), + LanguageError::InvalidFilename + ); + + assert_eq!( + Language::from_filename("py").unwrap_err(), + LanguageError::InvalidFilename + ); + + assert_eq!( + Language::from_filename("file.").unwrap_err(), + LanguageError::InvalidFilename + ); + + assert_eq!( + Language::from_filename("run.exe").unwrap_err(), + LanguageError::UnsupportedExtension("exe".to_string()) + ); + + assert_eq!( + Language::from_filename("").unwrap_err(), + LanguageError::InvalidFilename + ); + } + + #[test] + fn test_language_from_extension_success() { + assert_eq!(Language::from_extension("py").unwrap(), Language::Python); + assert_eq!(Language::from_extension("rs").unwrap(), Language::Rust); + assert_eq!(Language::from_extension("java").unwrap(), Language::Java); + assert_eq!(Language::from_extension("cpp").unwrap(), Language::Cpp); + assert_eq!( + Language::from_extension("js").unwrap(), + Language::Javascript + ); + assert_eq!( + Language::from_extension("ts").unwrap(), + Language::Typescript + ); + assert_eq!(Language::from_extension("c").unwrap(), Language::C); + assert_eq!(Language::from_extension("cs").unwrap(), Language::Csharp); + assert_eq!(Language::from_extension("go").unwrap(), Language::Go); + assert_eq!(Language::from_extension("kt").unwrap(), Language::Kotlin); + assert_eq!(Language::from_extension("rb").unwrap(), Language::Ruby); + assert_eq!(Language::from_extension("php").unwrap(), Language::Php); + assert_eq!(Language::from_extension("pl").unwrap(), Language::Perl); + assert_eq!(Language::from_extension("pas").unwrap(), Language::Pascal); + assert_eq!(Language::from_extension("ml").unwrap(), Language::Ocaml); + assert_eq!(Language::from_extension("scala").unwrap(), Language::Scala); + assert_eq!(Language::from_extension("d").unwrap(), Language::D); + assert_eq!(Language::from_extension("txt").unwrap(), Language::Text); + assert_eq!(Language::from_extension("md").unwrap(), Language::Markdown); + assert_eq!(Language::from_extension("in").unwrap(), Language::Input); + assert_eq!(Language::from_extension("out").unwrap(), Language::Output); + } + + #[test] + fn test_language_from_extension_errors() { + assert_eq!( + Language::from_extension("").unwrap_err(), + LanguageError::UnsupportedExtension("".to_string()) + ); + assert_eq!( + Language::from_extension("no").unwrap_err(), + LanguageError::UnsupportedExtension("no".to_string()) + ); + } +} diff --git a/tests/file_manager/language.rs b/tests/file_manager/language.rs deleted file mode 100644 index 8e7c441..0000000 --- a/tests/file_manager/language.rs +++ /dev/null @@ -1,89 +0,0 @@ -use coduck_backend::file_manager::{Language, LanguageError}; - -#[test] -fn test_language_from_filename_success() { - assert_eq!( - Language::from_filename("solution.py").unwrap(), - Language::Python - ); - assert_eq!( - Language::from_filename("test.PY").unwrap(), - Language::Python - ); - - assert_eq!( - Language::from_filename("Solution.java").unwrap(), - Language::Java - ); - - assert_eq!(Language::from_filename("code.cpp").unwrap(), Language::Cpp); - - assert_eq!( - Language::from_filename("input.in").unwrap(), - Language::Input - ); - - assert_eq!( - Language::from_filename("output.out").unwrap(), - Language::Output - ); -} - -#[test] -fn test_language_from_filename_errors() { - assert_eq!( - Language::from_filename(".java").unwrap_err(), - LanguageError::InvalidFilename - ); - - assert_eq!( - Language::from_filename("py").unwrap_err(), - LanguageError::InvalidFilename - ); - - assert_eq!( - Language::from_filename("file.").unwrap_err(), - LanguageError::InvalidFilename - ); - - assert_eq!( - Language::from_filename("run.exe").unwrap_err(), - LanguageError::UnsupportedExtension("exe".to_string()) - ); - - assert_eq!( - Language::from_filename("").unwrap_err(), - LanguageError::InvalidFilename - ); -} - -#[test] -fn test_language_from_extension_success() { - assert_eq!(Language::from_extension("py").unwrap(), Language::Python); - assert_eq!(Language::from_extension("rs").unwrap(), Language::Rust); - assert_eq!(Language::from_extension("java").unwrap(), Language::Java); - assert_eq!(Language::from_extension("cpp").unwrap(), Language::Cpp); - assert_eq!( - Language::from_extension("js").unwrap(), - Language::Javascript - ); - assert_eq!( - Language::from_extension("ts").unwrap(), - Language::Typescript - ); - assert_eq!(Language::from_extension("c").unwrap(), Language::C); - assert_eq!(Language::from_extension("cs").unwrap(), Language::Csharp); - assert_eq!(Language::from_extension("go").unwrap(), Language::Go); - assert_eq!(Language::from_extension("kt").unwrap(), Language::Kotlin); - assert_eq!(Language::from_extension("rb").unwrap(), Language::Ruby); - assert_eq!(Language::from_extension("php").unwrap(), Language::Php); - assert_eq!(Language::from_extension("pl").unwrap(), Language::Perl); - assert_eq!(Language::from_extension("pas").unwrap(), Language::Pascal); - assert_eq!(Language::from_extension("ml").unwrap(), Language::Ocaml); - assert_eq!(Language::from_extension("scala").unwrap(), Language::Scala); - assert_eq!(Language::from_extension("d").unwrap(), Language::D); - assert_eq!(Language::from_extension("txt").unwrap(), Language::Text); - assert_eq!(Language::from_extension("md").unwrap(), Language::Markdown); - assert_eq!(Language::from_extension("in").unwrap(), Language::Input); - assert_eq!(Language::from_extension("out").unwrap(), Language::Output); -} diff --git a/tests/file_manager/mod.rs b/tests/file_manager/mod.rs index 6d22f4a..c3d4495 100644 --- a/tests/file_manager/mod.rs +++ b/tests/file_manager/mod.rs @@ -1,2 +1 @@ pub mod handlers; -pub mod language; From 3362fbc3f4a7a7c6b8c6847ec3a4f39e4e0213e4 Mon Sep 17 00:00:00 2001 From: reddevilmidzy Date: Thu, 3 Jul 2025 22:55:26 +0900 Subject: [PATCH 11/17] refactor: Change pub to pub(crate) for file_manager module items --- src/file_manager/mod.rs | 6 +++--- tests/file_manager/handlers.rs | 30 +++++++++++++++--------------- tests/file_manager/mod.rs | 2 +- tests/mod.rs | 2 +- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/file_manager/mod.rs b/src/file_manager/mod.rs index cd09d88..bd9731a 100644 --- a/src/file_manager/mod.rs +++ b/src/file_manager/mod.rs @@ -1,5 +1,5 @@ -pub mod handlers; -pub mod models; +mod handlers; +mod models; -pub use handlers::*; +pub(crate) use handlers::*; pub use models::*; diff --git a/tests/file_manager/handlers.rs b/tests/file_manager/handlers.rs index 27052c3..43fb117 100644 --- a/tests/file_manager/handlers.rs +++ b/tests/file_manager/handlers.rs @@ -4,12 +4,12 @@ use tokio::fs; struct TestSetup { problem_id: u32, file_content: Vec, - file_name: String, + filename: String, multipart_body: String, port: u16, } -async fn setup_test(problem_id: u32, file_content: &[u8], file_name: &str) -> TestSetup { +async fn setup_test(problem_id: u32, file_content: &[u8], filename: &str) -> TestSetup { // 테스트용 디렉토리만 정리 (uploads 전체가 아닌 특정 problem_id만) let test_upload_dir = format!("uploads/{}", problem_id); if Path::new(&test_upload_dir).exists() { @@ -18,7 +18,7 @@ async fn setup_test(problem_id: u32, file_content: &[u8], file_name: &str) -> Te let multipart_body = format!( "--boundary\r\n\ - Content-Disposition: form-data; name=\"file\"; filename=\"{file_name}\"\r\n\ + Content-Disposition: form-data; name=\"file\"; filename=\"{filename}\"\r\n\ Content-Type: text/plain\r\n\r\n\ {}\r\n\ --boundary--\r\n", @@ -38,7 +38,7 @@ async fn setup_test(problem_id: u32, file_content: &[u8], file_name: &str) -> Te TestSetup { problem_id, file_content: file_content.to_vec(), - file_name: file_name.to_string(), + filename: filename.to_string(), multipart_body, port, } @@ -81,14 +81,14 @@ async fn upload_file_success() { .json::() .await .unwrap(); - assert_eq!(response_json.filename, setup.file_name); + assert_eq!(response_json.filename, setup.filename); assert_eq!( response_json.language, coduck_backend::file_manager::Language::Python ); assert_eq!(response_json.category, "solution"); - let expected_file_path = format!("uploads/{}/solution/{}", setup.problem_id, setup.file_name); + let expected_file_path = format!("uploads/{}/solution/{}", setup.problem_id, setup.filename); assert!(Path::new(&expected_file_path).exists()); let saved_content = fs::read(&expected_file_path).await.unwrap(); @@ -119,7 +119,7 @@ async fn upload_file_handles_duplicate_filename() { .contains("already exists")); // 파일이 실제로 하나만 존재하는지 확인 - let expected_file_path = format!("uploads/{}/solution/{}", setup.problem_id, setup.file_name); + let expected_file_path = format!("uploads/{}/solution/{}", setup.problem_id, setup.filename); assert!(Path::new(&expected_file_path).exists()); cleanup_test(setup.problem_id).await; @@ -140,7 +140,7 @@ async fn get_file_success() { "http://127.0.0.1:{port}/problems/{problem_id}/solution/{filename}", port = setup.port, problem_id = setup.problem_id, - filename = setup.file_name + filename = setup.filename )) .send() .await @@ -189,7 +189,7 @@ async fn get_files_by_category_success() { let files: Vec = response.json().await.unwrap(); assert_eq!(files.len(), 1); - assert_eq!(files[0], setup.file_name); + assert_eq!(files[0], setup.filename); cleanup_test(setup.problem_id).await; } @@ -203,7 +203,7 @@ async fn delete_file_success() { assert_eq!(upload_response.status(), reqwest::StatusCode::CREATED); // 파일이 존재하는지 확인 - let expected_file_path = format!("uploads/{}/solution/{}", setup.problem_id, setup.file_name); + let expected_file_path = format!("uploads/{}/solution/{}", setup.problem_id, setup.filename); assert!(Path::new(&expected_file_path).exists()); // 파일 삭제 @@ -213,7 +213,7 @@ async fn delete_file_success() { "http://127.0.0.1:{port}/problems/{problem_id}/solution/{filename}", port = setup.port, problem_id = setup.problem_id, - filename = setup.file_name + filename = setup.filename )) .send() .await @@ -282,7 +282,7 @@ async fn update_file_content_success() { "http://127.0.0.1:{port}/problems/{problem_id}/solution/{filename}", port = setup.port, problem_id = setup.problem_id, - filename = setup.file_name + filename = setup.filename )) .json(&update_data) .send() @@ -299,7 +299,7 @@ async fn update_file_content_success() { .contains("updated successfully")); // 파일 내용이 실제로 업데이트되었는지 확인 - let expected_file_path = format!("uploads/{}/solution/{}", setup.problem_id, setup.file_name); + let expected_file_path = format!("uploads/{}/solution/{}", setup.problem_id, setup.filename); let updated_content = fs::read_to_string(&expected_file_path).await.unwrap(); assert_eq!(updated_content, "print(int(input()) + int(input()))"); @@ -364,7 +364,7 @@ async fn update_file_missing_content() { "http://127.0.0.1:{port}/problems/{problem_id}/solution/{filename}", port = setup.port, problem_id = setup.problem_id, - filename = setup.file_name + filename = setup.filename )) .json(&update_data) .send() @@ -387,7 +387,7 @@ async fn update_file_filename_success() { let client = reqwest::Client::new(); let new_filename = "aplusb-AC.py"; let update_data = serde_json::json!({ - "old_filename": setup.file_name, + "old_filename": setup.filename, "new_filename": new_filename }); diff --git a/tests/file_manager/mod.rs b/tests/file_manager/mod.rs index c3d4495..6470330 100644 --- a/tests/file_manager/mod.rs +++ b/tests/file_manager/mod.rs @@ -1 +1 @@ -pub mod handlers; +mod handlers; diff --git a/tests/mod.rs b/tests/mod.rs index fa70974..aa6a160 100644 --- a/tests/mod.rs +++ b/tests/mod.rs @@ -1 +1 @@ -pub mod file_manager; +mod file_manager; From 77de2b8977bb671a6946d4ffe501dd73b4bdc731 Mon Sep 17 00:00:00 2001 From: reddevilmidzy Date: Thu, 3 Jul 2025 23:12:59 +0900 Subject: [PATCH 12/17] refactor: Move error related structs to errors directory --- src/errors/language_error.rs | 18 ++++++++++++++++++ src/errors/mod.rs | 3 +++ src/file_manager/models.rs | 19 ++----------------- src/lib.rs | 1 + 4 files changed, 24 insertions(+), 17 deletions(-) create mode 100644 src/errors/language_error.rs create mode 100644 src/errors/mod.rs diff --git a/src/errors/language_error.rs b/src/errors/language_error.rs new file mode 100644 index 0000000..ea0bb02 --- /dev/null +++ b/src/errors/language_error.rs @@ -0,0 +1,18 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +pub enum LanguageError { + UnsupportedExtension(String), + InvalidFilename, +} + +impl std::fmt::Display for LanguageError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LanguageError::UnsupportedExtension(extension) => { + write!(f, "Unsupported file extension: {extension}") + } + LanguageError::InvalidFilename => write!(f, "Invalid filename"), + } + } +} diff --git a/src/errors/mod.rs b/src/errors/mod.rs new file mode 100644 index 0000000..f80451e --- /dev/null +++ b/src/errors/mod.rs @@ -0,0 +1,3 @@ +mod language_error; + +pub(crate) use language_error::*; diff --git a/src/file_manager/models.rs b/src/file_manager/models.rs index 895b3cf..f9b4d16 100644 --- a/src/file_manager/models.rs +++ b/src/file_manager/models.rs @@ -1,3 +1,5 @@ +use crate::errors::LanguageError; + use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -23,23 +25,6 @@ pub struct UpdateFilenameRequest { pub new_filename: String, } -#[derive(Debug, PartialEq, Serialize, Deserialize)] -pub enum LanguageError { - UnsupportedExtension(String), - InvalidFilename, -} - -impl std::fmt::Display for LanguageError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - LanguageError::UnsupportedExtension(extension) => { - write!(f, "Unsupported file extension: {extension}") - } - LanguageError::InvalidFilename => write!(f, "Invalid filename"), - } - } -} - #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Language { diff --git a/src/lib.rs b/src/lib.rs index cc5b915..2c3e91d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +mod errors; pub mod file_manager; use axum::{ From eefeee12260c9767a5189f42567f065d9a89bac7 Mon Sep 17 00:00:00 2001 From: reddevilmidzy Date: Mon, 7 Jul 2025 18:58:29 +0900 Subject: [PATCH 13/17] refactor: Language extension mapping to use static HashMap --- src/file_manager/models.rs | 59 +++++++++++++++++++++----------------- 1 file changed, 33 insertions(+), 26 deletions(-) diff --git a/src/file_manager/models.rs b/src/file_manager/models.rs index f9b4d16..ba1c4a4 100644 --- a/src/file_manager/models.rs +++ b/src/file_manager/models.rs @@ -2,6 +2,7 @@ use crate::errors::LanguageError; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, sync::LazyLock}; #[derive(Debug, Serialize, Deserialize)] pub struct FileMetadata { @@ -25,7 +26,7 @@ pub struct UpdateFilenameRequest { pub new_filename: String, } -#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)] #[serde(rename_all = "lowercase")] pub enum Language { C, @@ -51,6 +52,32 @@ pub enum Language { Text, } +static EXTENSION_TO_LANGUAGE: LazyLock> = LazyLock::new(|| { + HashMap::from([ + ("c", Language::C), + ("cpp", Language::Cpp), + ("cs", Language::Csharp), + ("d", Language::D), + ("go", Language::Go), + ("in", Language::Input), + ("java", Language::Java), + ("js", Language::Javascript), + ("kt", Language::Kotlin), + ("md", Language::Markdown), + ("ml", Language::Ocaml), + ("out", Language::Output), + ("pas", Language::Pascal), + ("pl", Language::Perl), + ("php", Language::Php), + ("py", Language::Python), + ("rb", Language::Ruby), + ("rs", Language::Rust), + ("scala", Language::Scala), + ("ts", Language::Typescript), + ("txt", Language::Text), + ]) +}); + impl Language { pub fn from_filename(filename: &str) -> Result { if !filename.contains('.') || filename.starts_with('.') { @@ -59,7 +86,7 @@ impl Language { let extension = filename .split('.') - .last() + .next_back() .ok_or(LanguageError::InvalidFilename)? .to_lowercase(); @@ -71,30 +98,10 @@ impl Language { } pub fn from_extension(extension: &str) -> Result { - match extension { - "c" => Ok(Language::C), - "cpp" => Ok(Language::Cpp), - "cs" => Ok(Language::Csharp), - "d" => Ok(Language::D), - "go" => Ok(Language::Go), - "in" => Ok(Language::Input), - "java" => Ok(Language::Java), - "js" => Ok(Language::Javascript), - "kt" => Ok(Language::Kotlin), - "md" => Ok(Language::Markdown), - "ml" => Ok(Language::Ocaml), - "out" => Ok(Language::Output), - "pas" => Ok(Language::Pascal), - "pl" => Ok(Language::Perl), - "php" => Ok(Language::Php), - "py" => Ok(Language::Python), - "rb" => Ok(Language::Ruby), - "rs" => Ok(Language::Rust), - "scala" => Ok(Language::Scala), - "ts" => Ok(Language::Typescript), - "txt" => Ok(Language::Text), - _ => Err(LanguageError::UnsupportedExtension(extension.to_string())), - } + EXTENSION_TO_LANGUAGE + .get(&extension.to_lowercase() as &str) + .cloned() + .ok_or(LanguageError::UnsupportedExtension(extension.to_string())) } } From 2c5dc38fd5b9414a14e7ba79dfe6b44ccefc621b Mon Sep 17 00:00:00 2001 From: reddevilmidzy Date: Mon, 7 Jul 2025 19:04:32 +0900 Subject: [PATCH 14/17] test: rename test functions to start with verbs for clarity --- src/file_manager/models.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/file_manager/models.rs b/src/file_manager/models.rs index ba1c4a4..e8a42b2 100644 --- a/src/file_manager/models.rs +++ b/src/file_manager/models.rs @@ -110,7 +110,7 @@ mod tests { use super::*; #[test] - fn test_language_from_filename_success() { + fn can_parse_language_from_valid_filename() { assert_eq!( Language::from_filename("solution.py").unwrap(), Language::Python @@ -139,7 +139,7 @@ mod tests { } #[test] - fn test_language_from_filename_errors() { + fn fails_to_parse_language_from_invalid_filename() { assert_eq!( Language::from_filename(".java").unwrap_err(), LanguageError::InvalidFilename @@ -167,7 +167,7 @@ mod tests { } #[test] - fn test_language_from_extension_success() { + fn can_parse_language_from_valid_extension() { assert_eq!(Language::from_extension("py").unwrap(), Language::Python); assert_eq!(Language::from_extension("rs").unwrap(), Language::Rust); assert_eq!(Language::from_extension("java").unwrap(), Language::Java); @@ -198,7 +198,7 @@ mod tests { } #[test] - fn test_language_from_extension_errors() { + fn fails_to_parse_language_from_invalid_extension() { assert_eq!( Language::from_extension("").unwrap_err(), LanguageError::UnsupportedExtension("".to_string()) From e8a78f8076c10a8655661b37441ce07187b547f9 Mon Sep 17 00:00:00 2001 From: reddevilmidzy Date: Wed, 9 Jul 2025 17:52:50 +0900 Subject: [PATCH 15/17] refactor: change language_error.rs to language.rs --- src/errors/{language_error.rs => language.rs} | 0 src/errors/mod.rs | 4 ++-- 2 files changed, 2 insertions(+), 2 deletions(-) rename src/errors/{language_error.rs => language.rs} (100%) diff --git a/src/errors/language_error.rs b/src/errors/language.rs similarity index 100% rename from src/errors/language_error.rs rename to src/errors/language.rs diff --git a/src/errors/mod.rs b/src/errors/mod.rs index f80451e..2ff9686 100644 --- a/src/errors/mod.rs +++ b/src/errors/mod.rs @@ -1,3 +1,3 @@ -mod language_error; +mod language; -pub(crate) use language_error::*; +pub(crate) use language::*; From 03ebfedb4bb41a9cf1e8065403d943490b4ebcd5 Mon Sep 17 00:00:00 2001 From: reddevilmidzy Date: Wed, 9 Jul 2025 18:02:30 +0900 Subject: [PATCH 16/17] refactor: store timestamps as i64 nanoseconds instead of DateTime --- src/file_manager/handlers.rs | 4 +++- src/file_manager/models.rs | 5 ++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/file_manager/handlers.rs b/src/file_manager/handlers.rs index 5e52530..d0e9a6c 100644 --- a/src/file_manager/handlers.rs +++ b/src/file_manager/handlers.rs @@ -54,7 +54,9 @@ pub async fn upload_file( .into_response(); } - let now = Utc::now(); + let now = Utc::now() + .timestamp_nanos_opt() + .expect("Failed to get timestamp"); let language = match Language::from_filename(&filename) { Ok(lang) => lang, Err(e) => { diff --git a/src/file_manager/models.rs b/src/file_manager/models.rs index e8a42b2..b050069 100644 --- a/src/file_manager/models.rs +++ b/src/file_manager/models.rs @@ -1,6 +1,5 @@ use crate::errors::LanguageError; -use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::{collections::HashMap, sync::LazyLock}; @@ -11,8 +10,8 @@ pub struct FileMetadata { pub language: Language, pub category: String, pub size: u64, - pub created_at: DateTime, - pub updated_at: DateTime, + pub created_at: i64, + pub updated_at: i64, } #[derive(Debug, Deserialize)] From 5187ea1dae09564ac17478252b73614ca949b906 Mon Sep 17 00:00:00 2001 From: reddevilmidzy Date: Wed, 9 Jul 2025 18:08:36 +0900 Subject: [PATCH 17/17] refactor: remove unused file extension mappings --- src/file_manager/models.rs | 51 ---------------------------------- tests/file_manager/handlers.rs | 12 ++++++-- 2 files changed, 10 insertions(+), 53 deletions(-) diff --git a/src/file_manager/models.rs b/src/file_manager/models.rs index b050069..ea1747a 100644 --- a/src/file_manager/models.rs +++ b/src/file_manager/models.rs @@ -28,51 +28,21 @@ pub struct UpdateFilenameRequest { #[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)] #[serde(rename_all = "lowercase")] pub enum Language { - C, Cpp, - Csharp, - D, - Go, Input, Java, - Javascript, - Kotlin, - Markdown, - Ocaml, Output, - Pascal, - Perl, - Php, Python, - Ruby, - Rust, - Scala, - Typescript, Text, } static EXTENSION_TO_LANGUAGE: LazyLock> = LazyLock::new(|| { HashMap::from([ - ("c", Language::C), ("cpp", Language::Cpp), - ("cs", Language::Csharp), - ("d", Language::D), - ("go", Language::Go), ("in", Language::Input), ("java", Language::Java), - ("js", Language::Javascript), - ("kt", Language::Kotlin), - ("md", Language::Markdown), - ("ml", Language::Ocaml), ("out", Language::Output), - ("pas", Language::Pascal), - ("pl", Language::Perl), - ("php", Language::Php), ("py", Language::Python), - ("rb", Language::Ruby), - ("rs", Language::Rust), - ("scala", Language::Scala), - ("ts", Language::Typescript), ("txt", Language::Text), ]) }); @@ -168,30 +138,9 @@ mod tests { #[test] fn can_parse_language_from_valid_extension() { assert_eq!(Language::from_extension("py").unwrap(), Language::Python); - assert_eq!(Language::from_extension("rs").unwrap(), Language::Rust); assert_eq!(Language::from_extension("java").unwrap(), Language::Java); assert_eq!(Language::from_extension("cpp").unwrap(), Language::Cpp); - assert_eq!( - Language::from_extension("js").unwrap(), - Language::Javascript - ); - assert_eq!( - Language::from_extension("ts").unwrap(), - Language::Typescript - ); - assert_eq!(Language::from_extension("c").unwrap(), Language::C); - assert_eq!(Language::from_extension("cs").unwrap(), Language::Csharp); - assert_eq!(Language::from_extension("go").unwrap(), Language::Go); - assert_eq!(Language::from_extension("kt").unwrap(), Language::Kotlin); - assert_eq!(Language::from_extension("rb").unwrap(), Language::Ruby); - assert_eq!(Language::from_extension("php").unwrap(), Language::Php); - assert_eq!(Language::from_extension("pl").unwrap(), Language::Perl); - assert_eq!(Language::from_extension("pas").unwrap(), Language::Pascal); - assert_eq!(Language::from_extension("ml").unwrap(), Language::Ocaml); - assert_eq!(Language::from_extension("scala").unwrap(), Language::Scala); - assert_eq!(Language::from_extension("d").unwrap(), Language::D); assert_eq!(Language::from_extension("txt").unwrap(), Language::Text); - assert_eq!(Language::from_extension("md").unwrap(), Language::Markdown); assert_eq!(Language::from_extension("in").unwrap(), Language::Input); assert_eq!(Language::from_extension("out").unwrap(), Language::Output); } diff --git a/tests/file_manager/handlers.rs b/tests/file_manager/handlers.rs index 43fb117..a8a3861 100644 --- a/tests/file_manager/handlers.rs +++ b/tests/file_manager/handlers.rs @@ -127,7 +127,12 @@ async fn upload_file_handles_duplicate_filename() { #[tokio::test] async fn get_file_success() { - let setup = setup_test(2, b"fn main() { println!(\"Hello, World!\"); }", "hello.rs").await; + let setup = setup_test( + 2, + b"#include \nint main() { std::cout << \"Hello, World!\" << std::endl; return 0; }", + "hello.cpp", + ) + .await; // 먼저 파일 업로드 let upload_response = upload_file_request(&setup).await; @@ -159,7 +164,10 @@ async fn get_file_success() { // 파일 내용 확인 let file_content = response.text().await.unwrap(); - assert_eq!(file_content, "fn main() { println!(\"Hello, World!\"); }"); + assert_eq!( + file_content, + "#include \nint main() { std::cout << \"Hello, World!\" << std::endl; return 0; }" + ); cleanup_test(setup.problem_id).await; }