chore: scaffold axum server with in-memory CRUD, auth stub and tracing
This commit is contained in:
7
Justfile
Normal file
7
Justfile
Normal 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
1271
backend/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -4,8 +4,15 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = "0.8.8"
|
anyhow = "1.0.102"
|
||||||
|
axum = { version = "0.8.9", features = [] }
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
|
serde_json = "1.0.149"
|
||||||
tokio = { version = "1.51.0", features = ["full"] }
|
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 = "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
33
backend/src/error.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() -> Result<()> {
|
||||||
let app = Router::new().route("/", get(|| async { "Hello Async!\n"}));
|
tracing_subscriber::registry()
|
||||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
|
.with(
|
||||||
axum::serve(listener, app).await.unwrap();
|
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
136
backend/src/model.rs
Normal 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
3
backend/src/web/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod routes_file;
|
||||||
|
pub mod routes_health;
|
||||||
|
pub mod routes_login;
|
||||||
132
backend/src/web/routes_file.rs
Normal file
132
backend/src/web/routes_file.rs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
25
backend/src/web/routes_health.rs
Normal file
25
backend/src/web/routes_health.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
101
backend/src/web/routes_login.rs
Normal file
101
backend/src/web/routes_login.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user