chore: scaffold axum server with in-memory CRUD, auth stub and tracing
This commit is contained in:
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"
|
||||
|
||||
[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
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]
|
||||
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
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