feat: migrate file storage to postgres with sqlx

This commit is contained in:
2026-05-01 01:16:45 +03:00
parent e6b5cb75ba
commit 0aa87d61e5
6 changed files with 1292 additions and 108 deletions

1098
backend/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,10 @@ tower-cookies = "0.11.0"
tracing = "0.1.44" tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
lazy-regex = "3.6.0" lazy-regex = "3.6.0"
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono"] }
chrono = { version = "0.4.44", features = ["serde"] }
dotenvy = "0.15.7"
[dev-dependencies] [dev-dependencies]
axum-test = "20.0.0" axum-test = "20.0.0"
serial_test = "3.4.0"

View File

@@ -0,0 +1,7 @@
CREATE TABLE file_records (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
file_type TEXT NOT NULL,
size BIGINT NOT NULL,
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

View File

@@ -11,7 +11,7 @@ use tracing::info;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use crate::{ use crate::{
model::FileController, model::FileRepository,
web::{ web::{
mw_auth::{mw_ctx_resolver, mw_require_auth}, mw_auth::{mw_ctx_resolver, mw_require_auth},
routes_file::routes_file, routes_file::routes_file,
@@ -31,10 +31,10 @@ async fn main() -> Result<()> {
.with(tracing_subscriber::fmt::layer()) .with(tracing_subscriber::fmt::layer())
.init(); .init();
let file_controller = FileController::new().await?; let file_repository = FileRepository::new().await?;
let routes_file = let routes_file =
routes_file(file_controller.clone()).route_layer(middleware::from_fn(mw_require_auth)); routes_file(file_repository.clone()).route_layer(middleware::from_fn(mw_require_auth));
let app = Router::new() let app = Router::new()
.nest("/api", routes_file) .nest("/api", routes_file)
@@ -43,7 +43,7 @@ async fn main() -> Result<()> {
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
.layer(middleware::map_response(main_response_mapper)) .layer(middleware::map_response(main_response_mapper))
.layer(middleware::from_fn_with_state( .layer(middleware::from_fn_with_state(
file_controller, file_repository,
mw_ctx_resolver, mw_ctx_resolver,
)) ))
.layer(CookieManagerLayer::new()) .layer(CookieManagerLayer::new())

View File

@@ -1,67 +1,120 @@
use std::sync::{Arc, Mutex};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::{PgPool, prelude::FromRow};
use std::fmt::Display;
use crate::error::LoftError; use crate::error::LoftError;
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize, FromRow)]
pub struct File { pub struct FileRecord {
pub id: u64, // make uuid pub id: i64,
// pub user_id: i64,
pub name: String, pub name: String,
pub file_type: String, // make enum pub file_type: String,
pub size: i64,
pub uploaded_at: chrono::DateTime<chrono::Utc>,
} }
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
pub struct FileToCreate { pub struct FileToCreate {
pub name: String, pub name: String,
pub file_type: String, // make enum pub file_type: FileType,
pub size: i64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum FileType {
Image,
Video,
Document,
}
impl Display for FileType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FileType::Image => write!(f, "Image"),
FileType::Video => write!(f, "Video"),
FileType::Document => write!(f, "Document"),
}
}
} }
#[derive(Clone)] #[derive(Clone)]
pub struct FileController { pub struct FileRepository {
file_storage: Arc<Mutex<Vec<Option<File>>>>, pub pool: PgPool,
} }
impl FileController { impl FileRepository {
pub async fn new() -> Result<Self, LoftError> { pub async fn new() -> Result<Self, LoftError> {
Ok(Self { dotenvy::dotenv().ok();
file_storage: Arc::default(), let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
}) let pool = PgPool::connect(&database_url).await.unwrap();
Ok(Self { pool })
} }
pub async fn upload_file(&self, file_to_create: FileToCreate) -> Result<File, LoftError> { pub async fn upload_file(&self, file_to_create: FileToCreate) -> Result<FileRecord, LoftError> {
let mut file_storage = self.file_storage.lock().unwrap(); let file = sqlx::query_as!(
let id = file_storage.len() as u64; FileRecord,
let file = File { r#"
id, INSERT INTO file_records (name, file_type, size)
name: file_to_create.name, VALUES ($1, $2, $3)
file_type: file_to_create.file_type, RETURNING *
}; "#,
file_storage.push(Some(file.clone())); file_to_create.name,
file_to_create.file_type.to_string(),
file_to_create.size as i64
)
.fetch_one(&self.pool)
.await
.unwrap();
Ok(file) Ok(file)
} }
pub async fn download_file(&self, file_id: u64) -> Result<File, LoftError> { pub async fn download_file(&self, file_id: i64) -> Result<FileRecord, LoftError> {
let file_storage = self.file_storage.lock().unwrap(); sqlx::query_as!(
file_storage FileRecord,
.iter() r#"
.flatten() SELECT *
.find(|f| f.id == file_id) FROM file_records fr
WHERE fr.id = $1
"#,
file_id
)
.fetch_optional(&self.pool)
.await
.unwrap()
.ok_or(LoftError::FileIdNotFound) .ok_or(LoftError::FileIdNotFound)
.cloned()
} }
pub async fn delete_file(&self, file_id: u64) -> Result<File, LoftError> { pub async fn delete_file(&self, file_id: i64) -> Result<FileRecord, LoftError> {
let mut file_storage = self.file_storage.lock().unwrap(); sqlx::query_as!(
let file = file_storage FileRecord,
.get_mut(file_id as usize) r#"
.and_then(|f| f.take()); DELETE FROM file_records
file.ok_or(LoftError::FileIdNotFound) WHERE id = $1
RETURNING *
"#,
file_id
)
.fetch_optional(&self.pool)
.await
.unwrap()
.ok_or(LoftError::FileIdNotFound)
} }
pub async fn list_files(&self) -> Result<Vec<File>, LoftError> { pub async fn list_files(&self) -> Result<Vec<FileRecord>, LoftError> {
let file_storage = self.file_storage.lock().unwrap(); let files = sqlx::query_as!(
let files = file_storage.iter().flatten().cloned().collect(); FileRecord,
r#"
SELECT *
FROM file_records fr
"#
)
.fetch_all(&self.pool)
.await
.unwrap();
Ok(files) Ok(files)
} }
} }
@@ -70,59 +123,86 @@ impl FileController {
mod tests { mod tests {
use super::*; use super::*;
async fn fc() -> Result<FileController, LoftError> { async fn file_repository() -> Result<FileRepository, LoftError> {
Ok(FileController::new().await.unwrap()) Ok(FileRepository::new().await.unwrap())
} }
fn new_file(name: &str) -> FileToCreate { fn new_file_record(name: &str, file_type: FileType, size: i64) -> FileToCreate {
FileToCreate { FileToCreate {
name: name.to_string(), name: name.to_string(),
file_type: "text".to_string(), file_type,
size,
} }
} }
#[tokio::test] #[tokio::test]
#[serial_test::serial]
async fn test_upload_and_list() { async fn test_upload_and_list() {
let fc = fc().await.unwrap(); let file_repository = file_repository().await.unwrap();
fc.upload_file(new_file("a.txt")).await.unwrap(); sqlx::query!("TRUNCATE TABLE file_records")
fc.upload_file(new_file("b.txt")).await.unwrap(); .execute(&file_repository.pool)
let files = fc.list_files().await.unwrap(); .await
.unwrap();
let file_a = new_file_record("a", FileType::Document, 10);
let file_b = new_file_record("b", FileType::Document, 10);
file_repository.upload_file(file_a).await.unwrap();
file_repository.upload_file(file_b).await.unwrap();
let files = file_repository.list_files().await.unwrap();
assert_eq!(files.len(), 2); assert_eq!(files.len(), 2);
} }
#[tokio::test] #[tokio::test]
#[serial_test::serial]
async fn test_download() { async fn test_download() {
let fc = fc().await.unwrap(); let file_repository = file_repository().await.unwrap();
let uploaded = fc.upload_file(new_file("a.txt")).await.unwrap(); sqlx::query!("TRUNCATE TABLE file_records")
let downloaded = fc.download_file(uploaded.id).await.unwrap(); .execute(&file_repository.pool)
assert_eq!(downloaded.name, "a.txt"); .await
.unwrap();
let file_a = new_file_record("a", FileType::Document, 10);
let uploaded = file_repository.upload_file(file_a).await.unwrap();
let downloaded = file_repository.download_file(uploaded.id).await.unwrap();
assert_eq!(downloaded.name, "a");
} }
#[tokio::test] #[tokio::test]
#[serial_test::serial]
async fn test_download_not_found() { async fn test_download_not_found() {
let fc = fc().await.unwrap(); let file_repository = file_repository().await.unwrap();
assert!(matches!( assert!(matches!(
fc.download_file(99).await, file_repository.download_file(99).await,
Err(LoftError::FileIdNotFound) Err(LoftError::FileIdNotFound)
)); ));
} }
#[tokio::test] #[tokio::test]
#[serial_test::serial]
async fn test_delete() { async fn test_delete() {
let fc = fc().await.unwrap(); let file_repository = file_repository().await.unwrap();
let uploaded = fc.upload_file(new_file("a.txt")).await.unwrap(); sqlx::query!("TRUNCATE TABLE file_records")
fc.delete_file(uploaded.id).await.unwrap(); .execute(&file_repository.pool)
.await
.unwrap();
let file_a = new_file_record("a", FileType::Document, 10);
let uploaded = file_repository.upload_file(file_a).await.unwrap();
file_repository.delete_file(uploaded.id).await.unwrap();
assert!(matches!( assert!(matches!(
fc.download_file(uploaded.id).await, file_repository.download_file(uploaded.id).await,
Err(LoftError::FileIdNotFound) Err(LoftError::FileIdNotFound)
)); ));
} }
#[tokio::test] #[tokio::test]
#[serial_test::serial]
async fn test_delete_not_found() { async fn test_delete_not_found() {
let fc = fc().await.unwrap(); let file_repository = file_repository().await.unwrap();
assert!(matches!( assert!(matches!(
fc.delete_file(99).await, file_repository.delete_file(99).await,
Err(LoftError::FileIdNotFound) Err(LoftError::FileIdNotFound)
)); ));
} }

View File

@@ -7,53 +7,53 @@ use tracing::info;
use crate::{ use crate::{
error::LoftError, error::LoftError,
model::{File, FileController, FileToCreate}, model::{FileRecord, FileRepository, FileToCreate},
}; };
pub fn routes_file(file_controller: FileController) -> Router { pub fn routes_file(file_repository: FileRepository) -> Router {
Router::new() Router::new()
.route("/files", get(list_files).post(upload_file)) .route("/files", get(list_files).post(upload_file))
.route("/files/{id}", get(download_file).delete(delete_file)) .route("/files/{id}", get(download_file).delete(delete_file))
.with_state(file_controller) .with_state(file_repository)
} }
async fn upload_file( async fn upload_file(
State(file_controller): State<FileController>, State(file_repository): State<FileRepository>,
Json(file_to_create): Json<FileToCreate>, Json(file_to_create): Json<FileToCreate>,
) -> Result<Json<File>, LoftError> { ) -> Result<Json<FileRecord>, LoftError> {
info!("handler: upload_file"); info!("handler: upload_file");
let file = file_controller.upload_file(file_to_create).await?; let file = file_repository.upload_file(file_to_create).await?;
Ok(Json(file)) Ok(Json(file))
} }
async fn download_file( async fn download_file(
State(file_controller): State<FileController>, State(file_repository): State<FileRepository>,
Path(file_id): Path<u64>, Path(file_id): Path<u64>,
) -> Result<Json<File>, LoftError> { ) -> Result<Json<FileRecord>, LoftError> {
info!("handler: download_file"); info!("handler: download_file");
let file = file_controller.download_file(file_id).await?; let file = file_repository.download_file(file_id as i64).await?;
Ok(Json(file)) Ok(Json(file))
} }
async fn delete_file( async fn delete_file(
State(file_controller): State<FileController>, State(file_repository): State<FileRepository>,
Path(file_id): Path<u64>, Path(file_id): Path<u64>,
) -> Result<Json<File>, LoftError> { ) -> Result<Json<FileRecord>, LoftError> {
info!("handler: delete_file"); info!("handler: delete_file");
let file = file_controller.delete_file(file_id).await?; let file = file_repository.delete_file(file_id as i64).await?;
Ok(Json(file)) Ok(Json(file))
} }
async fn list_files( async fn list_files(
State(file_controller): State<FileController>, State(file_repository): State<FileRepository>,
// can add a filters param here // can add a filters param here
) -> Result<Json<Vec<File>>, LoftError> { ) -> Result<Json<Vec<FileRecord>>, LoftError> {
info!("handler: list_files"); info!("handler: list_files");
let files = file_controller.list_files().await?; let files = file_repository.list_files().await?;
Ok(Json(files)) Ok(Json(files))
} }
@@ -65,7 +65,7 @@ mod tests {
use tower_cookies::CookieManagerLayer; use tower_cookies::CookieManagerLayer;
use crate::{ use crate::{
model::FileController, model::FileRepository,
web::{ web::{
mw_auth::{mw_ctx_resolver, mw_require_auth}, mw_auth::{mw_ctx_resolver, mw_require_auth},
routes_file::routes_file, routes_file::routes_file,
@@ -77,13 +77,13 @@ mod tests {
const BAD_AUTH_COOKIE: &str = "auth-token=user-1.0123456789"; const BAD_AUTH_COOKIE: &str = "auth-token=user-1.0123456789";
async fn test_server() -> TestServer { async fn test_server() -> TestServer {
let file_controller = FileController::new().await.unwrap(); let file_repository = FileRepository::new().await.unwrap();
let routes_file = let routes_file =
routes_file(file_controller.clone()).route_layer(middleware::from_fn(mw_require_auth)); routes_file(file_repository.clone()).route_layer(middleware::from_fn(mw_require_auth));
let app = Router::new() let app = Router::new()
.nest("/api", routes_file) .nest("/api", routes_file)
.layer(middleware::from_fn_with_state( .layer(middleware::from_fn_with_state(
file_controller, file_repository,
mw_ctx_resolver, mw_ctx_resolver,
)) ))
.layer(CookieManagerLayer::new()); .layer(CookieManagerLayer::new());
@@ -111,13 +111,20 @@ mod tests {
let server = test_server().await; let server = test_server().await;
server server
.post("/api/files") .post("/api/files")
.json(&json!({"name": "a.txt", "file_type": "text"})) .json(&json!({"name": "a", "file_type": "Document"}))
.await .await
.assert_status_unauthorized(); .assert_status_unauthorized();
} }
#[tokio::test] #[tokio::test]
#[serial_test::serial]
async fn test_list_files_empty() { async fn test_list_files_empty() {
let file_repository = FileRepository::new().await.unwrap();
sqlx::query!("TRUNCATE TABLE file_records")
.execute(&file_repository.pool)
.await
.unwrap();
let server = test_server().await; let server = test_server().await;
server server
.get("/api/files") .get("/api/files")
@@ -128,17 +135,25 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
#[serial_test::serial]
async fn test_upload_and_list_files() { async fn test_upload_and_list_files() {
let file_repository = FileRepository::new().await.unwrap();
sqlx::query!("TRUNCATE TABLE file_records")
.execute(&file_repository.pool)
.await
.unwrap();
let server = test_server().await; let server = test_server().await;
let res = server let res = server
.post("/api/files") .post("/api/files")
.add_header(axum::http::header::COOKIE, AUTH_COOKIE) .add_header(axum::http::header::COOKIE, AUTH_COOKIE)
.json(&json!({"name": "a.txt", "file_type": "text"})) .json(&json!({"name": "a", "file_type": "Document", "size": 10}))
.await; .await;
res.assert_status_ok(); res.assert_status_ok();
let file = res.json::<serde_json::Value>(); let file = res.json::<serde_json::Value>();
assert_eq!(file["name"], "a.txt"); assert_eq!(file["name"], "a");
assert_eq!(file["id"], 0); assert_eq!(file["file_type"], "Document");
assert_eq!(file["size"], 10);
let list = server let list = server
.get("/api/files") .get("/api/files")
@@ -149,22 +164,25 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
#[serial_test::serial]
async fn test_download_file() { async fn test_download_file() {
let server = test_server().await; let server = test_server().await;
server let post_res = server
.post("/api/files") .post("/api/files")
.add_header(axum::http::header::COOKIE, AUTH_COOKIE) .add_header(axum::http::header::COOKIE, AUTH_COOKIE)
.json(&json!({"name": "b.txt", "file_type": "text"})) .json(&json!({"name": "b", "file_type": "Document", "size": 10}))
.await; .await;
let id = post_res.json::<serde_json::Value>()["id"].as_i64().unwrap();
let res = server let res = server
.get("/api/files/0") .get(&format!("/api/files/{id}"))
.add_header(axum::http::header::COOKIE, AUTH_COOKIE) .add_header(axum::http::header::COOKIE, AUTH_COOKIE)
.await; .await;
res.assert_status_ok(); res.assert_status_ok();
assert_eq!(res.json::<serde_json::Value>()["name"], "b.txt"); assert_eq!(res.json::<serde_json::Value>()["name"], "b");
} }
#[tokio::test] #[tokio::test]
#[serial_test::serial]
async fn test_download_file_not_found() { async fn test_download_file_not_found() {
let server = test_server().await; let server = test_server().await;
server server
@@ -175,26 +193,31 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
#[serial_test::serial]
async fn test_delete_file() { async fn test_delete_file() {
let server = test_server().await; let server = test_server().await;
server let post_res = server
.post("/api/files") .post("/api/files")
.add_header(axum::http::header::COOKIE, AUTH_COOKIE) .add_header(axum::http::header::COOKIE, AUTH_COOKIE)
.json(&json!({"name": "c.txt", "file_type": "text"})) .json(&json!({"name": "c", "file_type": "Document", "size": 10}))
.await; .await;
let id = post_res.json::<serde_json::Value>()["id"].as_i64().unwrap();
server server
.delete("/api/files/0") .delete(&format!("/api/files/{id}"))
.add_header(axum::http::header::COOKIE, AUTH_COOKIE) .add_header(axum::http::header::COOKIE, AUTH_COOKIE)
.await .await
.assert_status_ok(); .assert_status_ok();
server server
.get("/api/files/0") .get(&format!("/api/files/{id}"))
.add_header(axum::http::header::COOKIE, AUTH_COOKIE) .add_header(axum::http::header::COOKIE, AUTH_COOKIE)
.await .await
.assert_status_not_found(); .assert_status_not_found();
} }
#[tokio::test] #[tokio::test]
#[serial_test::serial]
async fn test_delete_file_not_found() { async fn test_delete_file_not_found() {
let server = test_server().await; let server = test_server().await;
server server