chore: scaffold axum server with in-memory CRUD, auth stub and tracing

This commit is contained in:
2026-04-19 16:03:04 +03:00
parent 132de93600
commit 90c9b9d6ff
10 changed files with 1756 additions and 13 deletions

7
Justfile Normal file
View File

@@ -0,0 +1,7 @@
backend_path := "backend"
watch:
cd {{backend_path}} && cargo watch -q -c -w src/ -x run
watch-test:
cd {{backend_path}} && cargo watch -q -c -w src/ -x test

1271
backend/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,8 +4,15 @@ version = "0.1.0"
edition = "2024"
[dependencies]
axum = "0.8.8"
anyhow = "1.0.102"
axum = { version = "0.8.9", features = [] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
tokio = { version = "1.51.0", features = ["full"] }
tower-http = { version = "0.6.8", features = ["fs"] }
tower-cookies = "0.11.0"
tracing = "0.1.44"
tracing-subscriber = "0.3.23"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
[dev-dependencies]
axum-test = "20.0.0"

33
backend/src/error.rs Normal file
View File

@@ -0,0 +1,33 @@
use std::fmt;
use axum::{http::StatusCode, response::IntoResponse};
use tracing::info;
#[derive(Debug)]
pub enum LoftError {
LoginFail,
FileIdNotFound,
}
impl fmt::Display for LoftError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:?}", self)
}
}
impl std::error::Error for LoftError {}
impl IntoResponse for LoftError {
fn into_response(self) -> axum::response::Response {
match self {
Self::LoginFail => {
info!("UNAUTHORIZED");
StatusCode::UNAUTHORIZED.into_response()
}
Self::FileIdNotFound => {
info!("NOT_FOUND");
StatusCode::NOT_FOUND.into_response()
}
}
}
}

View File

@@ -1,9 +1,49 @@
use axum::{Router, routing::get};
mod error;
mod model;
mod web;
use anyhow::Result;
use axum::{Router, middleware, response::Response};
use tower_cookies::CookieManagerLayer;
use tower_http::services::ServeDir;
use tracing::info;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use crate::{
model::FileController,
web::{routes_file::routes_file, routes_health::routes_health, routes_login::routes_login},
};
#[tokio::main]
async fn main() {
let app = Router::new().route("/", get(|| async { "Hello Async!\n"}));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
async fn main() -> Result<()> {
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
format!("{}=info,tower_http=info", env!("CARGO_CRATE_NAME")).into()
}),
)
.with(tracing_subscriber::fmt::layer())
.init();
let file_controller = FileController::new().await?;
let app = Router::new()
.nest("/api", routes_file(file_controller))
.merge(routes_health())
.merge(routes_login())
.layer(middleware::map_response(main_response_mapper))
.layer(CookieManagerLayer::new())
.fallback_service(ServeDir::new("./"));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
info!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, app).await?;
Ok(())
}
async fn main_response_mapper(res: Response) -> Response {
info!("response mapper: {}", res.status());
res
}

136
backend/src/model.rs Normal file
View File

@@ -0,0 +1,136 @@
use std::sync::{Arc, Mutex};
use serde::{Deserialize, Serialize};
use crate::error::LoftError;
#[derive(Clone, Debug, Serialize)]
pub struct File {
pub id: u64, // make uuid
pub name: String,
pub file_type: String, // make enum
}
#[derive(Clone, Debug, Deserialize)]
pub struct FileToCreate {
pub name: String,
pub file_type: String, // make enum
}
#[derive(Clone)]
pub struct FileController {
file_storage: Arc<Mutex<Vec<Option<File>>>>,
}
impl FileController {
pub async fn new() -> Result<Self, LoftError> {
Ok(Self {
file_storage: Arc::default(),
})
}
pub async fn upload_file(&self, file_to_create: FileToCreate) -> Result<File, LoftError> {
let mut file_storage = self.file_storage.lock().unwrap();
let id = file_storage.len() as u64;
let file = File {
id,
name: file_to_create.name,
file_type: file_to_create.file_type,
};
file_storage.push(Some(file.clone()));
Ok(file)
}
pub async fn download_file(&self, file_id: u64) -> Result<File, LoftError> {
let file_storage = self.file_storage.lock().unwrap();
file_storage
.iter()
.flatten()
.find(|f| f.id == file_id)
.ok_or(LoftError::FileIdNotFound)
.cloned()
}
pub async fn delete_file(&self, file_id: u64) -> Result<File, LoftError> {
let mut file_storage = self.file_storage.lock().unwrap();
let file = file_storage
.get_mut(file_id as usize)
.and_then(|f| f.take());
file.ok_or(LoftError::FileIdNotFound)
}
pub async fn list_files(&self) -> Result<Vec<File>, LoftError> {
let file_storage = self.file_storage.lock().unwrap();
let files = file_storage.iter().flatten().cloned().collect();
Ok(files)
}
}
#[cfg(test)]
mod tests {
use anyhow::Result;
use super::*;
async fn fc() -> Result<FileController> {
Ok(FileController::new().await?)
}
fn new_file(name: &str) -> FileToCreate {
FileToCreate {
name: name.to_string(),
file_type: "text".to_string(),
}
}
#[tokio::test]
async fn test_upload_and_list() -> Result<()> {
let fc = fc().await?;
fc.upload_file(new_file("a.txt")).await?;
fc.upload_file(new_file("b.txt")).await?;
let files = fc.list_files().await?;
assert_eq!(files.len(), 2);
Ok(())
}
#[tokio::test]
async fn test_download() -> Result<()> {
let fc = fc().await?;
let uploaded = fc.upload_file(new_file("a.txt")).await?;
let downloaded = fc.download_file(uploaded.id).await?;
assert_eq!(downloaded.name, "a.txt");
Ok(())
}
#[tokio::test]
async fn test_download_not_found() -> Result<()> {
let fc = fc().await?;
assert!(matches!(
fc.download_file(99).await,
Err(LoftError::FileIdNotFound)
));
Ok(())
}
#[tokio::test]
async fn test_delete() -> Result<()> {
let fc = fc().await?;
let uploaded = fc.upload_file(new_file("a.txt")).await?;
fc.delete_file(uploaded.id).await?;
assert!(matches!(
fc.download_file(uploaded.id).await,
Err(LoftError::FileIdNotFound)
));
Ok(())
}
#[tokio::test]
async fn test_delete_not_found() -> Result<()> {
let fc = fc().await?;
assert!(matches!(
fc.delete_file(99).await,
Err(LoftError::FileIdNotFound)
));
Ok(())
}
}

3
backend/src/web/mod.rs Normal file
View File

@@ -0,0 +1,3 @@
pub mod routes_file;
pub mod routes_health;
pub mod routes_login;

View File

@@ -0,0 +1,132 @@
use axum::{
Json, Router,
extract::{Path, State},
routing::get,
};
use tracing::info;
use crate::{
error::LoftError,
model::{File, FileController, FileToCreate},
};
pub fn routes_file(file_controller: FileController) -> Router {
Router::new()
.route("/files", get(list_files).post(upload_file))
.route("/files/{id}", get(download_file).delete(delete_file))
.with_state(file_controller)
}
async fn upload_file(
State(file_controller): State<FileController>,
Json(file_to_create): Json<FileToCreate>,
) -> Result<Json<File>, LoftError> {
info!("handler: upload_file");
let file = file_controller.upload_file(file_to_create).await?;
Ok(Json(file))
}
async fn download_file(
State(file_controller): State<FileController>,
Path(file_id): Path<u64>,
) -> Result<Json<File>, LoftError> {
info!("handler: download_file");
let file = file_controller.download_file(file_id).await?;
Ok(Json(file))
}
async fn delete_file(
State(file_controller): State<FileController>,
Path(file_id): Path<u64>,
) -> Result<Json<File>, LoftError> {
info!("handler: delete_file");
let file = file_controller.delete_file(file_id).await?;
Ok(Json(file))
}
async fn list_files(
State(file_controller): State<FileController>,
// can add a filters param here
) -> Result<Json<Vec<File>>, LoftError> {
info!("handler: list_files");
let files = file_controller.list_files().await?;
Ok(Json(files))
}
#[cfg(test)]
mod tests {
use axum_test::TestServer;
use serde_json::json;
use crate::{model::FileController, web::routes_file::routes_file};
async fn test_server() -> TestServer {
let fc = FileController::new().await.unwrap();
TestServer::new(routes_file(fc))
}
#[tokio::test]
async fn test_list_files_empty() {
let server = test_server().await;
server
.get("/files")
.await
.assert_status_ok()
.assert_json(&json!([]));
}
#[tokio::test]
async fn test_upload_and_list_files() {
let server = test_server().await;
let res = server
.post("/files")
.json(&json!({"name": "a.txt", "file_type": "text"}))
.await;
res.assert_status_ok();
let file = res.json::<serde_json::Value>();
assert_eq!(file["name"], "a.txt");
assert_eq!(file["id"], 0);
let list = server.get("/files").await.json::<serde_json::Value>();
assert_eq!(list.as_array().unwrap().len(), 1);
}
#[tokio::test]
async fn test_download_file() {
let server = test_server().await;
server
.post("/files")
.json(&json!({"name": "b.txt", "file_type": "text"}))
.await;
let res = server.get("/files/0").await;
res.assert_status_ok();
assert_eq!(res.json::<serde_json::Value>()["name"], "b.txt");
}
#[tokio::test]
async fn test_download_file_not_found() {
let server = test_server().await;
server.get("/files/99").await.assert_status_not_found();
}
#[tokio::test]
async fn test_delete_file() {
let server = test_server().await;
server
.post("/files")
.json(&json!({"name": "c.txt", "file_type": "text"}))
.await;
server.delete("/files/0").await.assert_status_ok();
server.get("/files/0").await.assert_status_not_found();
}
#[tokio::test]
async fn test_delete_file_not_found() {
let server = test_server().await;
server.delete("/files/99").await.assert_status_not_found();
}
}

View File

@@ -0,0 +1,25 @@
use axum::{Router, routing::get};
pub fn routes_health() -> Router {
Router::new().route("/health", get(health))
}
async fn health() -> &'static str {
"up"
}
#[cfg(test)]
mod tests {
use axum::{Router, routing::get};
use axum_test::TestServer;
use crate::web::routes_health::health;
#[tokio::test]
async fn test_route_health() {
let app = Router::new().route(&"/health", get(health));
let server = TestServer::new(app);
let response = server.get("/health").await;
response.assert_status_ok().assert_text("up");
}
}

View File

@@ -0,0 +1,101 @@
use axum::{
Json, Router,
routing::{get, post},
};
use serde::Deserialize;
use serde_json::{Value, json};
use tower_cookies::{Cookie, Cookies};
use crate::error::LoftError;
pub fn routes_login() -> Router {
Router::new()
.route("/login", post(login))
.route("/register", get(register))
}
async fn login(
cookies: Cookies,
Json(payload): Json<LoginPayload>,
) -> Result<Json<Value>, LoftError> {
//TODO: real db/auth logic
if payload.username != "x" || payload.password != "y" {
return Err(LoftError::LoginFail);
}
// FIXME: real auth-token generation-signature
cookies.add(Cookie::new("auth-token", "user-1.exp.sign"));
let body = Json(json!({
"result": {
"success": true
}
}));
Ok(body)
}
async fn register() -> &'static str {
"register"
}
#[derive(Debug, Deserialize)]
struct LoginPayload {
username: String,
password: String,
}
#[cfg(test)]
mod tests {
use axum::{
Router,
routing::{get, post},
};
use axum_test::TestServer;
use serde_json::json;
use crate::web::routes_login::{login, register};
#[tokio::test]
async fn test_routes_login_wrong_credentials() {
let app = Router::new()
.route(&"/login", post(login))
.layer(tower_cookies::CookieManagerLayer::new());
let server = TestServer::new(app);
let response = server
.post("/login")
.json(&json!({
"username": "wrong",
"password": "wrong",
}))
.await;
response.assert_status_unauthorized();
}
#[tokio::test]
async fn test_routes_login() {
let app = Router::new()
.route(&"/login", post(login))
.layer(tower_cookies::CookieManagerLayer::new());
let server = TestServer::new(app);
let response = server
.post("/login")
.json(&json!({
"username": "x",
"password": "y",
}))
.await;
response.assert_status_ok().assert_json(&json!({
"result": {
"success": true
}
}));
}
#[tokio::test]
async fn test_routes_register() {
let app = Router::new().route(&"/register", get(register));
let server = TestServer::new(app);
let response = server.get("/register").await;
response.assert_status_ok().assert_text("register");
}
}