From 6d250c9c1a64af9b45a64ec3d0fbfe7f87d725dd Mon Sep 17 00:00:00 2001 From: TolaMironcenko Date: Tue, 11 Nov 2025 03:33:28 +0700 Subject: [PATCH] full with cli options and config support and all endpoints for crud operations --- Cargo.lock | 134 +++++++++++++++++++++++++++++++++++++++ Cargo.toml | 2 + config.json | 5 ++ src/config.rs | 71 +++++++++++++++++++++ src/db.rs | 65 ++++++++----------- src/handlers.rs | 162 ++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 92 +++------------------------ src/model.rs | 18 +++++- src/startup.rs | 40 ++++++++++++ src/state.rs | 20 ++++++ 10 files changed, 483 insertions(+), 126 deletions(-) create mode 100644 config.json create mode 100644 src/config.rs create mode 100644 src/handlers.rs create mode 100644 src/startup.rs create mode 100644 src/state.rs diff --git a/Cargo.lock b/Cargo.lock index ebcf3b8..4c0844c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,62 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.60.2", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + [[package]] name = "async-trait" version = "0.1.89" @@ -92,11 +148,59 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "clap" +version = "4.5.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "embedded-web" version = "0.1.0" dependencies = [ + "anyhow", "axum", + "clap", "serde", "serde_json", "tokio", @@ -152,6 +256,12 @@ dependencies = [ "pin-utils", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "http" version = "1.3.1" @@ -235,6 +345,12 @@ dependencies = [ "tower-service", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.15" @@ -312,6 +428,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "parking_lot" version = "0.12.5" @@ -498,6 +620,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.110" @@ -643,6 +771,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "valuable" version = "0.1.1" diff --git a/Cargo.toml b/Cargo.toml index 81d8ed7..6921276 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,3 +10,5 @@ tower = "0.4" tracing-subscriber = "0.3" # Добавьте эту строку serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +clap = { version = "4.4", features = ["derive"] } +anyhow = "1.0" diff --git a/config.json b/config.json new file mode 100644 index 0000000..edf1063 --- /dev/null +++ b/config.json @@ -0,0 +1,5 @@ +{ + "host": "0.0.0.0", + "port": 8888, + "log_level": "debug" +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..641e47d --- /dev/null +++ b/src/config.rs @@ -0,0 +1,71 @@ +use clap::Parser; +use serde::Deserialize; +use std::path::PathBuf; + +#[derive(Deserialize, Debug)] +struct FileConfig { + host: Option, + port: Option, + log_level: Option, +} + +#[derive(Parser, Debug)] +#[command(version, about)] +struct Cli { + #[arg(short, long)] + config: Option, + + #[arg(short, long)] + addr: Option, + + #[arg(short, long)] + port: Option, + + #[arg(short, long)] + log_level: Option, +} + +#[derive(Debug, Clone)] +pub struct Config { + pub host: String, + pub port: u16, + pub log_level: String, +} + +impl Config { + pub async fn new() -> anyhow::Result { + let cli: Cli = Cli::parse(); + let file_config: FileConfig = if let Some(config_path) = &cli.config { + println!("Loading config from file"); + Self::load_file_config(config_path).await? + } else { + FileConfig { + host: None, + port: None, + log_level: None, + } + }; + + let host: String = cli + .addr + .or(file_config.host) + .unwrap_or_else(|| "0.0.0.0".to_string()); + let port: u16 = cli.port.or(file_config.port).unwrap_or(8888); + let log_level: String = cli + .log_level + .or(file_config.log_level) + .unwrap_or_else(|| "info".to_string()); + + Ok(Config { + host, + port, + log_level, + }) + } + + async fn load_file_config(path: &PathBuf) -> anyhow::Result { + let content = tokio::fs::read_to_string(path).await?; + let config: FileConfig = serde_json::from_str(&content)?; + Ok(config) + } +} diff --git a/src/db.rs b/src/db.rs index ae8dfdd..caf16ec 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,47 +1,34 @@ -use std::fs; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::Mutex; -// mod model; use crate::model; -pub fn get_all_users() -> Vec { - let data = fs::read_to_string("users.json").expect("Unable to read file"); - serde_json::from_str(&data).expect("Unable to parse JSON") +pub type UserStorage = Arc>>; + +// storage initialization +pub async fn init_user_storage() -> UserStorage { + let users = read_users_from_file().await.unwrap_or_default(); + let map: HashMap = users + .into_iter() + .map(|user| (user.id.clone(), user)) + .collect(); + Arc::new(Mutex::new(map)) } -pub fn get_user_by_id(id: String) -> Option { - let users = get_all_users(); - users.into_iter().find(|user| user.id == id) +// async read file +async fn read_users_from_file() -> Result, Box> { + let content = tokio::fs::read_to_string("users.json").await?; + let users: Vec = serde_json::from_str(&content)?; + Ok(users) } -pub fn get_user_by_username(username: &str) -> Option { - let users = get_all_users(); - users.into_iter().find(|user| user.username == username) -} - -pub fn insert_user(user: model::User) -> model::GetUserResponse { - let mut users = get_all_users(); - let respuser = model::GetUserResponse { - id: user.id.clone(), - username: user.username.clone(), - email: user.email.clone(), - group: user.group.clone(), - is_superuser: user.is_superuser.clone(), - }; - users.push(user); - let data = serde_json::to_string(&users).expect("Unable to serialize JSON"); - fs::write("users.json", data).expect("Unable to write file"); - respuser -} - -pub fn delete_user(id: String) -> bool { - let mut users = get_all_users(); - let user_exists = users.iter().any(|user| user.id == id); - if user_exists { - users.retain(|user| user.id != id); - let data = serde_json::to_string_pretty(&users).expect("Unable to serialize JSON"); - fs::write("users.json", data).expect("Unable to write file"); - true - } else { - false - } +// async write file +pub async fn write_users_to_file( + users: &HashMap, +) -> Result<(), Box> { + let users_vec: Vec = users.values().cloned().collect(); + let content = serde_json::to_string_pretty(&users_vec)?; + tokio::fs::write("users.json", content).await?; + Ok(()) } diff --git a/src/handlers.rs b/src/handlers.rs new file mode 100644 index 0000000..5ba54f6 --- /dev/null +++ b/src/handlers.rs @@ -0,0 +1,162 @@ +use crate::db; +use crate::model; +use crate::state; +use axum::Json; +use axum::extract::State; + +pub async fn get_all_users_handler( + State(state): State, +) -> Json> { + let storage_guard = state.storage.lock().await; + let users: Vec = storage_guard.values().cloned().collect(); + let mapped_users = users + .into_iter() + .map(|user| model::GetUserResponse { + id: user.id, + username: user.username, + email: user.email, + group: user.group, + is_superuser: user.is_superuser, + }) + .collect(); + Json(mapped_users) +} + +pub async fn get_user_by_id_handler( + State(state): State, + Json(payload): Json, +) -> Result, Json> { + let storage_guard = state.storage.lock().await; + storage_guard + .get(&payload.id) + .cloned() + .map(|user| { + Json(model::GetUserResponse { + id: user.id, + username: user.username, + email: user.email, + group: user.group, + is_superuser: user.is_superuser, + }) + }) + .ok_or_else(|| { + Json(model::ErrorResponse { + error: "User not found".to_string(), + }) + }) +} + +pub async fn get_user_by_username_handler( + State(state): State, + Json(payload): Json, +) -> Result, Json> { + let storage_guard = state.storage.lock().await; + storage_guard + .get(&payload.username) + .cloned() + .map(|user| { + Json(model::GetUserResponse { + id: user.id, + username: user.username, + email: user.email, + group: user.group, + is_superuser: user.is_superuser, + }) + }) + .ok_or_else(|| { + Json(model::ErrorResponse { + error: "User not found".to_string(), + }) + }) +} + +pub async fn add_user_handler( + State(state): State, + Json(payload): Json, +) -> Result, Json> { + let user = model::User { + id: payload.id.clone(), + username: payload.username.clone(), + email: payload.email.clone(), + password: payload.password.clone(), + group: payload.group.clone(), + is_superuser: payload.is_superuser, + }; + let mut storage_guard = state.storage.lock().await; + if storage_guard.contains_key(&user.id) { + return Err(Json(model::ErrorResponse { + error: "User already exists".to_string(), + })); + } + storage_guard.insert(user.id.clone(), user.clone()); + let storage_clone = storage_guard.clone(); + tokio::spawn(async move { + if let Err(e) = db::write_users_to_file(&storage_clone).await { + eprintln!("Failed to write users to file: {}", e); + } + }); + Ok(Json(model::GetUserResponse { + id: user.id, + username: user.username, + email: user.email, + group: user.group, + is_superuser: user.is_superuser, + })) +} + +pub async fn delete_user_handler( + State(state): State, + Json(payload): Json, +) -> Result, Json> { + let mut storage_guard = state.storage.lock().await; + if storage_guard.remove(&payload.id).is_none() { + return Err(Json(model::ErrorResponse { + error: "User not found".to_string(), + })); + } + let storage_clone = storage_guard.clone(); + tokio::spawn(async move { + if let Err(e) = db::write_users_to_file(&storage_clone).await { + eprintln!("Failed to write users to file: {}", e); + } + }); + Ok(Json(model::DeleteUserResponse { success: true })) +} + +pub async fn update_user_handler( + State(state): State, + Json(payload): Json, +) -> Result, Json> { + let mut storage_guard = state.storage.lock().await; + if !storage_guard.contains_key(&payload.id) { + return Err(Json(model::ErrorResponse { + error: "User not found".to_string(), + })); + } + let old_user = storage_guard.get(&payload.id).unwrap().clone(); + storage_guard.insert( + payload.id.clone(), + model::User { + id: payload.id.clone(), + username: payload.username.clone(), + email: payload.email.clone(), + password: old_user.password.clone(), + group: payload.group.clone(), + is_superuser: payload.is_superuser, + }, + ); + let storage_clone = storage_guard.clone(); // write to file in background + tokio::spawn(async move { + if let Err(e) = db::write_users_to_file(&storage_clone).await { + eprintln!("Failed to write users to file: {}", e); + } + }); + + Ok(Json(model::GetUserResponse { + id: payload.id.clone(), + username: payload.username.clone(), + email: payload.email.clone(), + group: payload.group.clone(), + is_superuser: payload.is_superuser, + })) +} diff --git a/src/main.rs b/src/main.rs index 357f62d..680249b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,91 +1,13 @@ -use axum::{Json, Router, routing::delete, routing::post}; -use std::net::SocketAddr; - +mod config; mod db; +mod handlers; mod model; +mod startup; +mod state; #[tokio::main] -async fn main() { +async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt::init(); - - let app = Router::new() - .route("/api/users", post(get_all_users_handler)) - .route("/api/users/user", post(get_user_by_id_handler)) - .route( - "/api/users/user/username", - post(get_user_by_username_handler), - ) - .route("/api/users/add", post(add_user_handler)) - .route("/api/users", delete(delete_user_handler)); - - let addr = SocketAddr::from(([0, 0, 0, 0], 8888)); - println!("Сервер запускается на {}", addr); - - axum::serve(tokio::net::TcpListener::bind(addr).await.unwrap(), app) - .await - .unwrap(); -} - -async fn get_all_users_handler() -> Json> { - let users = db::get_all_users(); - let mapped_users = users - .into_iter() - .map(|user| model::GetUserResponse { - id: user.id, - username: user.username, - email: user.email, - group: user.group, - is_superuser: user.is_superuser, - }) - .collect(); - Json(mapped_users) -} - -async fn get_user_by_id_handler( - Json(payload): Json, -) -> Json> { - let user = db::get_user_by_id(payload.id); - let mapped_user = user.map(|user| model::GetUserResponse { - id: user.id, - username: user.username, - email: user.email, - group: user.group, - is_superuser: user.is_superuser, - }); - Json(mapped_user) -} - -async fn get_user_by_username_handler( - Json(payload): Json, -) -> Json> { - let user = db::get_user_by_username(&payload.username); - let mapped_user = user.map(|user| model::GetUserResponse { - id: user.id, - username: user.username, - email: user.email, - group: user.group, - is_superuser: user.is_superuser, - }); - Json(mapped_user) -} - -async fn add_user_handler( - Json(payload): Json, -) -> Json { - let user = model::User { - id: payload.id.clone(), - username: payload.username.clone(), - email: payload.email.clone(), - password: payload.password.clone(), - group: payload.group.clone(), - is_superuser: payload.is_superuser, - }; - Json(db::insert_user(user)) -} - -async fn delete_user_handler( - Json(payload): Json, -) -> Json { - let deleted: bool = db::delete_user(payload.id); - Json(model::DeleteUserResponse { success: deleted }) + startup::start_server().await?; + Ok(()) } diff --git a/src/model.rs b/src/model.rs index 0915084..82e8cfa 100644 --- a/src/model.rs +++ b/src/model.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, Clone)] pub struct User { pub id: String, pub username: String, @@ -20,7 +20,7 @@ pub struct GetUserByUsernameRequest { pub username: String, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Clone)] pub struct AddUserRequest { pub id: String, pub username: String, @@ -48,3 +48,17 @@ pub struct DeleteUserRequest { pub struct DeleteUserResponse { pub success: bool, } + +#[derive(Serialize, Deserialize, Debug)] +pub struct UpdateUserRequest { + pub id: String, + pub username: String, + pub email: String, + pub group: String, + pub is_superuser: bool, +} + +#[derive(Serialize, Debug)] +pub struct ErrorResponse { + pub error: String, +} diff --git a/src/startup.rs b/src/startup.rs new file mode 100644 index 0000000..abff156 --- /dev/null +++ b/src/startup.rs @@ -0,0 +1,40 @@ +use crate::handlers; +use crate::state; +use axum::{Router, routing::delete, routing::post, routing::put}; +use std::net::SocketAddr; + +pub async fn start_server() -> anyhow::Result<()> { + let state = state::AppState::new().await?; + print_config(&state.config); + let app = create_app(state.clone()); + let addr: SocketAddr = format!("{}:{}", state.config.host, state.config.port) + .parse() + .expect("Invalid host or port configuration"); + axum::serve(tokio::net::TcpListener::bind(addr).await.unwrap(), app) + .await + .unwrap(); + + tokio::signal::ctrl_c().await?; + println!("Server stopped"); + Ok(()) +} + +pub fn create_app(state: state::AppState) -> Router { + Router::new() + .route("/api/users", post(handlers::get_all_users_handler)) + .route("/api/users/user", post(handlers::get_user_by_id_handler)) + .route( + "/api/users/user/username", + post(handlers::get_user_by_username_handler), + ) + .route("/api/users/add", post(handlers::add_user_handler)) + .route("/api/users", delete(handlers::delete_user_handler)) + .route("/api/users", put(handlers::update_user_handler)) + .with_state(state) +} + +fn print_config(config: &crate::config::Config) { + println!("Host: {}", config.host.clone()); + println!("Port: {}", config.port); + println!("log_level: {}", config.log_level); +} diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..01908e1 --- /dev/null +++ b/src/state.rs @@ -0,0 +1,20 @@ +use crate::config; +use crate::db; + +#[derive(Clone)] +pub struct AppState { + pub storage: db::UserStorage, + pub config: config::Config, +} + +impl AppState { + pub async fn new() -> anyhow::Result { + let config = config::Config::new().await?; + let user_storage = db::init_user_storage().await; + let state = AppState { + storage: user_storage, + config: config.clone(), + }; + Ok(state) + } +}