diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..120bd1401d51dd59127b173c426c65a96cd51571 --- /dev/null +++ b/.gitignore @@ -0,0 +1,141 @@ +# Created by https://www.toptal.com/developers/gitignore/api/rust,linux,windows,database,rust-analyzer,visualstudiocode,venv,dotenv,virtualenv,direnv,jenv,git +# Edit at https://www.toptal.com/developers/gitignore?templates=rust,linux,windows,database,rust-analyzer,visualstudiocode,venv,dotenv,virtualenv,direnv,jenv,git + +### Database ### +*.accdb +*.db +*.dbf +*.mdb +*.pdb +*.sqlite3 +*.db-shm +*.db-wal + +### direnv ### +.direnv +.envrc + +### dotenv ### +.env + +### Git ### +# Created by git for backups. To disable backups in Git: +# $ git config --global mergetool.keepBackup false +*.orig + +# Created by git when using merge tools for conflicts +*.BACKUP.* +*.BASE.* +*.LOCAL.* +*.REMOTE.* +*_BACKUP_*.txt +*_BASE_*.txt +*_LOCAL_*.txt +*_REMOTE_*.txt + +### JEnv ### +# JEnv local Java version configuration file +.java-version + +# Used by previous versions of JEnv +.jenv-version + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### Rust ### +# 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 + +### rust-analyzer ### +# Can be generated by other build systems other than cargo (ex: bazelbuild/rust_rules) +rust-project.json + + +### venv ### +# Virtualenv +# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ +.Python +[Bb]in +[Ii]nclude +[Ll]ib +[Ll]ib64 +[Ll]ocal +[Ss]cripts +pyvenv.cfg +.venv +pip-selfcheck.json + +### VirtualEnv ### +# Virtualenv +# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/rust,linux,windows,database,rust-analyzer,visualstudiocode,venv,dotenv,virtualenv,direnv,jenv,git diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000000000000000000000000000000000..8e619a44e0cab2494706ca6e9d4043a1c2155848 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "rust-analyzer.showUnlinkedFileNotification": false +} diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..a9cec78ebc19f493222554bc8e9bf65d4e5772cb --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "bambangshop" +version = "0.1.0" +edition = "2021" + +[dependencies] +rocket = { version = "0.5.0", features = ["json"] } +dashmap = "5.5.3" +lazy_static = "1.4.0" +reqwest = { version = "0.12", features = ["json"] } +getset = "0.1.2" +dotenvy = "0.15.7" diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..3043f06d471f2933c5a98c71ad06b7d900d6812c --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# BambangShop Publisher App +Tutorial and Example for Advanced Programming 2024 - Faculty of Computer Science, Universitas Indonesia + +--- + +## About this Project +In this repository, we have provided you a REST (REpresentational State Transfer) API project using Rocket web framework. + +This project consists of four modules: +1. `controller`: this module contains handler functions used to receive request and send responses. + In Model-View-Controller (MVC) pattern, this is the Controller part. +2. `model`: this module contains structs that serve as data containers. + In MVC pattern, this is the Model part. +3. `service`: this module contains structs with business logic methods. + In MVC pattern, this is also the Model part. +4. `repository`: this module contains structs that serve as databases and methods to access the databases. + You can use methods of the struct to get list of objects, or operating an object (create, read, update, delete). + +This repository provides a basic functionality that makes BambangShop work: ability to create, read, and delete `Product`s. +This repository already contains a functioning `Product` model, repository, service, and controllers that you can try right away. + +As this is an Observer Design Pattern tutorial repository, you need to implement another feature: `Notification`. +This feature will notify creation, promotion, and deletion of a product, to external subscribers that are interested of a certain product type. +The subscribers are another Rocket instances, so the notification will be sent using HTTP POST request to each subscriber's `receive notification` address. + +## API Documentations + +You can download the Postman Collection JSON here: https://ristek.link/AdvProgWeek7Postman + +After you download the Postman Collection, you can try the endpoints inside "BambangShop Publisher" folder. +This Postman collection also contains endpoints that you need to implement later on (the `Notification` feature). + +Postman is an installable client that you can use to test web endpoints using HTTP request. +You can also make automated functional testing scripts for REST API projects using this client. +You can install Postman via this website: https://www.postman.com/downloads/ + +## How to Run in Development Environment +1. Set up environment variables first by creating `.env` file. + Here is the example of `.env` file: + ```bash + APP_INSTANCE_ROOT_URL="http://localhost:8000" + ``` + Here are the details of each environment variable: + | variable | type | description | + |-----------------------|--------|------------------------------------------------------------| + | APP_INSTANCE_ROOT_URL | string | URL address where this publisher instance can be accessed. | +2. Use `cargo run` to run this app. + (You might want to use `cargo check` if you only need to verify your work without running the app.) + +## Mandatory Checklists (Publisher) +- [ ] Clone https://gitlab.com/ichlaffterlalu/bambangshop to a new repository. +- **STAGE 1: Implement models and repositories** + - [ ] Commit: `Create Subscriber model struct.` + - [ ] Commit: `Create Notification model struct.` + - [ ] Commit: `Create Subscriber database and Subscriber repository struct skeleton.` + - [ ] Commit: `Implement add function in Subscriber repository.` + - [ ] Commit: `Implement list_all function in Subscriber repository.` + - [ ] Commit: `Implement delete function in Subscriber repository.` + - [ ] Write answers of your learning module's "Reflection Publisher-1" questions in this README. +- **STAGE 2: Implement services and controllers** + - [ ] Commit: `Create Notification service struct skeleton.` + - [ ] Commit: `Implement subscribe function in Notification service.` + - [ ] Commit: `Implement subscribe function in Notification controller.` + - [ ] Commit: `Implement unsubscribe function in Notification service.` + - [ ] Commit: `Implement unsubscribe function in Notification controller.` + - [ ] Write answers of your learning module's "Reflection Publisher-2" questions in this README. +- **STAGE 3: Implement notification mechanism** + - [ ] Commit: `Implement update method in Subscriber model to send notification HTTP requests.` + - [ ] Commit: `Implement notify function in Notification service to notify each Subscriber.` + - [ ] Commit: `Implement publish function in Program service and Program controller.` + - [ ] Commit: `Edit Product service methods to call notify after create/delete.` + - [ ] Write answers of your learning module's "Reflection Publisher-3" questions in this README. + +## Your Reflections +This is the place for you to write reflections: + +### Mandatory (Publisher) Reflections + +#### Reflection Publisher-1 + +#### Reflection Publisher-2 + +#### Reflection Publisher-3 diff --git a/Rocket.toml b/Rocket.toml new file mode 100644 index 0000000000000000000000000000000000000000..e98afca35809cfb27d3ef162528d758b291c78fc --- /dev/null +++ b/Rocket.toml @@ -0,0 +1,7 @@ +[debug] +address = "127.0.0.1" +port = 8000 + +[release] +address = "0.0.0.0" +port = 8000 diff --git a/src/controller/mod.rs b/src/controller/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..861f657d127990136aaeda07c46f9cb78a73ac78 --- /dev/null +++ b/src/controller/mod.rs @@ -0,0 +1,10 @@ +pub mod product; + +use rocket::fairing::AdHoc; + +pub fn route_stage() -> AdHoc { + return AdHoc::on_ignite("Initializing controller routes...", |rocket| async { + rocket + .mount("/product", routes![product::create, product::list, product::read, product::delete]) + }); +} diff --git a/src/controller/product.rs b/src/controller/product.rs new file mode 100644 index 0000000000000000000000000000000000000000..1cc538419d9a5a7f6b73e520417c04426fefbb3b --- /dev/null +++ b/src/controller/product.rs @@ -0,0 +1,39 @@ +use rocket::response::status::Created; +use rocket::serde::json::Json; + +use bambangshop::Result; +use crate::model::product::Product; +use crate::service::product::ProductService; + + +#[post("/", data = "<product>")] +pub fn create(product: Json<Product>) -> Result<Created<Json<Product>>> { + return match ProductService::create(product.into_inner()) { + Ok(f) => Ok(Created::new("/").body(Json::from(f))), + Err(e) => Err(e) + }; +} + +#[get("/")] +pub fn list() -> Result<Json<Vec<Product>>> { + return match ProductService::list() { + Ok(f) => Ok(Json::from(f)), + Err(e) => Err(e) + }; +} + +#[get("/<id>")] +pub fn read(id: usize) -> Result<Json<Product>> { + return match ProductService::read(id) { + Ok(f) => Ok(Json::from(f)), + Err(e) => Err(e) + }; +} + +#[delete("/<id>")] +pub fn delete(id: usize) -> Result<Json<Product>> { + return match ProductService::delete(id) { + Ok(f) => Ok(Json::from(f)), + Err(e) => Err(e) + }; +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..47e876a86aa022703f1010b4f9e8fbac33ce5d9e --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,58 @@ +use lazy_static::lazy_static; +use dotenvy::dotenv; +use getset::Getters; +use rocket::figment::{Figment, providers::{Serialized, Env}}; +use rocket::http::Status; +use rocket::serde::json::Json; +use rocket::serde::{Deserialize, Serialize}; +use rocket::response::status::Custom; +use reqwest::{Client, ClientBuilder}; + +lazy_static! { + pub static ref REQWEST_CLIENT: Client = ClientBuilder::new().build().unwrap(); + pub static ref APP_CONFIG: AppConfig = AppConfig::generate(); +} + +#[derive(Debug, Deserialize, Serialize, Getters)] +#[serde(crate = "rocket::serde")] +pub struct AppConfig { + #[getset(get = "pub with_prefix")] + instance_root_url: String +} + +impl Default for AppConfig { + fn default() -> AppConfig { + return AppConfig { + instance_root_url: String::from("http://localhost:8001") + } + } +} + +impl AppConfig { + pub fn generate() -> AppConfig { + dotenv().ok(); + return Figment::from(Serialized::defaults(AppConfig::default())) + .merge(Env::prefixed("APP_").global()) + .extract().unwrap(); + } +} + +pub type Result<T, E = Error> = std::result::Result<T, E>; + +pub type Error = Custom<Json<ErrorResponse>>; + +#[derive(Serialize, Debug, Clone, PartialEq)] +#[serde(crate = "rocket::serde")] +pub struct ErrorResponse { + pub status_code: Status, + pub message: String +} + +pub fn compose_error_response(status_code: Status, message: String) -> Custom<Json<ErrorResponse>> { + return Custom(status_code, Json::from( + ErrorResponse { + status_code: status_code, + message: message, + } + )); +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000000000000000000000000000000000000..cde1723ebb229183e0a3877f2c0e4a712674f92a --- /dev/null +++ b/src/main.rs @@ -0,0 +1,17 @@ +#[macro_use] extern crate rocket; + +pub mod controller; +pub mod service; +pub mod repository; +pub mod model; + +use dotenvy::dotenv; +use crate::controller::route_stage; + +#[launch] +fn rocket() -> _ { + dotenv().ok(); + rocket::build() + .manage(reqwest::Client::builder().build().unwrap()) + .attach(route_stage()) +} diff --git a/src/model/mod.rs b/src/model/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..68024e148492337f0e30a98004247f4755ad48b6 --- /dev/null +++ b/src/model/mod.rs @@ -0,0 +1 @@ +pub mod product; diff --git a/src/model/product.rs b/src/model/product.rs new file mode 100644 index 0000000000000000000000000000000000000000..260a68a633c03b4c9fc8aafc415c380b1befda65 --- /dev/null +++ b/src/model/product.rs @@ -0,0 +1,20 @@ +use rocket::serde::{Serialize, Deserialize}; + +use bambangshop::APP_CONFIG; + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(crate = "rocket::serde")] +pub struct Product { + #[serde(skip_deserializing)] + pub id: usize, + pub title: String, + pub description: String, + pub price: f64, + pub product_type: String, +} + +impl Product { + pub fn get_url(&self) -> String { + return format!("{}/product/{}", APP_CONFIG.get_instance_root_url(), self.id); + } +} diff --git a/src/repository/mod.rs b/src/repository/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..68024e148492337f0e30a98004247f4755ad48b6 --- /dev/null +++ b/src/repository/mod.rs @@ -0,0 +1 @@ +pub mod product; diff --git a/src/repository/product.rs b/src/repository/product.rs new file mode 100644 index 0000000000000000000000000000000000000000..bfe55c281ad15947aa1bdb39b74b334046709a01 --- /dev/null +++ b/src/repository/product.rs @@ -0,0 +1,39 @@ +use dashmap::DashMap; +use lazy_static::lazy_static; +use crate::model::product::Product; + +// Singleton of Database +lazy_static! { + static ref PRODUCTS: DashMap<usize, Product> = DashMap::new(); +} + +pub struct ProductRepository; + +impl ProductRepository { + pub fn add(mut product: Product) -> Product { + product.id = PRODUCTS.len(); + let product_value = product.clone(); + PRODUCTS.insert(product_value.id, product_value); + return product; + } + + pub fn list_all() -> Vec<Product> { + return PRODUCTS.iter().map(|f| f.value().clone()).collect(); + } + + pub fn get_by_id(id: usize) -> Option<Product> { + let result = PRODUCTS.get(&id); + if !result.is_none() { + return Some(result.unwrap().clone()); + } + return None; + } + + pub fn delete(id: usize) -> Option<Product> { + let result = PRODUCTS.remove(&id); + if !result.is_none() { + return Some(result.unwrap().1); + } + return None; + } +} diff --git a/src/service/mod.rs b/src/service/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..68024e148492337f0e30a98004247f4755ad48b6 --- /dev/null +++ b/src/service/mod.rs @@ -0,0 +1 @@ +pub mod product; diff --git a/src/service/product.rs b/src/service/product.rs new file mode 100644 index 0000000000000000000000000000000000000000..775bf09d1d171391547c61071a0f17c976131036 --- /dev/null +++ b/src/service/product.rs @@ -0,0 +1,45 @@ +use rocket::http::Status; +use rocket::serde::json::Json; + +use bambangshop::{Result, compose_error_response}; +use crate::model::product::Product; +use crate::repository::product::ProductRepository; + +pub struct ProductService; + +impl ProductService { + pub fn create(mut product: Product) -> Result<Product> { + product.product_type = product.product_type.to_uppercase(); + let product_result: Product = ProductRepository::add(product); + + return Ok(product_result); + } + + pub fn list() -> Result<Vec<Product>> { + return Ok(ProductRepository::list_all()); + } + + pub fn read(id: usize) -> Result<Product> { + let product_opt: Option<Product> = ProductRepository::get_by_id(id); + if product_opt.is_none() { + return Err(compose_error_response( + Status::NotFound, + String::from("Product not found.") + )); + } + return Ok(product_opt.unwrap()); + } + + pub fn delete(id: usize) -> Result<Json<Product>> { + let product_opt: Option<Product> = ProductRepository::delete(id); + if product_opt.is_none() { + return Err(compose_error_response( + Status::NotFound, + String::from("Product not found.") + )); + } + let product: Product = product_opt.unwrap(); + + return Ok(Json::from(product)); + } +}