diff --git a/.env b/.env new file mode 100644 index 0000000..1951d22 --- /dev/null +++ b/.env @@ -0,0 +1,12 @@ +CARGO_ENV=development +RUST_LOG=debug + +APP_HOST=127.0.0.1 +APP_PORT=5000 + +MONGO_URI=mongodb://localhost:27017 +MONGO_DB=rust-axum-boilerplate-db + +ACCESS_TOKEN_SECRET=ddd26ab8c5140fb0dc5bd8cdccd6a0102d09c9bdf2466a5cd718373fd42a17b1 +REFRESH_TOKEN_SECRET=bdca01f8df0bfba0e400f3daf3e629118b76fe8b15d7362bbabb57496a30bdfc +ARGON_SALT=4a936421ec2c38de8e968c0e80959aae8158df7fe541d774c173e0a8b3390545 diff --git a/.github/workflows/releases.yaml b/.github/workflows/releases.yaml new file mode 100644 index 0000000..ed12a79 --- /dev/null +++ b/.github/workflows/releases.yaml @@ -0,0 +1,21 @@ +name: Release + +permissions: + contents: write + +on: + push: + tags: + - v[0-9]+.* + +jobs: + create-release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: taiki-e/create-gh-release-action@v1 + with: + # (optional) Path to changelog. + # changelog: CHANGELOG.md + # (required) GitHub token for creating GitHub Releases. + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..26c9408 --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +logs/* +!logs/.gitkeep + +# docker volumes +data +.data diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..e4964ef --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,69 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "lldb", + "request": "launch", + "name": "Debug unit tests in library 'database'", + "cargo": { + "args": ["test", "--no-run", "--lib", "--package=database"], + "filter": { + "name": "database", + "kind": "lib" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug unit tests in library 'utils'", + "cargo": { + "args": ["test", "--no-run", "--lib", "--package=utils"], + "filter": { + "name": "utils", + "kind": "lib" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug executable 'rust-axum-boilerplate'", + "cargo": { + "args": ["build", "--bin=rust-axum-boilerplate", "--package=server"], + "filter": { + "name": "rust-axum-boilerplate", + "kind": "bin" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug unit tests in executable 'rust-axum-boilerplate'", + "cargo": { + "args": [ + "test", + "--no-run", + "--bin=rust-axum-boilerplate", + "--package=server" + ], + "filter": { + "name": "rust-axum-boilerplate", + "kind": "bin" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + } + ] +} diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9b13429 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,27 @@ +[workspace] +members = ["crates/*"] +resolver = "2" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[workspace.dependencies] +anyhow = "1.0.79" +async-trait = "0.1.77" +axum = { version = "0.7.3", features = ["macros"] } +clap = { version = "4.4.13", features = ["env", "derive"] } +dotenvy = "0.15.7" +lazy_static = "1.4.0" +mongodb = { version = "2.8.0" } +pbkdf2 = "0.12.2" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0.68" +time = "0.3.31" +thiserror = "1.0.56" +tokio = { version = "1", features = ["full"] } +tokio-stream = { version = "0.1.14" } +tower = { version = "0.4.13", features = ["timeout", "buffer", "limit"] } +tower-http = { version = "0.5.0", features = ["fs", "trace", "cors"] } +tracing = { version = "0.1.40" } +tracing-appender = "0.2.3" +tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } +validator = { version = "0.16.1", features = ["derive"] } diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8f1ea5d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Flavio Del Grosso + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3443ea1 --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# Rust Application Server Boilerplate using Axum framework and MongoDB! 🦀 + +This project is a boilerplate for building a Rust application server using the [Axum framework]("https://github.com/tokio-rs/axum") and MongoDB as the database. It provides a solid starting point for building your own Rust applications, with many common features already implemented. + +## Features + +- [x] Axum server: A modern and fast web framework with a focus on ergonomics and modularity. +- [x] MongoDB driver: A Rust driver for MongoDB, allowing you to interact with MongoDB collections. +- [x] Logging: Logging support using `tracing` and `tracing-subscriber` for async-compatible logging. +- [x] Error handler: Application error handling system. +- [x] Router: A router for mapping requests to handlers, cors, and static files. +- [x] Static: Static file serving using `tower-http`. +- [x] Extractors: Validation extractor for getting data from requests and validate with `validator` crate. +- [x] App config (dotenvy): Load your application's configuration from a `.env` file. + +## Possible Planned Features + +- Authentication: User authentication system. +- Hashing: Password hashing +- Jwt utils: Utilities for working with JWTs. +- Server Metrics + +## Project Structure + +The project is organized into several crates: + +- `database`: Contains the MongoDB driver and user model and repository. +- `server`: Contains the main application server, including the API, router, and services. +- `utils`: Contains utility modules like config and errors. + +## Getting Started + +1. Clone the repository. +2. Install the Rust toolchain if you haven't already. +3. Run `cargo build` to build the project. +4. Run `cargo run` to start the server. + +You can install cargo-watch to automatically recompile the project when changes are made: + +```bash +cargo install cargo-watch +``` + +Then run `cargo watch -x run` to start the server. + +## Contributing + +Contributions are welcome! + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/crates/database/Cargo.toml b/crates/database/Cargo.toml new file mode 100644 index 0000000..6388d8f --- /dev/null +++ b/crates/database/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "database" +version = "0.1.0" +edition = "2021" +metadata.workspace = true + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +async-trait = { workspace = true } +mongodb = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +time = { workspace = true } +tokio-stream = { workspace = true } +tracing = { workspace = true } +utils = { path = "../utils" } +validator = { workspace = true } diff --git a/crates/database/src/lib.rs b/crates/database/src/lib.rs new file mode 100644 index 0000000..f723912 --- /dev/null +++ b/crates/database/src/lib.rs @@ -0,0 +1,26 @@ +pub mod user; + +use std::sync::Arc; + +use mongodb::{Client, Collection}; +use tracing::info; + +use user::model::User; +use utils::{AppConfig, AppResult}; + +#[derive(Clone, Debug)] +pub struct Database { + pub user_col: Collection, +} + +impl Database { + pub async fn new(config: Arc) -> AppResult { + let client = Client::with_uri_str(&config.mongo_uri).await?; + let db = client.database(&config.mongo_db); + let user_col: Collection = db.collection("User"); + + info!("initializing database connection..."); + + Ok(Database { user_col }) + } +} diff --git a/crates/database/src/user/mod.rs b/crates/database/src/user/mod.rs new file mode 100644 index 0000000..1442c17 --- /dev/null +++ b/crates/database/src/user/mod.rs @@ -0,0 +1,2 @@ +pub mod model; +pub mod repository; diff --git a/crates/database/src/user/model.rs b/crates/database/src/user/model.rs new file mode 100644 index 0000000..76741f8 --- /dev/null +++ b/crates/database/src/user/model.rs @@ -0,0 +1,15 @@ +use mongodb::bson::oid::ObjectId; +use serde::{Deserialize, Serialize}; +use validator::Validate; + +#[derive(Debug, Clone, Serialize, Deserialize, Validate, Default)] +pub struct User { + #[serde(rename = "_id", skip_deserializing, skip_serializing)] + pub id: Option, + #[validate(length(min = 1))] + pub name: String, + #[validate(length(min = 1), email(message = "email is invalid"))] + pub email: String, + #[validate(length(min = 6))] + pub password: String, +} diff --git a/crates/database/src/user/repository.rs b/crates/database/src/user/repository.rs new file mode 100644 index 0000000..5295617 --- /dev/null +++ b/crates/database/src/user/repository.rs @@ -0,0 +1,117 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use mongodb::{ + bson::{doc, oid::ObjectId}, + results::{DeleteResult, InsertOneResult, UpdateResult}, +}; +use tokio_stream::StreamExt; + +use crate::{user::model::User, Database}; +use utils::AppResult; + +pub type DynUserRepository = Arc; + +#[async_trait] +pub trait UserRepositoryTrait { + async fn create_user( + &self, + name: &str, + email: &str, + password: &str, + ) -> AppResult; + + async fn get_user_by_id(&self, id: &str) -> AppResult>; + + async fn get_user_by_email(&self, email: &str) -> AppResult>; + + async fn update_user( + &self, + id: &str, + name: &str, + email: &str, + password: &str, + ) -> AppResult; + + async fn delete_user(&self, id: &str) -> AppResult; + + async fn get_all_users(&self) -> AppResult>; +} + +#[async_trait] +impl UserRepositoryTrait for Database { + async fn create_user( + &self, + name: &str, + email: &str, + password: &str, + ) -> AppResult { + let new_doc = User { + id: None, + name: name.to_string(), + email: email.to_string(), + password: password.to_string(), + }; + + let user = self.user_col.insert_one(new_doc, None).await?; + + Ok(user) + } + + async fn get_user_by_email(&self, email: &str) -> AppResult> { + let filter = doc! {"email": email}; + let user_detail = self.user_col.find_one(filter, None).await?; + + Ok(user_detail) + } + + async fn get_user_by_id(&self, id: &str) -> AppResult> { + let obj_id = ObjectId::parse_str(id)?; + let filter = doc! {"_id": obj_id}; + let user_detail = self.user_col.find_one(filter, None).await?; + + Ok(user_detail) + } + + async fn update_user( + &self, + id: &str, + name: &str, + email: &str, + password: &str, + ) -> AppResult { + let id = ObjectId::parse_str(id)?; + let filter = doc! {"_id": id}; + let new_doc = doc! { + "$set": + { + "name": name, + "email": email, + "password": password, + }, + }; + + let updated_doc = self.user_col.update_one(filter, new_doc, None).await?; + + Ok(updated_doc) + } + + async fn delete_user(&self, id: &str) -> AppResult { + let obj_id = ObjectId::parse_str(id)?; + let filter = doc! {"_id": obj_id}; + let user_detail = self.user_col.delete_one(filter, None).await?; + + Ok(user_detail) + } + + async fn get_all_users(&self) -> AppResult> { + let mut cursor = self.user_col.find(None, None).await?; + + let mut users: Vec = Vec::new(); + while let Some(doc) = cursor.next().await { + users.push(doc?); + } + + Ok(users) + } +} diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml new file mode 100644 index 0000000..aed3226 --- /dev/null +++ b/crates/server/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "server" +version = "0.1.0" +edition = "2021" +metadata.workspace = true + +[[bin]] +name = "rust-axum-boilerplate" +path = "src/main.rs" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +anyhow = { workspace = true } +async-trait = { workspace = true } +axum = { workspace = true } +axum-extra = { version = "0.9.1", features = ["cookie"] } +clap = { workspace = true } +database = { path = "../database" } +dotenvy = { workspace = true } +lazy_static = { workspace = true } +mongodb = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +pbkdf2 = { workspace = true } +time = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +tokio-stream = { workspace = true } +tower = { workspace = true } +tower-http = { workspace = true } +tracing = { workspace = true } +tracing-appender = { workspace = true } +tracing-subscriber = { workspace = true } +utils = { path = "../utils" } +validator = { workspace = true } diff --git a/crates/server/src/api/mod.rs b/crates/server/src/api/mod.rs new file mode 100644 index 0000000..925566a --- /dev/null +++ b/crates/server/src/api/mod.rs @@ -0,0 +1,13 @@ +mod user_controller; + +use axum::routing::{get, Router}; + +pub async fn health() -> &'static str { + "🚀 Server is running! 🚀" +} + +pub fn app() -> Router { + Router::new() + .route("/", get(health)) + .nest("/users", user_controller::UserController::app()) +} diff --git a/crates/server/src/api/user_controller.rs b/crates/server/src/api/user_controller.rs new file mode 100644 index 0000000..a14126f --- /dev/null +++ b/crates/server/src/api/user_controller.rs @@ -0,0 +1,36 @@ +use axum::{ + routing::{get, post}, + Extension, Json, Router, +}; + +use database::user::model::User; +use mongodb::results::InsertOneResult; +use utils::AppResult; + +use crate::{ + dtos::user_dto::SignUpUserDto, extractors::validation_extractor::ValidationExtractor, + services::Services, +}; + +pub struct UserController; +impl UserController { + pub fn app() -> Router { + Router::new() + .route("/", get(Self::all)) + .route("/signup", post(Self::signup)) + } + + pub async fn all(Extension(services): Extension) -> AppResult>> { + let users = services.user.get_all_users().await?; + Ok(Json(users)) + } + + pub async fn signup( + Extension(services): Extension, + ValidationExtractor(req): ValidationExtractor, + ) -> AppResult> { + let created_user = services.user.signup_user(req).await?; + + Ok(Json(created_user)) + } +} diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs new file mode 100644 index 0000000..c9b8dce --- /dev/null +++ b/crates/server/src/app.rs @@ -0,0 +1,65 @@ +use anyhow::Context; +use axum::serve; +use database::Database; +use std::sync::Arc; +use tokio::signal; +use tracing::info; +use utils::AppConfig; + +use crate::services::Services; +use crate::{logger::Logger, router::AppRouter}; + +pub struct ApplicationServer; +impl ApplicationServer { + pub async fn serve(config: Arc) -> anyhow::Result<()> { + let _guard = Logger::new(config.cargo_env); + + let address = format!("{}:{}", config.app_host, config.app_port); + let tcp_listener = tokio::net::TcpListener::bind(address) + .await + .context("Failed to bind TCP listener")?; + + let local_addr = tcp_listener + .local_addr() + .context("Failed to get local address")?; + + info!("server has launched on {local_addr} 🚀"); + + let db = Database::new(config.clone()).await?; + let services = Services::new(db); + let router = AppRouter::new(services); + + serve(tcp_listener, router) + .with_graceful_shutdown(Self::shutdown_signal()) + .await + .context("Failed to start server")?; + + Ok(()) + } + + async fn shutdown_signal() { + let ctrl_c = async { + signal::ctrl_c() + .await + .expect("Failed to install Ctrl+C handler"); + }; + + #[cfg(unix)] + let terminate = async { + signal::unix::signal(signal::unix::SignalKind::terminate()) + .expect("Failed to install signal handler") + .recv() + .await; + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => {}, + _ = terminate => {}, + } + + tracing::warn!("❌ Signal received, starting graceful shutdown..."); + } +} diff --git a/crates/server/src/dtos/mod.rs b/crates/server/src/dtos/mod.rs new file mode 100644 index 0000000..e024312 --- /dev/null +++ b/crates/server/src/dtos/mod.rs @@ -0,0 +1 @@ +pub(crate) mod user_dto; diff --git a/crates/server/src/dtos/user_dto.rs b/crates/server/src/dtos/user_dto.rs new file mode 100644 index 0000000..e3bf27e --- /dev/null +++ b/crates/server/src/dtos/user_dto.rs @@ -0,0 +1,12 @@ +use serde::{Deserialize, Serialize}; +use validator::Validate; + +#[derive(Clone, Serialize, Deserialize, Debug, Validate, Default)] +pub struct SignUpUserDto { + #[validate(required, length(min = 1))] + pub name: Option, + #[validate(required, length(min = 1), email(message = "email is invalid"))] + pub email: Option, + #[validate(required, length(min = 6))] + pub password: Option, +} diff --git a/crates/server/src/extractors/mod.rs b/crates/server/src/extractors/mod.rs new file mode 100644 index 0000000..997314a --- /dev/null +++ b/crates/server/src/extractors/mod.rs @@ -0,0 +1 @@ +pub(crate) mod validation_extractor; diff --git a/crates/server/src/extractors/validation_extractor.rs b/crates/server/src/extractors/validation_extractor.rs new file mode 100644 index 0000000..400ac54 --- /dev/null +++ b/crates/server/src/extractors/validation_extractor.rs @@ -0,0 +1,26 @@ +use axum::{ + async_trait, + extract::{rejection::JsonRejection, FromRequest, Request}, + Json, +}; +use serde::de::DeserializeOwned; +use utils::AppError; +use validator::Validate; + +pub struct ValidationExtractor(pub T); + +#[async_trait] +impl FromRequest for ValidationExtractor +where + T: DeserializeOwned + Validate, + S: Send + Sync, + Json: FromRequest, +{ + type Rejection = AppError; + + async fn from_request(req: Request, state: &S) -> Result { + let Json(value) = Json::::from_request(req, state).await?; + value.validate()?; + Ok(ValidationExtractor(value)) + } +} diff --git a/crates/server/src/logger.rs b/crates/server/src/logger.rs new file mode 100644 index 0000000..3394352 --- /dev/null +++ b/crates/server/src/logger.rs @@ -0,0 +1,34 @@ +use tracing_appender::non_blocking::WorkerGuard; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +use utils::CargoEnv; + +pub struct Logger; +impl Logger { + pub fn new(cargo_env: CargoEnv) -> WorkerGuard { + let (non_blocking, guard) = match cargo_env { + CargoEnv::Development => { + let console_logger = std::io::stdout(); + tracing_appender::non_blocking(console_logger) + } + CargoEnv::Production => { + let file_logger = tracing_appender::rolling::daily("logs", "log"); + tracing_appender::non_blocking(file_logger) + } + }; + + // Set the default verbosity level for the root of the dependency graph. + // env var: `RUST_LOG` + let env_filter = + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| { + format!("{}=debug,tower_http=debug", env!("CARGO_PKG_NAME")).into() + }); + + tracing_subscriber::registry() + .with(env_filter) + .with(tracing_subscriber::fmt::layer().with_writer(non_blocking)) + .init(); + + guard + } +} diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs new file mode 100644 index 0000000..a019564 --- /dev/null +++ b/crates/server/src/main.rs @@ -0,0 +1,28 @@ +pub(crate) mod api; +pub(crate) mod app; +pub(crate) mod dtos; +pub(crate) mod extractors; +pub(crate) mod logger; +pub(crate) mod router; +pub(crate) mod services; + +use std::sync::Arc; + +use anyhow::{Context, Result}; +use app::ApplicationServer; +use clap::Parser; +use dotenvy::dotenv; +use utils::AppConfig; + +#[tokio::main] +async fn main() -> Result<(), anyhow::Error> { + dotenv().ok(); + + let config = Arc::new(AppConfig::parse()); + + ApplicationServer::serve(config) + .await + .context("Failed to start server")?; + + Ok(()) +} diff --git a/crates/server/src/router.rs b/crates/server/src/router.rs new file mode 100644 index 0000000..8bb9be8 --- /dev/null +++ b/crates/server/src/router.rs @@ -0,0 +1,93 @@ +use std::time::Duration; + +use axum::{ + error_handling::HandleErrorLayer, + http::{ + header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE}, + Method, StatusCode, + }, + response::IntoResponse, + BoxError, Extension, Json, Router, +}; +use lazy_static::lazy_static; +use serde_json::json; +use tower::{buffer::BufferLayer, limit::RateLimitLayer, ServiceBuilder}; +use tower_http::trace::TraceLayer; +use tower_http::{ + cors::{Any, CorsLayer}, + services::{ServeDir, ServeFile}, +}; + +use super::services::Services; +use crate::api; + +lazy_static! { + static ref HTTP_TIMEOUT: u64 = 30; +} + +pub struct AppRouter; +impl AppRouter { + pub fn new(services: Services) -> Router { + let cors = CorsLayer::new() + .allow_origin(Any) + .allow_methods([ + Method::GET, + Method::POST, + Method::DELETE, + Method::PUT, + Method::PATCH, + ]) + .allow_headers([AUTHORIZATION, ACCEPT, CONTENT_TYPE]); + + let index = ServeDir::new("dist").not_found_service(ServeFile::new("dist/index.html")); + + let router = Router::new() + .nest_service("/", index) + .nest("/api/v1", api::app()) + .layer(cors) + .layer( + ServiceBuilder::new() + .layer(Extension(services)) + .layer(TraceLayer::new_for_http()) + .layer(HandleErrorLayer::new(Self::handle_timeout_error)) + .timeout(Duration::from_secs(*HTTP_TIMEOUT)) + .layer(BufferLayer::new(1024)) + .layer(RateLimitLayer::new(5, Duration::from_secs(1))), + ) + .fallback(Self::handle_404); + + router + } + + async fn handle_404() -> impl IntoResponse { + ( + StatusCode::NOT_FOUND, + axum::response::Json(serde_json::json!({ + "errors":{ + "message": vec!(String::from("The requested resource does not exist on this server!")),} + })), + ) + } + + async fn handle_timeout_error(err: BoxError) -> (StatusCode, Json) { + if err.is::() { + ( + StatusCode::REQUEST_TIMEOUT, + Json(json!({ + "error": + format!( + "request took longer than the configured {} second timeout", + *HTTP_TIMEOUT + ) + })), + ) + } else { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ + "error": format!("unhandled internal error: {}", err) + })), + ) + } + } +} diff --git a/crates/server/src/services/mod.rs b/crates/server/src/services/mod.rs new file mode 100644 index 0000000..72bcce4 --- /dev/null +++ b/crates/server/src/services/mod.rs @@ -0,0 +1,23 @@ +pub(crate) mod user_service; + +use database::Database; +use std::sync::Arc; +use tracing::info; + +use crate::services::user_service::{DynUserService, UserService}; + +#[derive(Clone)] +pub struct Services { + pub user: DynUserService, +} + +impl Services { + pub fn new(db: Database) -> Self { + info!("initializing services..."); + let repository = Arc::new(db); + + let user = Arc::new(UserService::new(repository.clone())) as DynUserService; + + Self { user } + } +} diff --git a/crates/server/src/services/user_service.rs b/crates/server/src/services/user_service.rs new file mode 100644 index 0000000..1fb1e3e --- /dev/null +++ b/crates/server/src/services/user_service.rs @@ -0,0 +1,68 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use database::user::{model::User, repository::DynUserRepository}; +use mongodb::results::InsertOneResult; +use tracing::{error, info}; +use utils::{AppError, AppResult}; + +use crate::dtos::user_dto::SignUpUserDto; + +pub type DynUserService = Arc; + +#[async_trait] +pub trait UserServiceTrait { + async fn get_current_user(&self, user_id: &str) -> AppResult>; + + async fn get_all_users(&self) -> AppResult>; + + async fn signup_user(&self, request: SignUpUserDto) -> AppResult; +} + +#[derive(Clone)] +pub struct UserService { + repository: DynUserRepository, +} + +impl UserService { + pub fn new(repository: DynUserRepository) -> Self { + Self { repository } + } +} + +#[async_trait] +impl UserServiceTrait for UserService { + async fn signup_user(&self, request: SignUpUserDto) -> AppResult { + let email = request.email.unwrap(); + let name = request.name.unwrap(); + let password = request.password.unwrap(); + + let existing_user = self.repository.get_user_by_email(&email).await?; + + if existing_user.is_some() { + error!("user {:?} already exists", email); + return Err(AppError::Conflict(format!("email {} is taken", email))); + } + + let new_user = self + .repository + .create_user(&name, &email, &password) + .await?; + + info!("created user {:?}", new_user); + + Ok(new_user) + } + + async fn get_current_user(&self, user_id: &str) -> AppResult> { + let user = self.repository.get_user_by_id(user_id).await?; + + Ok(user) + } + + async fn get_all_users(&self) -> AppResult> { + let users = self.repository.get_all_users().await?; + + Ok(users) + } +} diff --git a/crates/utils/Cargo.toml b/crates/utils/Cargo.toml new file mode 100644 index 0000000..e06a70d --- /dev/null +++ b/crates/utils/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "utils" +version = "0.1.0" +edition = "2021" +metadata.workspace = true + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +anyhow = { workspace = true } +axum = { workspace = true } +clap = { workspace = true } +jsonwebtoken = "9.2.0" +mongodb = { workspace = true } +rust-argon2 = "2.1.0" +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +time = { workspace = true } +tracing = { workspace = true } +validator = { workspace = true } diff --git a/crates/utils/src/config.rs b/crates/utils/src/config.rs new file mode 100644 index 0000000..c13b700 --- /dev/null +++ b/crates/utils/src/config.rs @@ -0,0 +1,23 @@ +#[derive(clap::ValueEnum, Clone, Debug, Copy)] +pub enum CargoEnv { + Development, + Production, +} + +#[derive(clap::Parser)] +pub struct AppConfig { + #[clap(long, env, value_enum)] + pub cargo_env: CargoEnv, + + #[clap(long, env, default_value = "127.0.0.1")] + pub app_host: String, + + #[clap(long, env, default_value = "5000")] + pub app_port: u16, + + #[clap(long, env, default_value = "mongodb://localhost:27017")] + pub mongo_uri: String, + + #[clap(long, env)] + pub mongo_db: String, +} diff --git a/crates/utils/src/errors.rs b/crates/utils/src/errors.rs new file mode 100644 index 0000000..4a8a751 --- /dev/null +++ b/crates/utils/src/errors.rs @@ -0,0 +1,153 @@ +#![allow(dead_code)] +use std::borrow::Cow; +use std::collections::HashMap; +use std::fmt::Debug; + +use axum::extract::rejection::JsonRejection; +use axum::response::Response; +use axum::{http::StatusCode, response::IntoResponse, Json}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use thiserror::Error; +use tracing::debug; +use tracing::log::error; +use validator::{ValidationErrors, ValidationErrorsKind}; + +pub type AppResult = Result; + +pub type ErrorMap = HashMap, Vec>>; + +#[derive(Debug, Deserialize, Serialize)] +pub struct HttpError { + pub error: String, +} + +impl HttpError { + pub fn new(error: String) -> Self { + Self { error } + } +} + +#[derive(Error, Debug)] +pub enum AppError { + #[error("{0}")] + NotFound(String), + #[error("{0}")] + BadRequest(String), + #[error("authentication is required to access this resource")] + Unauthorized, + #[error("user does not have privilege to access this resource")] + Forbidden, + #[error("unexpected error has occurred")] + InternalServerError, + #[error("{0}")] + InternalServerErrorWithContext(String), + #[error("{0}")] + Conflict(String), + #[error("{0}")] + PreconditionFailed(String), + #[error(transparent)] + AxumJsonRejection(#[from] JsonRejection), + #[error(transparent)] + ValidationError(#[from] ValidationErrors), + #[error("unprocessable request has occurred")] + UnprocessableEntity { errors: ErrorMap }, + #[error(transparent)] + SerdeJsonError(#[from] serde_json::Error), + #[error(transparent)] + AnyhowError(#[from] anyhow::Error), + #[error("{0}")] + MongoError(#[from] mongodb::error::Error), + #[error("{0}")] + MongoErrorKind(mongodb::error::ErrorKind), + #[error("error serializing BSON")] + MongoSerializeBsonError(#[from] mongodb::bson::ser::Error), + #[error("error deserializing BSON")] + MongoDeserializeBsonError(#[from] mongodb::bson::de::Error), + #[error("document validation error")] + MongoDataError(#[from] mongodb::bson::document::ValueAccessError), + #[error("error converting object id")] + MongoObjectIdError(#[from] mongodb::bson::oid::Error), +} + +impl AppError { + /// Maps `validator`'s `ValidationrErrors` to a simple map of property name/error messages structure. + pub fn unprocessable_entity(errors: ValidationErrors) -> Response { + let mut validation_errors = ErrorMap::new(); + + for (field_property, error_kind) in errors.into_errors() { + if let ValidationErrorsKind::Field(field_meta) = error_kind.clone() { + for error in field_meta.into_iter() { + validation_errors + .entry(Cow::from(field_property)) + .or_insert_with(Vec::new) + .push(error.message.unwrap_or_else(|| { + let params: Vec> = error + .params + .iter() + .filter(|(key, _value)| key.to_owned() != "value") + .map(|(key, value)| { + Cow::from(format!("{} value is {}", key, value.to_string())) + }) + .collect(); + + if params.len() >= 1 { + Cow::from(params.join(", ")) + } else { + Cow::from(format!("{} is required", field_property)) + } + })) + } + } + + if let ValidationErrorsKind::Struct(meta) = error_kind.clone() { + for (struct_property, struct_error_kind) in meta.into_errors() { + if let ValidationErrorsKind::Field(field_meta) = struct_error_kind { + for error in field_meta.into_iter() { + validation_errors + .entry(Cow::from(struct_property)) + .or_insert_with(Vec::new) + .push(error.message.unwrap_or_else(|| { + Cow::from(format!("{} is required", struct_property)) + })); + } + } + } + } + } + + let body = Json(json!({ + "errors": validation_errors, + })); + + (StatusCode::BAD_REQUEST, body).into_response() + } +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + debug!("{:#?}", self); + if let Self::ValidationError(e) = self { + return Self::unprocessable_entity(e); + } + + let (status, error_message) = match self { + Self::InternalServerErrorWithContext(err) => (StatusCode::INTERNAL_SERVER_ERROR, err), + Self::NotFound(err) => (StatusCode::NOT_FOUND, err), + Self::Conflict(err) => (StatusCode::CONFLICT, err), + Self::PreconditionFailed(err) => (StatusCode::PRECONDITION_FAILED, err), + Self::BadRequest(err) => (StatusCode::BAD_REQUEST, err), + Self::Unauthorized => (StatusCode::UNAUTHORIZED, Self::Unauthorized.to_string()), + Self::Forbidden => (StatusCode::FORBIDDEN, Self::Forbidden.to_string()), + Self::AxumJsonRejection(err) => (StatusCode::BAD_REQUEST, err.body_text()), + _ => ( + StatusCode::INTERNAL_SERVER_ERROR, + Self::InternalServerError.to_string(), + ), + }; + + let body = Json(HttpError::new(error_message)); + + (status, body).into_response() + } +} diff --git a/crates/utils/src/lib.rs b/crates/utils/src/lib.rs new file mode 100644 index 0000000..97d3bbb --- /dev/null +++ b/crates/utils/src/lib.rs @@ -0,0 +1,5 @@ +pub mod config; +pub mod errors; + +pub use config::*; +pub use errors::*; diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..b6020ee --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,10 @@ +version: "3.8" +services: + mongodb: + image: mongo:latest + restart: always + container_name: mongodb + ports: + - 27017:27017 + volumes: + - ./data:/data/db diff --git a/logs/.gitkeep b/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..1842813 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,2 @@ +imports_granularity = "Crate" +group_imports = "One" diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..359ffad --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +# Get the current version from the latest Git tag +current_version=$(git describe --abbrev=0 --tags) + +# Parse the version components. +major=$(echo "$current_version" | cut -d. -f1 | sed 's/v//g') +minor=$(echo "$current_version" | cut -d. -f2) +patch=$(echo "$current_version" | cut -d. -f3) + +# Determine the release type based on command-line arguments +if [[ $# -eq 0 ]]; then + release_type="patch" +elif [[ $1 == "minor" ]]; then + release_type="minor" +elif [[ $1 == "major" ]]; then + release_type="major" +else + echo "Invalid release type. Usage: release.sh [patch|minor|major]" + exit 1 +fi + +# Increment the version based on the release type +if [[ $release_type == "patch" ]]; then + patch=$((patch + 1)) +elif [[ $release_type == "minor" ]]; then + minor=$((minor + 1)) + patch=0 +elif [[ $release_type == "major" ]]; then + major=$((major + 1)) + minor=0 + patch=0 +fi + +# Create the new version tag +new_version="v$major.$minor.$patch" +git tag "$new_version" +git push origin "$new_version"