├── .github └── workflows │ ├── continuous_integration.yaml │ └── security_audit.yaml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── README.md ├── crates ├── adapter │ ├── Cargo.toml │ └── src │ │ ├── api.rs │ │ ├── controller │ │ ├── area_of_life.rs │ │ ├── mod.rs │ │ └── thought.rs │ │ ├── db.rs │ │ ├── lib.rs │ │ ├── model │ │ ├── app │ │ │ ├── area_of_life.rs │ │ │ ├── mod.rs │ │ │ └── thought.rs │ │ ├── mod.rs │ │ └── view │ │ │ ├── json.rs │ │ │ └── mod.rs │ │ └── presenter │ │ ├── cli.rs │ │ ├── http_json_api │ │ ├── mod.rs │ │ └── to_json.rs │ │ └── mod.rs ├── application │ ├── Cargo.toml │ └── src │ │ ├── gateway │ │ ├── mod.rs │ │ └── repository │ │ │ ├── area_of_life.rs │ │ │ ├── mod.rs │ │ │ └── thought.rs │ │ ├── identifier.rs │ │ ├── lib.rs │ │ └── usecase │ │ ├── area_of_life │ │ ├── check_existence.rs │ │ ├── create.rs │ │ ├── delete.rs │ │ ├── mod.rs │ │ ├── read_all.rs │ │ ├── update.rs │ │ └── validate.rs │ │ ├── mod.rs │ │ └── thought │ │ ├── create.rs │ │ ├── delete.rs │ │ ├── find_by_id.rs │ │ ├── mod.rs │ │ ├── read_all.rs │ │ ├── update.rs │ │ └── validate.rs ├── cli │ ├── Cargo.toml │ └── src │ │ └── lib.rs ├── db │ ├── Cargo.toml │ └── src │ │ ├── in_memory.rs │ │ ├── json_file │ │ ├── area_of_life.rs │ │ ├── mod.rs │ │ ├── models.rs │ │ └── thought.rs │ │ └── lib.rs ├── desktop-egui │ ├── Cargo.toml │ └── src │ │ ├── actions.rs │ │ ├── lib.rs │ │ └── ui.rs ├── domain │ ├── Cargo.toml │ └── src │ │ ├── entity │ │ ├── area_of_life.rs │ │ ├── mod.rs │ │ └── thought.rs │ │ ├── lib.rs │ │ └── value_object │ │ ├── id.rs │ │ ├── mod.rs │ │ └── name.rs ├── infrastructure │ ├── Cargo.toml │ └── src │ │ ├── cli.rs │ │ ├── desktop.rs │ │ ├── lib.rs │ │ ├── logger.rs │ │ ├── storage.rs │ │ └── web.rs ├── json-boundary │ ├── Cargo.toml │ └── src │ │ ├── domain.rs │ │ ├── lib.rs │ │ ├── status_code.rs │ │ └── usecase │ │ ├── area_of_life │ │ ├── create.rs │ │ ├── delete.rs │ │ ├── mod.rs │ │ ├── read_all.rs │ │ └── update.rs │ │ ├── mod.rs │ │ └── thought │ │ ├── create.rs │ │ ├── delete.rs │ │ ├── find_by_id.rs │ │ ├── mod.rs │ │ ├── read_all.rs │ │ └── update.rs ├── web-app-api │ ├── Cargo.toml │ └── src │ │ ├── http.rs │ │ └── lib.rs ├── web-app-kern │ ├── Cargo.toml │ └── src │ │ ├── domain.rs │ │ ├── lib.rs │ │ └── usecase │ │ ├── area_of_life.rs │ │ ├── mod.rs │ │ └── thought.rs ├── web-app-seed │ ├── Cargo.toml │ └── src │ │ ├── lib.rs │ │ └── view │ │ ├── mod.rs │ │ ├── new_area_of_life_dialog.rs │ │ └── page │ │ ├── home.rs │ │ └── mod.rs ├── web-app │ ├── .gitignore │ ├── Cargo.lock │ ├── Cargo.toml │ ├── Trunk.toml │ ├── assets │ │ ├── bulma.min.css │ │ ├── fa-solid.min.css │ │ ├── fontawesome.min.css │ │ └── webfonts │ │ │ └── fa-solid-900.woff2 │ ├── index.html │ ├── main.sass │ └── src │ │ └── main.rs └── web-server-warp │ ├── Cargo.toml │ └── src │ ├── handler │ ├── area_of_life │ │ ├── create.rs │ │ ├── delete.rs │ │ ├── mod.rs │ │ ├── read_all.rs │ │ └── update.rs │ ├── error.rs │ ├── mod.rs │ └── thought │ │ ├── create.rs │ │ ├── delete.rs │ │ ├── find_by_id.rs │ │ ├── mod.rs │ │ ├── read_all.rs │ │ └── update.rs │ ├── lib.rs │ ├── route.rs │ ├── tests.rs │ └── webapp.rs ├── justfile ├── shell.nix └── src └── bin ├── cli.rs ├── desktop.rs └── web.rs /.github/workflows/continuous_integration.yaml: -------------------------------------------------------------------------------- 1 | name: Continuous integration 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | env: 12 | CARGO_TERM_COLOR: always 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: Install Rust toolchain 20 | uses: actions-rs/toolchain@v1 21 | with: 22 | profile: minimal 23 | toolchain: stable 24 | components: rustfmt, clippy 25 | 26 | - name: Checkout code 27 | uses: actions/checkout@v3 28 | 29 | - name: Cache Dependencies 30 | uses: Swatinem/rust-cache@v2 31 | 32 | - name: Install trunk 33 | run: | 34 | wget -qO- https://github.com/thedodd/trunk/releases/download/v0.20.2/trunk-x86_64-unknown-linux-gnu.tar.gz | tar -C ~/.cargo/bin -xzf- 35 | 36 | - name: Build web-app 37 | run: | 38 | cd crates/web-app/ 39 | rustup target add wasm32-unknown-unknown 40 | trunk -V 41 | trunk build 42 | 43 | - name: Check code formatting 44 | uses: actions-rs/cargo@v1 45 | with: 46 | command: fmt 47 | args: --all -- --check 48 | 49 | - name: Check for linter warnings 50 | uses: actions-rs/cargo@v1 51 | with: 52 | command: clippy 53 | args: -- -D warnings 54 | 55 | - name: Build project 56 | uses: actions-rs/cargo@v1 57 | with: 58 | command: build 59 | args: --release --all-features 60 | 61 | - name: Test project 62 | uses: actions-rs/cargo@v1 63 | with: 64 | command: test 65 | args: --workspace --all-features 66 | -------------------------------------------------------------------------------- /.github/workflows/security_audit.yaml: -------------------------------------------------------------------------------- 1 | name: Security audit 2 | on: 3 | push: 4 | paths: 5 | - '**/Cargo.toml' 6 | - '**/Cargo.lock' 7 | # schedule: 8 | # - cron: '0 0 * * *' 9 | 10 | jobs: 11 | security_audit: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions-rs/audit-check@v1 16 | with: 17 | token: ${{ secrets.GITHUB_TOKEN }} 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "clean-architecture-with-rust" 3 | version.workspace = true 4 | authors.workspace = true 5 | edition.workspace = true 6 | rust-version.workspace = true 7 | publish = false 8 | 9 | [[bin]] 10 | name = "clean-architecture-with-rust-cli" 11 | path = "src/bin/cli.rs" 12 | 13 | [[bin]] 14 | name = "clean-architecture-with-rust-web" 15 | path = "src/bin/web.rs" 16 | 17 | [[bin]] 18 | name = "clean-architecture-with-rust-desktop" 19 | path = "src/bin/desktop.rs" 20 | 21 | [workspace] 22 | members = [ 23 | "crates/adapter", 24 | "crates/application", 25 | "crates/cli", 26 | "crates/db", 27 | "crates/desktop-egui", 28 | "crates/domain", 29 | "crates/infrastructure", 30 | "crates/json-boundary", 31 | "crates/web-app-api", 32 | "crates/web-app-kern", 33 | "crates/web-app-seed", 34 | "crates/web-server-warp" 35 | ] 36 | exclude = [ "crates/web-app" ] 37 | 38 | [workspace.package] 39 | authors = ["Markus Kohlhase "] 40 | version = "0.0.0" 41 | edition = "2021" 42 | rust-version = "1.79" 43 | 44 | [dependencies] 45 | cawr-infrastructure = "0.0.0" 46 | 47 | [patch.crates-io] 48 | cawr-adapter = { path = "crates/adapter" } 49 | cawr-application = { path = "crates/application" } 50 | cawr-cli = { path = "crates/cli" } 51 | cawr-db = { path = "crates/db" } 52 | cawr-desktop-egui = { path = "crates/desktop-egui" } 53 | cawr-domain = { path = "crates/domain" } 54 | cawr-infrastructure = { path = "crates/infrastructure" } 55 | cawr-json-boundary = { path = "crates/json-boundary" } 56 | cawr-web-app-api = { path = "crates/web-app-api" } 57 | cawr-web-app-kern = { path = "crates/web-app-kern" } 58 | cawr-web-app-seed = { path = "crates/web-app-seed" } 59 | cawr-web-server-warp = { path = "crates/web-server-warp" } 60 | 61 | [profile.release] 62 | lto = true 63 | opt-level = 3 64 | codegen-units = 1 65 | strip = true 66 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Full-Stack Clean Architecture with Rust 2 | 3 | This repository contains an example implementation of a 4 | [Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) 5 | written in [Rust](https://rust-lang.org). 6 | 7 | ## Circles 8 | 9 | Each circle (a.k.a layer) is organized in a separate crate. 10 | Currently there are these circles: 11 | 12 | - `domain` 13 | - `application` 14 | - `adapter` 15 | - `json-boundary` 16 | - `infrastructure` 17 | - `cli` 18 | - `db` 19 | - `desktop-egui` 20 | - `web` 21 | - `web-app` 22 | - `web-app-api` 23 | - `web-app-kern` 24 | - `web-app-seed` 25 | - `web-server-warp` 26 | 27 | Depending on your system the amount and the name of circles could 28 | be different but the main **dependency rule** must be ensured: 29 | 30 | > Source code dependencies can only point inwards. 31 | 32 | that means 33 | 34 | > Nothing in an inner circle can know anything at all about 35 | > something in an outer circle 36 | 37 | ## Build & run 38 | 39 | First install [just](https://just.systems/): 40 | 41 | ``` 42 | cargo install just 43 | ``` 44 | 45 | Then you can run 46 | 47 | ``` 48 | just run-web 49 | ``` 50 | or 51 | 52 | ``` 53 | just run-desktop 54 | ``` 55 | 56 | or 57 | 58 | ``` 59 | just run-cli 60 | ``` 61 | 62 | ## Example Szenario 63 | 64 | The main purpose of this example is to discuss the architecture, 65 | not the application szenario itself. 66 | Nevertheless, the code represents a real-world application 67 | that helps self-employed people organize their lives. 68 | 69 | ### User Stories 70 | 71 | > *As a* self-employed person, 72 | > *I want to* be able to write down spontaneous thoughts, 73 | > *so that* I can find them later again at a central point. 74 | 75 | > *As a* self-employed person, 76 | > *I want to* structure my thoughts, 77 | > *so that* they're connected with my personal life topics. 78 | -------------------------------------------------------------------------------- /crates/adapter/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cawr-adapter" 3 | version.workspace = true 4 | edition.workspace = true 5 | rust-version.workspace = true 6 | publish = false 7 | 8 | [dependencies] 9 | # Workspace dependencies 10 | cawr-application = "=0.0.0" 11 | cawr-domain = "=0.0.0" 12 | cawr-json-boundary = "=0.0.0" 13 | 14 | # External dependencies 15 | log = "0.4" 16 | serde = { version = "1.0", features = ["derive"] } 17 | thiserror = "1.0" 18 | -------------------------------------------------------------------------------- /crates/adapter/src/api.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | controller, 3 | model::app::{area_of_life as aol, thought}, 4 | presenter::Present, 5 | }; 6 | use cawr_application::{gateway::repository as repo, identifier::NewId}; 7 | use std::{collections::HashSet, sync::Arc}; 8 | 9 | pub struct Api { 10 | db: Arc, 11 | presenter: P, 12 | } 13 | 14 | impl Clone for Api 15 | where 16 | P: Clone, 17 | { 18 | fn clone(&self) -> Self { 19 | let db = Arc::clone(&self.db); 20 | let presenter = self.presenter.clone(); 21 | Self { db, presenter } 22 | } 23 | } 24 | 25 | impl Api 26 | where 27 | D: repo::thought::Repo 28 | + repo::area_of_life::Repo 29 | + 'static 30 | + NewId 31 | + NewId, 32 | P: Present 33 | + Present 34 | + Present 35 | + Present 36 | + Present 37 | + Present 38 | + Present 39 | + Present 40 | + Present, 41 | { 42 | pub const fn new(db: Arc, presenter: P) -> Self { 43 | Self { db, presenter } 44 | } 45 | fn thought_controller(&self) -> controller::thought::Controller { 46 | controller::thought::Controller::new(&self.db, &self.presenter) 47 | } 48 | fn aol_controller(&self) -> controller::area_of_life::Controller { 49 | controller::area_of_life::Controller::new(&self.db, &self.presenter) 50 | } 51 | pub fn create_thought( 52 | &self, 53 | title: impl Into, 54 | areas_of_life: &HashSet, 55 | ) ->

>::ViewModel { 56 | self.thought_controller() 57 | .create_thought(title, areas_of_life) 58 | } 59 | pub fn update_thought( 60 | &self, 61 | id: &str, 62 | title: impl Into, 63 | areas_of_life: &HashSet, 64 | ) ->

>::ViewModel { 65 | self.thought_controller() 66 | .update_thought(id, title, areas_of_life) 67 | } 68 | pub fn delete_thought(&self, id: &str) ->

>::ViewModel { 69 | self.thought_controller().delete_thought(id) 70 | } 71 | pub fn find_thought(&self, id: &str) ->

>::ViewModel { 72 | self.thought_controller().find_thought(id) 73 | } 74 | pub fn read_all_thoughts(&self) ->

>::ViewModel { 75 | self.thought_controller().read_all_thoughts() 76 | } 77 | pub fn create_area_of_life( 78 | &self, 79 | name: impl Into, 80 | ) ->

>::ViewModel { 81 | self.aol_controller().create_area_of_life(name) 82 | } 83 | pub fn update_area_of_life( 84 | &self, 85 | id: &str, 86 | name: impl Into, 87 | ) ->

>::ViewModel { 88 | self.aol_controller().update_area_of_life(id, name) 89 | } 90 | pub fn delete_area_of_life(&self, id: &str) ->

>::ViewModel { 91 | self.aol_controller().delete_area_of_life(id) 92 | } 93 | pub fn read_all_areas_of_life(&self) ->

>::ViewModel { 94 | self.aol_controller().read_all_areas_of_life() 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /crates/adapter/src/controller/area_of_life.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | model::app::area_of_life::{self as app, Id}, 3 | presenter::Present, 4 | }; 5 | use cawr_application::{ 6 | gateway::repository::area_of_life::Repo, identifier::NewId, usecase::area_of_life as uc, 7 | }; 8 | use cawr_domain::area_of_life as aol; 9 | 10 | pub struct Controller<'d, 'p, D, P> { 11 | db: &'d D, 12 | presenter: &'p P, 13 | } 14 | 15 | impl<'d, 'p, D, P> Controller<'d, 'p, D, P> 16 | where 17 | D: Repo + 'static + NewId, 18 | P: Present 19 | + Present 20 | + Present 21 | + Present, 22 | { 23 | pub const fn new(db: &'d D, presenter: &'p P) -> Self { 24 | Self { db, presenter } 25 | } 26 | pub fn create_area_of_life( 27 | &self, 28 | name: impl Into, 29 | ) ->

>::ViewModel { 30 | let name = name.into(); 31 | log::debug!("Create area of life '{}'", name); 32 | let req = app::create::Request { name }; 33 | let interactor = uc::create::CreateAreaOfLife::new(self.db, self.db); 34 | let res = interactor.exec(req); 35 | self.presenter.present(res) 36 | } 37 | pub fn update_area_of_life( 38 | &self, 39 | id: &str, 40 | name: impl Into, 41 | ) ->

>::ViewModel { 42 | let name = name.into(); 43 | log::debug!("Update area of life '{:?}'", id); 44 | let res = id 45 | .parse::() 46 | .map_err(|_| app::update::Error::Id) 47 | .and_then(|id| { 48 | let req = app::update::Request { 49 | id: id.into(), 50 | name, 51 | }; 52 | let interactor = uc::update::UpdateAreaOfLife::new(self.db); 53 | interactor.exec(req).map_err(Into::into) 54 | }); 55 | self.presenter.present(res) 56 | } 57 | pub fn delete_area_of_life(&self, id: &str) ->

>::ViewModel { 58 | log::debug!("Delete area of life {}", id); 59 | let res = id 60 | .parse::() 61 | .map_err(|_| app::delete::Error::Id) 62 | .map(Into::into) 63 | .map(|id| app::delete::Request { id }) 64 | .and_then(|req| { 65 | let interactor = uc::delete::Delete::new(self.db); 66 | interactor.exec(req).map_err(|e| { 67 | // TODO: impl From for app::Error 68 | match e { 69 | uc::delete::Error::Repo => app::delete::Error::Repo, 70 | uc::delete::Error::NotFound => app::delete::Error::NotFound, 71 | } 72 | }) 73 | }); 74 | self.presenter.present(res) 75 | } 76 | pub fn read_all_areas_of_life(&self) ->

>::ViewModel { 77 | log::debug!("Read all areas of life"); 78 | let interactor = uc::read_all::ReadAll::new(self.db); 79 | let res = interactor.exec(app::read_all::Request {}); 80 | self.presenter.present(res) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /crates/adapter/src/controller/mod.rs: -------------------------------------------------------------------------------- 1 | //! Controllers 2 | //! 3 | //! *"The **controller** takes user input, converts it into the request model 4 | //! defined by the use case interactor and passes this to the same."* 5 | //! 6 | //! [...] 7 | //! 8 | //! *"It is the role of the controller to convert the given information 9 | //! into a format which is most convenient for and defined 10 | //! by the use case interactor."* [^1] 11 | //! 12 | //! [^1]: 13 | 14 | pub mod area_of_life; 15 | pub mod thought; 16 | -------------------------------------------------------------------------------- /crates/adapter/src/controller/thought.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | model::app::{ 3 | area_of_life as aol, 4 | thought::{self as app, Id}, 5 | }, 6 | presenter::Present, 7 | }; 8 | use cawr_application::{gateway::repository as repo, identifier::NewId, usecase::thought as uc}; 9 | use std::collections::HashSet; 10 | 11 | pub struct Controller<'d, 'p, D, P> { 12 | db: &'d D, 13 | presenter: &'p P, 14 | } 15 | 16 | impl<'d, 'p, D, P> Controller<'d, 'p, D, P> 17 | where 18 | D: repo::thought::Repo + repo::area_of_life::Repo + 'static + NewId, 19 | P: Present 20 | + Present 21 | + Present 22 | + Present 23 | + Present, 24 | { 25 | pub const fn new(db: &'d D, presenter: &'p P) -> Self { 26 | Self { db, presenter } 27 | } 28 | 29 | pub fn create_thought( 30 | &self, 31 | title: impl Into, 32 | areas_of_life: &HashSet, 33 | ) ->

>::ViewModel { 34 | let title = title.into(); 35 | log::debug!("Create thought '{}'", title); 36 | let res = parse_area_of_life_ids(areas_of_life) 37 | .map_err(Into::into) 38 | .and_then(|areas_of_life: HashSet<_>| { 39 | let req = app::create::Request { 40 | title, 41 | areas_of_life, 42 | }; 43 | let interactor = uc::create::CreateThought::new(self.db, self.db); 44 | interactor.exec(req).map_err(Into::into) 45 | }); 46 | self.presenter.present(res) 47 | } 48 | 49 | pub fn update_thought( 50 | &self, 51 | id: &str, 52 | title: impl Into, 53 | areas_of_life: &HashSet, 54 | ) ->

>::ViewModel { 55 | let title = title.into(); 56 | log::debug!("Update thought '{:?}'", id); 57 | let res = id 58 | .parse::() 59 | .map_err(|_| app::update::Error::Id) 60 | .and_then(|id| { 61 | parse_area_of_life_ids(areas_of_life) 62 | .map_err(Into::into) 63 | .and_then(|areas_of_life: HashSet<_>| { 64 | let req = app::update::Request { 65 | id: id.into(), 66 | title, 67 | areas_of_life, 68 | }; 69 | let interactor = uc::update::UpdateThought::new(self.db); 70 | interactor.exec(req).map_err(Into::into) 71 | }) 72 | }); 73 | self.presenter.present(res) 74 | } 75 | 76 | pub fn delete_thought(&self, id: &str) ->

>::ViewModel { 77 | log::debug!("Delete thought {}", id); 78 | let res = id 79 | .parse::() 80 | .map_err(|_| app::delete::Error::Id) 81 | .map(Into::into) 82 | .map(|id| app::delete::Request { id }) 83 | .and_then(|req| { 84 | let interactor = uc::delete::Delete::new(self.db); 85 | interactor.exec(req).map_err(app::delete::Error::from) 86 | }); 87 | self.presenter.present(res) 88 | } 89 | 90 | pub fn find_thought(&self, id: &str) ->

>::ViewModel { 91 | log::debug!("Find thought {}", id); 92 | let res = id 93 | .parse::() 94 | .map_err(|_| app::find_by_id::Error::Id) 95 | .map(Into::into) 96 | .map(|id| app::find_by_id::Request { id }) 97 | .and_then(|req| { 98 | let interactor = uc::find_by_id::FindById::new(self.db); 99 | interactor.exec(req).map_err(app::find_by_id::Error::from) 100 | }); 101 | self.presenter.present(res) 102 | } 103 | pub fn read_all_thoughts(&self) ->

>::ViewModel { 104 | log::debug!("Read all thoughts"); 105 | let interactor = uc::read_all::ReadAll::new(self.db); 106 | let res = interactor.exec(app::read_all::Request {}); 107 | self.presenter.present(res) 108 | } 109 | } 110 | 111 | fn parse_area_of_life_ids( 112 | areas_of_life: &HashSet, 113 | ) -> Result, aol::ParseIdError> { 114 | areas_of_life 115 | .iter() 116 | .map(|id| id.parse()) 117 | .collect::, _>>() 118 | .map(|ids| ids.into_iter().map(Into::into).collect()) 119 | } 120 | -------------------------------------------------------------------------------- /crates/adapter/src/db.rs: -------------------------------------------------------------------------------- 1 | use cawr_application::{gateway::repository as repo, identifier::NewId}; 2 | 3 | pub trait Db: 4 | repo::thought::Repo 5 | + NewId 6 | + repo::area_of_life::Repo 7 | + NewId 8 | + 'static 9 | { 10 | } 11 | -------------------------------------------------------------------------------- /crates/adapter/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod api; 2 | mod controller; 3 | pub mod db; 4 | pub mod model; 5 | pub mod presenter; 6 | -------------------------------------------------------------------------------- /crates/adapter/src/model/app/area_of_life.rs: -------------------------------------------------------------------------------- 1 | use std::{fmt, str::FromStr}; 2 | 3 | use thiserror::Error; 4 | 5 | use cawr_domain::area_of_life as aol; 6 | 7 | /// This is the public ID of an area of life. 8 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 9 | pub struct Id(u64); 10 | 11 | impl Id { 12 | #[must_use] 13 | pub const fn to_u64(self) -> u64 { 14 | self.0 15 | } 16 | } 17 | 18 | impl From for Id { 19 | fn from(id: aol::Id) -> Self { 20 | Self(id.to_u64()) 21 | } 22 | } 23 | 24 | impl From for aol::Id { 25 | fn from(id: Id) -> Self { 26 | Self::new(id.0) 27 | } 28 | } 29 | 30 | #[derive(Debug, Error)] 31 | #[error("Unable to parse area of life ID")] 32 | pub struct ParseIdError; 33 | 34 | impl FromStr for Id { 35 | type Err = ParseIdError; 36 | fn from_str(s: &str) -> Result { 37 | let id = s.parse().map_err(|_| ParseIdError)?; 38 | Ok(Self(id)) 39 | } 40 | } 41 | 42 | impl fmt::Display for Id { 43 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 44 | write!(f, "{}", self.0) 45 | } 46 | } 47 | 48 | pub mod create { 49 | use cawr_application::usecase::area_of_life::create as uc; 50 | use std::result; 51 | 52 | pub type Request = uc::Request; 53 | pub type Response = uc::Response; 54 | pub type Result = result::Result; 55 | pub type Error = uc::Error; 56 | } 57 | 58 | pub mod update { 59 | use super::{Id, ParseIdError}; 60 | use cawr_application::usecase::area_of_life::{update as uc, validate::AreaOfLifeInvalidity}; 61 | use std::result; 62 | use thiserror::Error; 63 | 64 | pub type Request = uc::Request; 65 | pub type Response = uc::Response; 66 | pub type Result = result::Result; 67 | 68 | #[derive(Debug, Error)] 69 | pub enum Error { 70 | #[error("{}", ParseIdError)] 71 | Id, 72 | #[error("Area of life {0:?} not found")] 73 | NotFound(Id), 74 | #[error("{}", uc::Error::Repo)] 75 | Repo, 76 | #[error(transparent)] 77 | Invalidity(#[from] AreaOfLifeInvalidity), 78 | } 79 | 80 | impl From for Error { 81 | fn from(_: ParseIdError) -> Self { 82 | Self::Id 83 | } 84 | } 85 | 86 | impl From for Error { 87 | fn from(from: uc::Error) -> Self { 88 | match from { 89 | uc::Error::NotFound(id) => Self::NotFound(id.into()), 90 | uc::Error::Invalidity(i) => Self::Invalidity(i), 91 | uc::Error::Repo => Self::Repo, 92 | } 93 | } 94 | } 95 | } 96 | 97 | pub mod read_all { 98 | use cawr_application::usecase::area_of_life::read_all as uc; 99 | use std::result; 100 | 101 | pub type Request = uc::Request; 102 | pub type Response = uc::Response; 103 | pub type Result = result::Result; 104 | pub type Error = uc::Error; 105 | } 106 | 107 | pub mod delete { 108 | use super::ParseIdError; 109 | use cawr_application::usecase::area_of_life::delete as uc; 110 | use std::result; 111 | use thiserror::Error; 112 | 113 | pub type Request = uc::Request; 114 | pub type Response = uc::Response; 115 | pub type Result = result::Result; 116 | 117 | #[derive(Debug, Error)] 118 | pub enum Error { 119 | #[error("{}", ParseIdError)] 120 | Id, 121 | #[error("{}", uc::Error::NotFound)] 122 | NotFound, 123 | #[error("{}", uc::Error::Repo)] 124 | Repo, 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /crates/adapter/src/model/app/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod area_of_life; 2 | pub mod thought; 3 | -------------------------------------------------------------------------------- /crates/adapter/src/model/app/thought.rs: -------------------------------------------------------------------------------- 1 | use std::{fmt, str::FromStr}; 2 | 3 | use thiserror::Error; 4 | 5 | use cawr_domain::thought; 6 | 7 | /// This is the public ID of a thought. 8 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 9 | pub struct Id(u64); 10 | 11 | impl Id { 12 | #[must_use] 13 | pub const fn to_u64(self) -> u64 { 14 | self.0 15 | } 16 | } 17 | 18 | impl From for Id { 19 | fn from(id: thought::Id) -> Self { 20 | Self(id.to_u64()) 21 | } 22 | } 23 | 24 | impl From for thought::Id { 25 | fn from(id: Id) -> Self { 26 | Self::new(id.0) 27 | } 28 | } 29 | 30 | #[derive(Debug, Error)] 31 | #[error("Unable to parse thought ID")] 32 | pub struct ParseIdError; 33 | 34 | impl FromStr for Id { 35 | type Err = ParseIdError; 36 | fn from_str(s: &str) -> Result { 37 | let id = s.parse().map_err(|_| ParseIdError)?; 38 | Ok(Self(id)) 39 | } 40 | } 41 | 42 | impl fmt::Display for Id { 43 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 44 | write!(f, "{}", self.0) 45 | } 46 | } 47 | 48 | pub mod create { 49 | use crate::model::app::area_of_life as aol; 50 | use cawr_application::usecase::thought::{create as uc, validate::ThoughtInvalidity}; 51 | use std::{collections::HashSet, result}; 52 | use thiserror::Error; 53 | 54 | pub type Request = uc::Request; 55 | pub type Response = uc::Response; 56 | pub type Result = result::Result; 57 | 58 | #[derive(Debug, Error)] 59 | pub enum Error { 60 | #[error("{}", aol::ParseIdError)] 61 | AreaOfLifeId, 62 | #[error("{}", uc::Error::NewId)] 63 | NewId, 64 | #[error("{}", uc::Error::Repo)] 65 | Repo, 66 | #[error(transparent)] 67 | Invalidity(#[from] ThoughtInvalidity), 68 | #[error("Areas of life {0:?} not found")] 69 | AreasOfLifeNotFound(HashSet), 70 | } 71 | 72 | impl From for Error { 73 | fn from(_: aol::ParseIdError) -> Self { 74 | Self::AreaOfLifeId 75 | } 76 | } 77 | impl From for Error { 78 | fn from(from: uc::Error) -> Self { 79 | match from { 80 | uc::Error::NewId => Self::NewId, 81 | uc::Error::Repo => Self::Repo, 82 | uc::Error::Invalidity(i) => Self::Invalidity(i), 83 | uc::Error::AreasOfLifeNotFound(ids) => { 84 | Self::AreasOfLifeNotFound(ids.into_iter().map(Into::into).collect()) 85 | } 86 | } 87 | } 88 | } 89 | } 90 | 91 | pub mod update { 92 | use super::ParseIdError; 93 | use crate::model::app::{area_of_life as aol, thought::Id}; 94 | use cawr_application::usecase::thought::{update as uc, validate::ThoughtInvalidity}; 95 | use std::{collections::HashSet, result}; 96 | use thiserror::Error; 97 | 98 | pub type Request = uc::Request; 99 | pub type Response = uc::Response; 100 | pub type Result = result::Result; 101 | 102 | #[derive(Debug, Error)] 103 | pub enum Error { 104 | #[error("{}", ParseIdError)] 105 | Id, 106 | #[error("Thought {0:?} not found")] 107 | NotFound(Id), 108 | #[error("{}", aol::ParseIdError)] 109 | AreaOfLifeId, 110 | #[error("{}", uc::Error::Repo)] 111 | Repo, 112 | #[error(transparent)] 113 | Invalidity(#[from] ThoughtInvalidity), 114 | #[error("Areas of life {0:?} not found")] 115 | AreasOfLifeNotFound(HashSet), 116 | } 117 | 118 | impl From for Error { 119 | fn from(_: aol::ParseIdError) -> Self { 120 | Self::AreaOfLifeId 121 | } 122 | } 123 | impl From for Error { 124 | fn from(from: uc::Error) -> Self { 125 | match from { 126 | uc::Error::Repo => Self::Repo, 127 | uc::Error::Invalidity(i) => Self::Invalidity(i), 128 | uc::Error::ThoughtNotFound(id) => Self::NotFound(Id::from(id)), 129 | uc::Error::AreasOfLifeNotFound(ids) => { 130 | Self::AreasOfLifeNotFound(ids.into_iter().map(Into::into).collect()) 131 | } 132 | } 133 | } 134 | } 135 | } 136 | 137 | pub mod find_by_id { 138 | use super::ParseIdError; 139 | use cawr_application::usecase::thought::find_by_id as uc; 140 | use std::result; 141 | use thiserror::Error; 142 | 143 | pub type Request = uc::Request; 144 | pub type Response = uc::Response; 145 | pub type Result = result::Result; 146 | 147 | #[derive(Debug, Error)] 148 | pub enum Error { 149 | #[error("{}", ParseIdError)] 150 | Id, 151 | #[error("{}", uc::Error::NotFound)] 152 | NotFound, 153 | #[error("{}", uc::Error::Repo)] 154 | Repo, 155 | } 156 | 157 | impl From for Error { 158 | fn from(e: uc::Error) -> Self { 159 | match e { 160 | uc::Error::Repo => Error::Repo, 161 | uc::Error::NotFound => Error::NotFound, 162 | } 163 | } 164 | } 165 | } 166 | 167 | pub mod read_all { 168 | use cawr_application::usecase::thought::read_all as uc; 169 | use std::result; 170 | 171 | pub type Request = uc::Request; 172 | pub type Response = uc::Response; 173 | pub type Result = result::Result; 174 | pub type Error = uc::Error; 175 | } 176 | 177 | pub mod delete { 178 | use super::ParseIdError; 179 | use cawr_application::usecase::thought::delete as uc; 180 | use std::result; 181 | use thiserror::Error; 182 | 183 | pub type Request = uc::Request; 184 | pub type Response = uc::Response; 185 | pub type Result = result::Result; 186 | 187 | #[derive(Debug, Error)] 188 | pub enum Error { 189 | #[error("{}", ParseIdError)] 190 | Id, 191 | #[error("{}", uc::Error::NotFound)] 192 | NotFound, 193 | #[error("{}", uc::Error::Repo)] 194 | Repo, 195 | } 196 | 197 | impl From for Error { 198 | fn from(e: uc::Error) -> Self { 199 | match e { 200 | uc::Error::Repo => Error::Repo, 201 | uc::Error::NotFound => Error::NotFound, 202 | } 203 | } 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /crates/adapter/src/model/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod app; 2 | pub mod view; 3 | -------------------------------------------------------------------------------- /crates/adapter/src/model/view/json.rs: -------------------------------------------------------------------------------- 1 | pub mod thought { 2 | pub use cawr_json_boundary::{ 3 | domain::{Thought, ThoughtId}, 4 | usecase::thought::*, 5 | }; 6 | } 7 | pub mod area_of_life { 8 | pub use cawr_json_boundary::{ 9 | domain::{AreaOfLife, AreaOfLifeId}, 10 | usecase::area_of_life::*, 11 | }; 12 | } 13 | pub use cawr_json_boundary::{Error, Response, Result, StatusCode}; 14 | -------------------------------------------------------------------------------- /crates/adapter/src/model/view/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod json; 2 | -------------------------------------------------------------------------------- /crates/adapter/src/presenter/cli.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | model::app::{area_of_life, thought}, 3 | presenter::Present, 4 | }; 5 | 6 | #[derive(Default)] 7 | pub struct Presenter; 8 | 9 | impl Present for Presenter { 10 | type ViewModel = String; 11 | fn present(&self, result: thought::create::Result) -> Self::ViewModel { 12 | match result { 13 | Ok(data) => format!("Created a new thought (ID = {})", data.id), 14 | Err(err) => format!("Undable to create a new thought: {err}"), 15 | } 16 | } 17 | } 18 | 19 | impl Present for Presenter { 20 | type ViewModel = String; 21 | fn present(&self, result: thought::update::Result) -> Self::ViewModel { 22 | match result { 23 | Ok(()) => "Updated thought".to_string(), 24 | Err(err) => format!("Undable to update thought: {err}"), 25 | } 26 | } 27 | } 28 | 29 | impl Present for Presenter { 30 | type ViewModel = String; 31 | fn present(&self, result: thought::find_by_id::Result) -> Self::ViewModel { 32 | match result { 33 | Ok(thought) => format!("{} ({})", thought.title, thought.id), 34 | Err(err) => format!("Unable find thought: {err}"), 35 | } 36 | } 37 | } 38 | 39 | impl Present for Presenter { 40 | type ViewModel = String; 41 | fn present(&self, result: thought::read_all::Result) -> Self::ViewModel { 42 | match result { 43 | Ok(resp) => resp 44 | .thoughts 45 | .into_iter() 46 | .map(|t| format!("- {} ({})", t.title, t.id)) 47 | .collect::>() 48 | .join("\n"), 49 | Err(err) => format!("Unable read all thoughts: {err}"), 50 | } 51 | } 52 | } 53 | 54 | impl Present for Presenter { 55 | type ViewModel = String; 56 | fn present(&self, result: thought::delete::Result) -> Self::ViewModel { 57 | match result { 58 | Ok(_) => "Successfully deleted thought".to_string(), 59 | Err(err) => format!("Unable delete thought: {err}"), 60 | } 61 | } 62 | } 63 | 64 | impl Present for Presenter { 65 | type ViewModel = String; 66 | fn present(&self, result: area_of_life::create::Result) -> Self::ViewModel { 67 | match result { 68 | Ok(data) => format!("Created a new area of life (ID = {})", data.id), 69 | Err(err) => format!("Undable to create a new area of life: {err}"), 70 | } 71 | } 72 | } 73 | 74 | impl Present for Presenter { 75 | type ViewModel = String; 76 | fn present(&self, result: area_of_life::update::Result) -> Self::ViewModel { 77 | match result { 78 | Ok(()) => "Updated area of life".to_string(), 79 | Err(err) => format!("Undable to update area of life: {err}"), 80 | } 81 | } 82 | } 83 | 84 | impl Present for Presenter { 85 | type ViewModel = String; 86 | fn present(&self, result: area_of_life::read_all::Result) -> Self::ViewModel { 87 | match result { 88 | Ok(resp) => resp 89 | .areas_of_life 90 | .into_iter() 91 | .map(|t| format!("- {} ({})", t.name, t.id)) 92 | .collect::>() 93 | .join("\n"), 94 | Err(err) => format!("Unable read all areas of life: {err}"), 95 | } 96 | } 97 | } 98 | 99 | impl Present for Presenter { 100 | type ViewModel = String; 101 | fn present(&self, result: area_of_life::delete::Result) -> Self::ViewModel { 102 | match result { 103 | Ok(_) => "Successfully deleted area of life".to_string(), 104 | Err(err) => format!("Unable delete aref of life: {err}"), 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /crates/adapter/src/presenter/http_json_api/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | model::view::json::{Error, Response, Result, StatusCode}, 3 | presenter::Present, 4 | }; 5 | 6 | mod to_json; 7 | 8 | #[derive(Default, Clone)] 9 | pub struct Presenter; 10 | 11 | mod thought { 12 | use super::{to_json, Error, Present, Presenter, Response, Result, StatusCode}; 13 | use crate::model::{ 14 | app::thought as app, 15 | view::json::{area_of_life::AreaOfLifeId, thought as view}, 16 | }; 17 | 18 | // -- Create -- // 19 | 20 | impl Present for Presenter { 21 | type ViewModel = Result; 22 | fn present(&self, res: app::create::Result) -> Self::ViewModel { 23 | res.map(to_json::thought::create::thought_id_from_response) 24 | .map(|id| Response { 25 | data: Some(id), 26 | status: StatusCode::CREATED, 27 | }) 28 | .map_err(|err| { 29 | use app::create::Error as E; 30 | match err { 31 | E::AreaOfLifeId => Error { 32 | msg: Some(err.to_string()), 33 | status: StatusCode::BAD_REQUEST, 34 | details: Some(view::create::Error::AreaOfLifeId), 35 | }, 36 | E::Invalidity(invalidity) => Error { 37 | msg: Some(invalidity.to_string()), 38 | status: StatusCode::BAD_REQUEST, 39 | details: Some(to_json::thought::create::from_thought_invalidity( 40 | invalidity, 41 | )), 42 | }, 43 | E::AreasOfLifeNotFound(ref ids) => Error { 44 | msg: Some(err.to_string()), 45 | status: StatusCode::BAD_REQUEST, 46 | details: Some(view::create::Error::AreasOfLifeNotFound( 47 | ids.clone().into_iter().map(AreaOfLifeId::from).collect(), 48 | )), 49 | }, 50 | E::Repo | E::NewId => Error::internal(), 51 | } 52 | }) 53 | } 54 | } 55 | 56 | // -- Update -- // 57 | 58 | impl Present for Presenter { 59 | type ViewModel = Result<(), view::update::Error>; 60 | fn present(&self, res: app::update::Result) -> Self::ViewModel { 61 | res.map(|()| Response { 62 | data: None, 63 | status: StatusCode::OK, 64 | }) 65 | .map_err(|err| { 66 | use app::update::Error as E; 67 | match err { 68 | E::Id => Error { 69 | msg: Some(err.to_string()), 70 | status: StatusCode::BAD_REQUEST, 71 | details: Some(view::update::Error::Id), 72 | }, 73 | E::NotFound(id) => Error { 74 | msg: Some(err.to_string()), 75 | status: StatusCode::NOT_FOUND, 76 | details: Some(view::update::Error::NotFound(id.into())), 77 | }, 78 | E::AreaOfLifeId => Error { 79 | msg: Some(err.to_string()), 80 | status: StatusCode::BAD_REQUEST, 81 | details: Some(view::update::Error::AreaOfLifeId), 82 | }, 83 | E::Invalidity(invalidity) => Error { 84 | msg: Some(invalidity.to_string()), 85 | status: StatusCode::BAD_REQUEST, 86 | details: Some(to_json::thought::update::from_thought_invalidity( 87 | invalidity, 88 | )), 89 | }, 90 | E::AreasOfLifeNotFound(ref ids) => Error { 91 | msg: Some(err.to_string()), 92 | status: StatusCode::BAD_REQUEST, 93 | details: Some(view::update::Error::AreasOfLifeNotFound( 94 | ids.clone().into_iter().map(AreaOfLifeId::from).collect(), 95 | )), 96 | }, 97 | E::Repo => Error::internal(), 98 | } 99 | }) 100 | } 101 | } 102 | 103 | // -- Find by ID -- // 104 | 105 | impl Present for Presenter { 106 | type ViewModel = Result; 107 | fn present(&self, res: app::find_by_id::Result) -> Self::ViewModel { 108 | res.map(to_json::thought::find_by_id::from_response) 109 | .map(|data| Response { 110 | data: Some(data), 111 | status: StatusCode::OK, 112 | }) 113 | .map_err(|err| match err { 114 | app::find_by_id::Error::Id => Error { 115 | msg: Some(err.to_string()), 116 | status: StatusCode::BAD_REQUEST, 117 | details: Some(view::find_by_id::Error::Id), 118 | }, 119 | app::find_by_id::Error::NotFound => Error { 120 | msg: Some("Could not find thought".to_string()), 121 | status: StatusCode::NOT_FOUND, 122 | details: Some(view::find_by_id::Error::NotFound), 123 | }, 124 | app::find_by_id::Error::Repo => Error::internal(), 125 | }) 126 | } 127 | } 128 | 129 | // -- Read all -- // 130 | 131 | impl Present for Presenter { 132 | type ViewModel = Result, view::read_all::Error>; 133 | fn present(&self, res: app::read_all::Result) -> Self::ViewModel { 134 | res.map(|resp| { 135 | resp.thoughts 136 | .into_iter() 137 | .map(to_json::thought::read_all::from_thought) 138 | .collect() 139 | }) 140 | .map(|data| Response { 141 | data: Some(data), 142 | status: StatusCode::OK, 143 | }) 144 | .map_err(|err| match err { 145 | app::read_all::Error::Repo => Error::internal(), 146 | }) 147 | } 148 | } 149 | 150 | // -- Delete by ID -- // 151 | 152 | impl Present for Presenter { 153 | type ViewModel = Result<(), view::delete::Error>; 154 | fn present(&self, res: app::delete::Result) -> Self::ViewModel { 155 | res.map(|_| Response { 156 | data: None, 157 | status: StatusCode::OK, 158 | }) 159 | .map_err(|err| match err { 160 | app::delete::Error::Id => Error { 161 | msg: Some(err.to_string()), 162 | status: StatusCode::BAD_REQUEST, 163 | details: Some(view::delete::Error::Id), 164 | }, 165 | app::delete::Error::NotFound => Error { 166 | msg: Some("Could not find thought".to_string()), 167 | status: StatusCode::NOT_FOUND, 168 | details: Some(view::delete::Error::NotFound), 169 | }, 170 | app::delete::Error::Repo => Error::internal(), 171 | }) 172 | } 173 | } 174 | } 175 | 176 | mod area_of_life { 177 | use super::{to_json, Error, Present, Presenter, Response, Result, StatusCode}; 178 | use crate::model::{app::area_of_life as app, view::json::area_of_life as view}; 179 | 180 | // -- Create -- // 181 | 182 | impl Present for Presenter { 183 | type ViewModel = Result; 184 | fn present(&self, res: app::create::Result) -> Self::ViewModel { 185 | res.map(to_json::area_of_life::create::from_response) 186 | .map(|id| Response { 187 | data: Some(id), 188 | status: StatusCode::CREATED, 189 | }) 190 | .map_err(|err| { 191 | use app::create::Error as E; 192 | match &err { 193 | E::Invalidity(invalidity) => Error { 194 | msg: Some(invalidity.to_string()), 195 | status: StatusCode::BAD_REQUEST, 196 | details: to_json::area_of_life::create::try_from_error(err).ok(), 197 | }, 198 | E::Repo | E::NewId => Error::internal(), 199 | } 200 | }) 201 | } 202 | } 203 | 204 | // -- Update -- // 205 | 206 | impl Present for Presenter { 207 | type ViewModel = Result<(), view::update::Error>; 208 | fn present(&self, res: app::update::Result) -> Self::ViewModel { 209 | res.map(|()| Response { 210 | data: None, 211 | status: StatusCode::OK, 212 | }) 213 | .map_err(|err| { 214 | use app::update::Error as E; 215 | match err { 216 | E::Id => Error { 217 | msg: Some(err.to_string()), 218 | status: StatusCode::BAD_REQUEST, 219 | details: Some(view::update::Error::Id), 220 | }, 221 | E::NotFound(_) => Error { 222 | msg: Some(err.to_string()), 223 | status: StatusCode::NOT_FOUND, 224 | details: Some(view::update::Error::NotFound), 225 | }, 226 | E::Invalidity(invalidity) => Error { 227 | msg: Some(invalidity.to_string()), 228 | status: StatusCode::BAD_REQUEST, 229 | details: Some(to_json::area_of_life::update::from_area_of_life_invalidity( 230 | invalidity, 231 | )), 232 | }, 233 | E::Repo => Error::internal(), 234 | } 235 | }) 236 | } 237 | } 238 | 239 | // -- Read all -- // 240 | 241 | impl Present for Presenter { 242 | type ViewModel = Result, view::read_all::Error>; 243 | fn present(&self, res: app::read_all::Result) -> Self::ViewModel { 244 | res.map(|resp| { 245 | resp.areas_of_life 246 | .into_iter() 247 | .map(to_json::area_of_life::read_all::from_area_of_life) 248 | .collect() 249 | }) 250 | .map(|data| Response { 251 | data: Some(data), 252 | status: StatusCode::OK, 253 | }) 254 | .map_err(|err| match err { 255 | app::read_all::Error::Repo => Error::internal(), 256 | }) 257 | } 258 | } 259 | 260 | // -- Delete by ID -- // 261 | 262 | impl Present for Presenter { 263 | type ViewModel = Result<(), view::delete::Error>; 264 | fn present(&self, res: app::delete::Result) -> Self::ViewModel { 265 | res.map(|_| Response { 266 | data: None, 267 | status: StatusCode::OK, 268 | }) 269 | .map_err(|err| match err { 270 | app::delete::Error::Id => Error { 271 | msg: Some(err.to_string()), 272 | status: StatusCode::BAD_REQUEST, 273 | details: Some(view::delete::Error::Id), 274 | }, 275 | app::delete::Error::NotFound => Error { 276 | msg: Some("Could not find area of life".to_string()), 277 | status: StatusCode::NOT_FOUND, 278 | details: Some(view::delete::Error::NotFound), 279 | }, 280 | app::delete::Error::Repo => Error::internal(), 281 | }) 282 | } 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /crates/adapter/src/presenter/http_json_api/to_json.rs: -------------------------------------------------------------------------------- 1 | use crate::model::{app, view::json}; 2 | 3 | impl From for json::area_of_life::AreaOfLifeId { 4 | fn from(from: app::area_of_life::Id) -> Self { 5 | from.to_u64().into() 6 | } 7 | } 8 | 9 | impl From for json::thought::ThoughtId { 10 | fn from(from: app::thought::Id) -> Self { 11 | from.to_u64().into() 12 | } 13 | } 14 | 15 | pub(crate) mod thought { 16 | pub mod create { 17 | use crate::model::{ 18 | app::thought::create::Response, view::json::thought::create::Error, 19 | view::json::thought::ThoughtId, 20 | }; 21 | use cawr_application::usecase::thought::validate::{self, ThoughtInvalidity}; 22 | 23 | pub fn thought_id_from_response(res: Response) -> ThoughtId { 24 | res.id.to_u64().into() 25 | } 26 | 27 | pub const fn from_thought_invalidity(from: ThoughtInvalidity) -> Error { 28 | let ThoughtInvalidity::Title(e) = from; 29 | use validate::TitleInvalidity as T; 30 | match e { 31 | T::MinLength { min, actual } => Error::TitleMinLength { min, actual }, 32 | T::MaxLength { max, actual } => Error::TitleMaxLength { max, actual }, 33 | } 34 | } 35 | } 36 | pub mod update { 37 | use crate::model::view::json::thought::update::Error; 38 | use cawr_application::usecase::thought::validate::{self, ThoughtInvalidity}; 39 | 40 | pub const fn from_thought_invalidity(from: ThoughtInvalidity) -> Error { 41 | let ThoughtInvalidity::Title(e) = from; 42 | use validate::TitleInvalidity as T; 43 | match e { 44 | T::MinLength { min, actual } => Error::TitleMinLength { min, actual }, 45 | T::MaxLength { max, actual } => Error::TitleMaxLength { max, actual }, 46 | } 47 | } 48 | } 49 | pub mod read_all { 50 | use crate::model::view::json::thought::Thought; 51 | use cawr_application::usecase::thought::read_all as uc; 52 | 53 | pub fn from_thought(from: uc::Thought) -> Thought { 54 | let uc::Thought { 55 | id, 56 | title, 57 | areas_of_life, 58 | } = from; 59 | let id = id.to_u64().into(); 60 | let areas_of_life = areas_of_life 61 | .into_iter() 62 | .map(|id| id.to_u64().into()) 63 | .collect(); 64 | Thought { 65 | id, 66 | title, 67 | areas_of_life, 68 | } 69 | } 70 | } 71 | pub mod find_by_id { 72 | use crate::model::view::json::thought::Thought; 73 | use cawr_application::usecase::thought::find_by_id as uc; 74 | 75 | pub fn from_response(from: uc::Response) -> Thought { 76 | let uc::Response { 77 | id, 78 | title, 79 | areas_of_life, 80 | } = from; 81 | let id = id.to_u64().into(); 82 | let areas_of_life = areas_of_life 83 | .into_iter() 84 | .map(|id| id.to_u64().into()) 85 | .collect(); 86 | Thought { 87 | id, 88 | title, 89 | areas_of_life, 90 | } 91 | } 92 | } 93 | } 94 | 95 | pub(crate) mod area_of_life { 96 | pub mod create { 97 | use crate::model::view::json::area_of_life::{create::Error, AreaOfLifeId}; 98 | use cawr_application::usecase::area_of_life::{create as uc, validate}; 99 | 100 | pub fn from_response(from: uc::Response) -> AreaOfLifeId { 101 | from.id.to_u64().into() 102 | } 103 | 104 | pub const fn try_from_error(from: uc::Error) -> Result { 105 | match from { 106 | uc::Error::Repo | uc::Error::NewId => Err(()), 107 | uc::Error::Invalidity(e) => { 108 | let validate::AreaOfLifeInvalidity::Name(e) = e; 109 | use validate::NameInvalidity as T; 110 | Ok(match e { 111 | T::MinLength { min, actual } => Error::NameMinLength { min, actual }, 112 | T::MaxLength { max, actual } => Error::NameMaxLength { max, actual }, 113 | }) 114 | } 115 | } 116 | } 117 | } 118 | pub mod update { 119 | use crate::model::view::json::area_of_life::update::Error; 120 | use cawr_application::usecase::area_of_life::validate::{ 121 | AreaOfLifeInvalidity, NameInvalidity, 122 | }; 123 | 124 | pub const fn from_area_of_life_invalidity(from: AreaOfLifeInvalidity) -> Error { 125 | let AreaOfLifeInvalidity::Name(e) = from; 126 | use NameInvalidity as T; 127 | match e { 128 | T::MinLength { min, actual } => Error::NameMinLength { min, actual }, 129 | T::MaxLength { max, actual } => Error::NameMaxLength { max, actual }, 130 | } 131 | } 132 | } 133 | pub mod read_all { 134 | use crate::model::view::json::area_of_life::AreaOfLife; 135 | use cawr_application::usecase::area_of_life::read_all as uc; 136 | 137 | pub fn from_area_of_life(from: uc::AreaOfLife) -> AreaOfLife { 138 | let uc::AreaOfLife { id, name } = from; 139 | let id = id.to_u64().into(); 140 | AreaOfLife { id, name } 141 | } 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /crates/adapter/src/presenter/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod cli; 2 | pub mod http_json_api; 3 | 4 | /// The Presenter 5 | /// 6 | /// *"Its job is to accept data from the application 7 | /// and format it for presentation so that the **view** 8 | /// can simply move it to the screen"* [^ca-presenter]. 9 | /// 10 | /// [^ca-presenter]: Robert C. Martin, Clean Architecture, 2017, p. 203. 11 | pub trait Present { 12 | /// View model 13 | type ViewModel; 14 | /// Present the given data `D` 15 | fn present(&self, data: D) -> Self::ViewModel; 16 | } 17 | -------------------------------------------------------------------------------- /crates/application/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cawr-application" 3 | version.workspace = true 4 | edition.workspace = true 5 | rust-version.workspace = true 6 | publish = false 7 | 8 | [dependencies] 9 | # Workspace dependencies 10 | cawr-domain = "=0.0.0" 11 | 12 | # External dependencies 13 | log = "0.4" 14 | parking_lot = "0.12" 15 | thiserror = "1.0" 16 | -------------------------------------------------------------------------------- /crates/application/src/gateway/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod repository; 2 | -------------------------------------------------------------------------------- /crates/application/src/gateway/repository/area_of_life.rs: -------------------------------------------------------------------------------- 1 | use cawr_domain::area_of_life::{AreaOfLife, Id}; 2 | use thiserror::Error; 3 | 4 | #[derive(Debug, Error)] 5 | pub enum GetError { 6 | #[error("Area of life not found")] 7 | NotFound, 8 | #[error("Area of life repository connection problem")] 9 | Connection, 10 | } 11 | 12 | #[derive(Debug, Error)] 13 | pub enum SaveError { 14 | #[error("Area of life repository connection problem")] 15 | Connection, 16 | } 17 | 18 | #[derive(Debug, Error)] 19 | pub enum GetAllError { 20 | #[error("Area of life repository connection problem")] 21 | Connection, 22 | } 23 | 24 | #[derive(Debug, Error)] 25 | pub enum DeleteError { 26 | #[error("Area of life not found")] 27 | NotFound, 28 | #[error("Area of life repository connection problem")] 29 | Connection, 30 | } 31 | 32 | #[derive(Debug, Clone)] 33 | pub struct Record { 34 | pub area_of_life: AreaOfLife, 35 | } 36 | 37 | // TODO: make it async 38 | pub trait Repo: Send + Sync { 39 | fn save(&self, record: Record) -> Result<(), SaveError>; 40 | fn get(&self, id: Id) -> Result; 41 | fn get_all(&self) -> Result, GetAllError>; 42 | fn delete(&self, id: Id) -> Result<(), DeleteError>; 43 | } 44 | -------------------------------------------------------------------------------- /crates/application/src/gateway/repository/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod area_of_life; 2 | pub mod thought; 3 | -------------------------------------------------------------------------------- /crates/application/src/gateway/repository/thought.rs: -------------------------------------------------------------------------------- 1 | use cawr_domain::thought::{Id, Thought}; 2 | use thiserror::Error; 3 | 4 | #[derive(Debug, Error)] 5 | pub enum GetError { 6 | #[error("Thought not found")] 7 | NotFound, 8 | #[error("Thought repository connection problem")] 9 | Connection, 10 | } 11 | 12 | #[derive(Debug, Error)] 13 | pub enum SaveError { 14 | #[error("Thought repository connection problem")] 15 | Connection, 16 | } 17 | 18 | #[derive(Debug, Error)] 19 | pub enum GetAllError { 20 | #[error("Thought repository connection problem")] 21 | Connection, 22 | } 23 | 24 | #[derive(Debug, Error)] 25 | pub enum DeleteError { 26 | #[error("Thought not found")] 27 | NotFound, 28 | #[error("Thought repository connection problem")] 29 | Connection, 30 | } 31 | 32 | #[derive(Debug, Clone)] 33 | pub struct Record { 34 | pub thought: Thought, 35 | } 36 | 37 | // TODO: make it async 38 | pub trait Repo: Send + Sync { 39 | fn save(&self, record: Record) -> Result<(), SaveError>; 40 | fn get(&self, id: Id) -> Result; 41 | fn get_all(&self) -> Result, GetAllError>; 42 | fn delete(&self, id: Id) -> Result<(), DeleteError>; 43 | } 44 | -------------------------------------------------------------------------------- /crates/application/src/identifier.rs: -------------------------------------------------------------------------------- 1 | use thiserror::Error; 2 | 3 | /// A service that generates a new entity ID. 4 | // The creation of the ID should be done **before** we save a record. 5 | // To do that we delegate the generation of a new ID to a separate 6 | // service that can be injected e.g. into a specific usecase. 7 | // See: https://matthiasnoback.nl/2018/05/when-and-where-to-determine-the-id-of-an-entity/ 8 | pub trait NewId { 9 | fn new_id(&self) -> Result; 10 | } 11 | 12 | #[derive(Debug, Error)] 13 | #[error("Unable to generade a new entity ID")] 14 | pub struct NewIdError; 15 | -------------------------------------------------------------------------------- /crates/application/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod gateway; 2 | pub mod identifier; 3 | pub mod usecase; 4 | -------------------------------------------------------------------------------- /crates/application/src/usecase/area_of_life/check_existence.rs: -------------------------------------------------------------------------------- 1 | use crate::gateway::repository::area_of_life::{GetError, Repo}; 2 | use cawr_domain::area_of_life::Id; 3 | use std::collections::HashSet; 4 | use thiserror::Error; 5 | 6 | pub type Request<'a> = &'a HashSet; 7 | 8 | pub struct CheckAreasOfLifeExistence<'r, R> { 9 | repo: &'r R, 10 | } 11 | 12 | impl<'r, R> CheckAreasOfLifeExistence<'r, R> { 13 | pub const fn new(repo: &'r R) -> Self { 14 | Self { repo } 15 | } 16 | } 17 | 18 | #[derive(Debug, Error)] 19 | pub enum Error { 20 | #[error("{}", GetError::Connection)] 21 | Repo, 22 | #[error("Area of life {0:?} not found")] 23 | NotFound(HashSet), 24 | } 25 | 26 | impl<'r, R> CheckAreasOfLifeExistence<'r, R> 27 | where 28 | R: Repo, 29 | { 30 | pub fn exec(&self, req: Request) -> Result<(), Error> { 31 | let mut not_found = HashSet::new(); 32 | for id in req { 33 | match self.repo.get(*id) { 34 | Err(GetError::Connection) => { 35 | return Err(Error::Repo); 36 | } 37 | Err(GetError::NotFound) => { 38 | not_found.insert(*id); 39 | } 40 | Ok(_) => {} 41 | } 42 | } 43 | if not_found.is_empty() { 44 | Ok(()) 45 | } else { 46 | Err(Error::NotFound(not_found)) 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /crates/application/src/usecase/area_of_life/create.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | gateway::repository::area_of_life::{Record, Repo, SaveError}, 3 | identifier::{NewId, NewIdError}, 4 | usecase::area_of_life::validate::{ 5 | self, validate_area_of_life_properties, AreaOfLifeInvalidity, 6 | }, 7 | }; 8 | use cawr_domain::area_of_life::{AreaOfLife, Id, Name}; 9 | use thiserror::Error; 10 | 11 | #[derive(Debug)] 12 | pub struct Request { 13 | /// The title of the new area of life. 14 | pub name: String, 15 | } 16 | 17 | #[derive(Debug)] 18 | pub struct Response { 19 | /// The ID of the newly created area of life. 20 | pub id: Id, 21 | } 22 | 23 | /// Create area of life usecase interactor 24 | pub struct CreateAreaOfLife<'r, 'g, R, G> { 25 | repo: &'r R, 26 | id_gen: &'g G, 27 | } 28 | 29 | impl<'r, 'g, R, G> CreateAreaOfLife<'r, 'g, R, G> { 30 | pub const fn new(repo: &'r R, id_gen: &'g G) -> Self { 31 | Self { repo, id_gen } 32 | } 33 | } 34 | 35 | #[derive(Debug, Error)] 36 | pub enum Error { 37 | #[error("{}", SaveError::Connection)] 38 | Repo, 39 | #[error("{}", NewIdError)] 40 | NewId, 41 | #[error(transparent)] 42 | Invalidity(#[from] AreaOfLifeInvalidity), 43 | } 44 | 45 | impl From for Error { 46 | fn from(e: SaveError) -> Self { 47 | match e { 48 | SaveError::Connection => Self::Repo, 49 | } 50 | } 51 | } 52 | 53 | impl<'r, 'g, R, G> CreateAreaOfLife<'r, 'g, R, G> 54 | where 55 | R: Repo, 56 | G: NewId, 57 | { 58 | /// Create a new area of life with the given name. 59 | pub fn exec(&self, req: Request) -> Result { 60 | log::debug!("Create new area of life: {:?}", req); 61 | validate_area_of_life_properties(&validate::Request { name: &req.name })?; 62 | let name = Name::new(req.name); 63 | let id = self.id_gen.new_id().map_err(|err| { 64 | log::warn!("{}", err); 65 | Error::NewId 66 | })?; 67 | let area_of_life = AreaOfLife::new(id, name); 68 | let record = Record { area_of_life }; 69 | self.repo.save(record)?; 70 | Ok(Response { id }) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /crates/application/src/usecase/area_of_life/delete.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Debug; 2 | 3 | use thiserror::Error; 4 | 5 | use cawr_domain::area_of_life::Id; 6 | 7 | use crate::gateway::repository::area_of_life::{DeleteError, Repo}; 8 | 9 | #[derive(Debug)] 10 | pub struct Request { 11 | pub id: Id, 12 | } 13 | 14 | #[derive(Debug)] 15 | pub struct Response; 16 | 17 | /// Delete area of life by ID usecase interactor 18 | pub struct Delete<'r, R> { 19 | repo: &'r R, 20 | } 21 | 22 | impl<'r, R> Delete<'r, R> { 23 | pub const fn new(repo: &'r R) -> Self { 24 | Self { repo } 25 | } 26 | } 27 | 28 | #[derive(Debug, Error)] 29 | pub enum Error { 30 | #[error("{}", DeleteError::NotFound)] 31 | NotFound, 32 | #[error("{}", DeleteError::Connection)] 33 | Repo, 34 | } 35 | 36 | impl From for Error { 37 | fn from(e: DeleteError) -> Self { 38 | match e { 39 | DeleteError::NotFound => Self::NotFound, 40 | DeleteError::Connection => Self::Repo, 41 | } 42 | } 43 | } 44 | 45 | impl<'r, R> Delete<'r, R> 46 | where 47 | R: Repo, 48 | { 49 | pub fn exec(&self, req: Request) -> Result { 50 | log::debug!("Delete area of life by ID: {:?}", req); 51 | self.repo.delete(req.id)?; 52 | Ok(Response {}) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /crates/application/src/usecase/area_of_life/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod check_existence; 2 | pub mod create; 3 | pub mod delete; 4 | pub mod read_all; 5 | pub mod update; 6 | pub mod validate; 7 | -------------------------------------------------------------------------------- /crates/application/src/usecase/area_of_life/read_all.rs: -------------------------------------------------------------------------------- 1 | use crate::gateway::repository::area_of_life::{GetAllError, Record, Repo}; 2 | use cawr_domain::area_of_life::Id; 3 | use std::fmt::Debug; 4 | use thiserror::Error; 5 | 6 | #[derive(Debug)] 7 | pub struct Request; 8 | 9 | #[derive(Debug)] 10 | pub struct Response { 11 | pub areas_of_life: Vec, 12 | } 13 | 14 | #[derive(Debug)] 15 | pub struct AreaOfLife { 16 | pub id: Id, 17 | pub name: String, 18 | } 19 | 20 | impl From for AreaOfLife { 21 | fn from(r: Record) -> Self { 22 | let Record { area_of_life } = r; 23 | let name = String::from(area_of_life.name().as_ref()); 24 | let id = area_of_life.id(); 25 | Self { id, name } 26 | } 27 | } 28 | 29 | /// Read all areas of life usecase interactor 30 | pub struct ReadAll<'r, R> { 31 | repo: &'r R, 32 | } 33 | 34 | impl<'r, R> ReadAll<'r, R> { 35 | pub const fn new(repo: &'r R) -> Self { 36 | Self { repo } 37 | } 38 | } 39 | 40 | #[derive(Debug, Error)] 41 | pub enum Error { 42 | #[error("{}", GetAllError::Connection)] 43 | Repo, 44 | } 45 | 46 | impl From for Error { 47 | fn from(e: GetAllError) -> Self { 48 | match e { 49 | GetAllError::Connection => Self::Repo, 50 | } 51 | } 52 | } 53 | 54 | impl<'r, R> ReadAll<'r, R> 55 | where 56 | R: Repo, 57 | { 58 | pub fn exec(&self, _: Request) -> Result { 59 | log::debug!("Read all areas of life"); 60 | let areas_of_life = self 61 | .repo 62 | .get_all()? 63 | .into_iter() 64 | .map(AreaOfLife::from) 65 | .collect(); 66 | Ok(Response { areas_of_life }) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /crates/application/src/usecase/area_of_life/update.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | gateway::repository::area_of_life::{GetError, Record, Repo, SaveError}, 3 | usecase::area_of_life::validate::{ 4 | self, validate_area_of_life_properties, AreaOfLifeInvalidity, 5 | }, 6 | }; 7 | use cawr_domain::area_of_life::{AreaOfLife, Id, Name}; 8 | use thiserror::Error; 9 | 10 | #[derive(Debug)] 11 | pub struct Request { 12 | /// The id of the area of life. 13 | pub id: Id, 14 | /// The name of the area of life. 15 | pub name: String, 16 | } 17 | 18 | pub type Response = (); 19 | 20 | /// Update area of life usecase interactor 21 | pub struct UpdateAreaOfLife<'r, R> { 22 | repo: &'r R, 23 | } 24 | 25 | impl<'r, R> UpdateAreaOfLife<'r, R> { 26 | pub const fn new(repo: &'r R) -> Self { 27 | Self { repo } 28 | } 29 | } 30 | 31 | #[derive(Debug, Error)] 32 | pub enum Error { 33 | #[error("Area of life {0} not found")] 34 | NotFound(Id), 35 | #[error(transparent)] 36 | Invalidity(#[from] AreaOfLifeInvalidity), 37 | #[error("{}", SaveError::Connection)] 38 | Repo, 39 | } 40 | 41 | impl From for Error { 42 | fn from(err: SaveError) -> Self { 43 | match err { 44 | SaveError::Connection => Self::Repo, 45 | } 46 | } 47 | } 48 | 49 | impl From<(GetError, Id)> for Error { 50 | fn from((err, id): (GetError, Id)) -> Self { 51 | match err { 52 | GetError::NotFound => Self::NotFound(id), 53 | GetError::Connection => Self::Repo, 54 | } 55 | } 56 | } 57 | 58 | impl<'r, R> UpdateAreaOfLife<'r, R> 59 | where 60 | R: Repo, 61 | { 62 | /// Update a area of life. 63 | pub fn exec(&self, req: Request) -> Result { 64 | log::debug!("Update area of life: {:?}", req); 65 | validate_area_of_life_properties(&validate::Request { name: &req.name })?; 66 | let name = Name::new(req.name); 67 | let area_of_life = AreaOfLife::new(req.id, name); 68 | let record = Record { area_of_life }; 69 | let _ = self.repo.get(req.id).map_err(|err| (err, req.id))?; 70 | self.repo.save(record)?; 71 | Ok(()) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /crates/application/src/usecase/area_of_life/validate.rs: -------------------------------------------------------------------------------- 1 | use cawr_domain::area_of_life::Name; 2 | use thiserror::Error; 3 | 4 | #[derive(Debug)] 5 | pub struct Request<'a> { 6 | pub name: &'a str, 7 | } 8 | pub type Response = Result<(), AreaOfLifeInvalidity>; 9 | 10 | #[derive(Debug, Error)] 11 | pub enum AreaOfLifeInvalidity { 12 | #[error(transparent)] 13 | Name(#[from] NameInvalidity), 14 | } 15 | 16 | #[derive(Debug, Error)] 17 | pub enum NameInvalidity { 18 | #[error("The name must have at least {min} but has {actual} chars")] 19 | MinLength { min: usize, actual: usize }, 20 | #[error("The name must have at most {max} but has {actual} chars")] 21 | MaxLength { max: usize, actual: usize }, 22 | } 23 | 24 | pub fn validate_area_of_life_properties(req: &Request) -> Response { 25 | log::debug!("Validate area of life properties {:?}", req); 26 | validate_name(req.name).map_err(AreaOfLifeInvalidity::Name)?; 27 | Ok(()) 28 | } 29 | 30 | const fn validate_name(name: &str) -> Result<(), NameInvalidity> { 31 | let actual = name.len(); 32 | let min = Name::min_len(); 33 | 34 | if actual < min { 35 | return Err(NameInvalidity::MinLength { min, actual }); 36 | } 37 | let max = Name::max_len(); 38 | if actual > max { 39 | return Err(NameInvalidity::MaxLength { max, actual }); 40 | } 41 | Ok(()) 42 | } 43 | -------------------------------------------------------------------------------- /crates/application/src/usecase/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod area_of_life; 2 | pub mod thought; 3 | -------------------------------------------------------------------------------- /crates/application/src/usecase/thought/create.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | 3 | use thiserror::Error; 4 | 5 | use cawr_domain::{ 6 | area_of_life as aol, 7 | thought::{Id, Thought, Title}, 8 | }; 9 | 10 | use crate::{ 11 | gateway::repository::{ 12 | area_of_life, 13 | thought::{self, Record, SaveError}, 14 | }, 15 | identifier::{NewId, NewIdError}, 16 | usecase::{ 17 | area_of_life::check_existence::{self as check_aol, CheckAreasOfLifeExistence}, 18 | thought::validate::{self, validate_thought_properties, ThoughtInvalidity}, 19 | }, 20 | }; 21 | 22 | #[derive(Debug)] 23 | pub struct Request { 24 | /// The title of new thought. 25 | pub title: String, 26 | /// Associated [`aol::AreaOfLife`]s. 27 | pub areas_of_life: HashSet, 28 | } 29 | 30 | #[derive(Debug)] 31 | pub struct Response { 32 | /// The ID of the newly created thought. 33 | pub id: Id, 34 | } 35 | 36 | /// Create thought usecase interactor 37 | pub struct CreateThought<'r, 'g, R, G> { 38 | repo: &'r R, 39 | id_gen: &'g G, 40 | } 41 | 42 | impl<'r, 'g, R, G> CreateThought<'r, 'g, R, G> { 43 | pub const fn new(repo: &'r R, id_gen: &'g G) -> Self { 44 | Self { repo, id_gen } 45 | } 46 | } 47 | 48 | #[derive(Debug, Error)] 49 | pub enum Error { 50 | #[error("{}", SaveError::Connection)] 51 | Repo, 52 | #[error("{}", NewIdError)] 53 | NewId, 54 | #[error(transparent)] 55 | Invalidity(#[from] ThoughtInvalidity), 56 | #[error("Areas of life {0:?} not found")] 57 | AreasOfLifeNotFound(HashSet), 58 | } 59 | 60 | impl From for Error { 61 | fn from(e: SaveError) -> Self { 62 | match e { 63 | SaveError::Connection => Self::Repo, 64 | } 65 | } 66 | } 67 | 68 | impl From for Error { 69 | fn from(e: check_aol::Error) -> Self { 70 | use check_aol::Error as E; 71 | match e { 72 | E::Repo => Error::Repo, 73 | E::NotFound(aol_ids) => Error::AreasOfLifeNotFound(aol_ids), 74 | } 75 | } 76 | } 77 | 78 | impl<'r, 'g, R, G> CreateThought<'r, 'g, R, G> 79 | where 80 | R: thought::Repo + area_of_life::Repo, 81 | G: NewId, 82 | { 83 | /// Create a new thought with the given title. 84 | pub fn exec(&self, req: Request) -> Result { 85 | log::debug!("Create new thought: {:?}", req); 86 | validate_thought_properties(&validate::Request { title: &req.title })?; 87 | CheckAreasOfLifeExistence::new(self.repo).exec(&req.areas_of_life)?; 88 | let title = Title::new(req.title); 89 | let id = self.id_gen.new_id().map_err(|err| { 90 | log::warn!("{}", err); 91 | Error::NewId 92 | })?; 93 | let thought = Thought::new(id, title, req.areas_of_life); 94 | let record = Record { thought }; 95 | thought::Repo::save(self.repo, record)?; 96 | Ok(Response { id }) 97 | } 98 | } 99 | 100 | #[cfg(test)] 101 | mod tests { 102 | use super::*; 103 | use crate::gateway::repository::thought::{DeleteError, GetAllError, GetError}; 104 | use parking_lot::RwLock; 105 | 106 | #[derive(Default)] 107 | struct MockRepo { 108 | thought: RwLock>, 109 | } 110 | 111 | impl thought::Repo for MockRepo { 112 | fn save(&self, record: Record) -> Result<(), SaveError> { 113 | *self.thought.write() = Some(record); 114 | Ok(()) 115 | } 116 | fn get(&self, _: Id) -> Result { 117 | todo!() 118 | } 119 | fn get_all(&self) -> Result, GetAllError> { 120 | todo!() 121 | } 122 | fn delete(&self, _: Id) -> Result<(), DeleteError> { 123 | todo!() 124 | } 125 | } 126 | 127 | impl area_of_life::Repo for MockRepo { 128 | fn save(&self, _: area_of_life::Record) -> Result<(), area_of_life::SaveError> { 129 | todo!() 130 | } 131 | fn get(&self, _: aol::Id) -> Result { 132 | todo!() 133 | } 134 | fn get_all(&self) -> Result, area_of_life::GetAllError> { 135 | todo!() 136 | } 137 | fn delete(&self, _: aol::Id) -> Result<(), area_of_life::DeleteError> { 138 | todo!() 139 | } 140 | } 141 | 142 | struct IdGen; 143 | 144 | impl NewId for IdGen { 145 | fn new_id(&self) -> Result { 146 | Ok(Id::new(42)) 147 | } 148 | } 149 | 150 | #[test] 151 | fn create_new_thought() { 152 | let repo = MockRepo::default(); 153 | let gen = IdGen {}; 154 | let usecase = CreateThought::new(&repo, &gen); 155 | let req = Request { 156 | title: "foo".into(), 157 | areas_of_life: HashSet::new(), 158 | }; 159 | let res = usecase.exec(req).unwrap(); 160 | assert_eq!( 161 | repo.thought 162 | .read() 163 | .as_ref() 164 | .unwrap() 165 | .thought 166 | .title() 167 | .as_ref(), 168 | "foo" 169 | ); 170 | assert_eq!(res.id, Id::new(42)); 171 | } 172 | 173 | #[test] 174 | fn create_with_empty_title() { 175 | let repo = MockRepo::default(); 176 | let gen = IdGen {}; 177 | let usecase = CreateThought::new(&repo, &gen); 178 | let req = Request { 179 | title: String::new(), 180 | areas_of_life: HashSet::new(), 181 | }; 182 | let err = usecase.exec(req).err().unwrap(); 183 | assert!(matches!(err, Error::Invalidity(_))); 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /crates/application/src/usecase/thought/delete.rs: -------------------------------------------------------------------------------- 1 | use crate::gateway::repository::thought::{DeleteError, Repo}; 2 | use cawr_domain::thought::Id; 3 | use std::fmt::Debug; 4 | use thiserror::Error; 5 | 6 | #[derive(Debug)] 7 | pub struct Request { 8 | pub id: Id, 9 | } 10 | 11 | #[derive(Debug)] 12 | pub struct Response; 13 | 14 | /// Delete thought by ID usecase interactor 15 | pub struct Delete<'r, R> { 16 | repo: &'r R, 17 | } 18 | 19 | impl<'r, R> Delete<'r, R> { 20 | pub const fn new(repo: &'r R) -> Self { 21 | Self { repo } 22 | } 23 | } 24 | 25 | #[derive(Debug, Error)] 26 | pub enum Error { 27 | #[error("{}", DeleteError::NotFound)] 28 | NotFound, 29 | #[error("{}", DeleteError::Connection)] 30 | Repo, 31 | } 32 | 33 | impl From for Error { 34 | fn from(e: DeleteError) -> Self { 35 | match e { 36 | DeleteError::NotFound => Self::NotFound, 37 | DeleteError::Connection => Self::Repo, 38 | } 39 | } 40 | } 41 | 42 | impl<'r, R> Delete<'r, R> 43 | where 44 | R: Repo, 45 | { 46 | pub fn exec(&self, req: Request) -> Result { 47 | log::debug!("Delete thought by ID: {:?}", req); 48 | self.repo.delete(req.id)?; 49 | Ok(Response {}) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /crates/application/src/usecase/thought/find_by_id.rs: -------------------------------------------------------------------------------- 1 | use crate::gateway::repository::thought::{GetError, Record, Repo}; 2 | use cawr_domain::{area_of_life as aol, thought::Id}; 3 | use std::{collections::HashSet, fmt::Debug}; 4 | use thiserror::Error; 5 | 6 | #[derive(Debug)] 7 | pub struct Request { 8 | pub id: Id, 9 | } 10 | 11 | #[derive(Debug)] 12 | pub struct Response { 13 | pub id: Id, 14 | pub title: String, 15 | pub areas_of_life: HashSet, 16 | } 17 | 18 | impl From for Response { 19 | fn from(r: Record) -> Self { 20 | let Record { thought } = r; 21 | let title = String::from(thought.title().as_ref()); 22 | let id = thought.id(); 23 | let areas_of_life = thought.areas_of_life().clone(); 24 | Self { 25 | id, 26 | title, 27 | areas_of_life, 28 | } 29 | } 30 | } 31 | 32 | /// Find thought by ID usecase interactor 33 | pub struct FindById<'r, R> { 34 | repo: &'r R, 35 | } 36 | 37 | impl<'r, R> FindById<'r, R> { 38 | pub const fn new(repo: &'r R) -> Self { 39 | Self { repo } 40 | } 41 | } 42 | 43 | #[derive(Debug, Error)] 44 | pub enum Error { 45 | #[error("{}", GetError::NotFound)] 46 | NotFound, 47 | #[error("{}", GetError::Connection)] 48 | Repo, 49 | } 50 | 51 | impl From for Error { 52 | fn from(e: GetError) -> Self { 53 | match e { 54 | GetError::NotFound => Self::NotFound, 55 | GetError::Connection => Self::Repo, 56 | } 57 | } 58 | } 59 | 60 | impl<'r, R> FindById<'r, R> 61 | where 62 | R: Repo, 63 | { 64 | pub fn exec(&self, req: Request) -> Result { 65 | log::debug!("Find thought by ID: {:?}", req); 66 | let thought_record = self.repo.get(req.id)?; 67 | Ok(Response::from(thought_record)) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /crates/application/src/usecase/thought/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod create; 2 | pub mod delete; 3 | pub mod find_by_id; 4 | pub mod read_all; 5 | pub mod update; 6 | pub mod validate; 7 | -------------------------------------------------------------------------------- /crates/application/src/usecase/thought/read_all.rs: -------------------------------------------------------------------------------- 1 | use crate::gateway::repository::thought::{GetAllError, Record, Repo}; 2 | use cawr_domain::{area_of_life as aol, thought::Id}; 3 | use std::{collections::HashSet, fmt::Debug}; 4 | use thiserror::Error; 5 | 6 | #[derive(Debug)] 7 | pub struct Request; 8 | 9 | #[derive(Debug)] 10 | pub struct Response { 11 | pub thoughts: Vec, 12 | } 13 | 14 | #[derive(Debug)] 15 | pub struct Thought { 16 | pub id: Id, 17 | pub title: String, 18 | pub areas_of_life: HashSet, 19 | } 20 | 21 | impl From for Thought { 22 | fn from(r: Record) -> Self { 23 | let Record { thought } = r; 24 | let title = String::from(thought.title().as_ref()); 25 | let id = thought.id(); 26 | let areas_of_life = thought.areas_of_life().clone(); 27 | Self { 28 | id, 29 | title, 30 | areas_of_life, 31 | } 32 | } 33 | } 34 | 35 | /// Read all thoughts usecase interactor 36 | pub struct ReadAll<'r, R> { 37 | repo: &'r R, 38 | } 39 | 40 | impl<'r, R> ReadAll<'r, R> { 41 | pub const fn new(repo: &'r R) -> Self { 42 | Self { repo } 43 | } 44 | } 45 | 46 | #[derive(Debug, Error)] 47 | pub enum Error { 48 | #[error("{}", GetAllError::Connection)] 49 | Repo, 50 | } 51 | 52 | impl From for Error { 53 | fn from(e: GetAllError) -> Self { 54 | match e { 55 | GetAllError::Connection => Self::Repo, 56 | } 57 | } 58 | } 59 | 60 | impl<'r, R> ReadAll<'r, R> 61 | where 62 | R: Repo, 63 | { 64 | pub fn exec(&self, _: Request) -> Result { 65 | log::debug!("Read all thoughts"); 66 | let thoughts = self 67 | .repo 68 | .get_all()? 69 | .into_iter() 70 | .map(Thought::from) 71 | .collect(); 72 | Ok(Response { thoughts }) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /crates/application/src/usecase/thought/update.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | gateway::repository::{ 3 | area_of_life, 4 | thought::{self, GetError, Record, SaveError}, 5 | }, 6 | usecase::{ 7 | area_of_life::check_existence::{self as check_aol, CheckAreasOfLifeExistence}, 8 | thought::validate::{self, validate_thought_properties, ThoughtInvalidity}, 9 | }, 10 | }; 11 | use cawr_domain::{ 12 | area_of_life as aol, 13 | thought::{Id, Thought, Title}, 14 | }; 15 | use std::collections::HashSet; 16 | use thiserror::Error; 17 | 18 | #[derive(Debug)] 19 | pub struct Request { 20 | /// The id of the thought. 21 | pub id: Id, 22 | /// The title of the thought. 23 | pub title: String, 24 | /// Associated [`aol::AreaOfLife`]s. 25 | pub areas_of_life: HashSet, 26 | } 27 | 28 | pub type Response = (); 29 | 30 | /// Update thought usecase interactor 31 | pub struct UpdateThought<'r, R> { 32 | repo: &'r R, 33 | } 34 | 35 | impl<'r, R> UpdateThought<'r, R> { 36 | pub const fn new(repo: &'r R) -> Self { 37 | Self { repo } 38 | } 39 | } 40 | 41 | #[derive(Debug, Error)] 42 | pub enum Error { 43 | #[error("{}", SaveError::Connection)] 44 | Repo, 45 | #[error("Thought {0} not found")] 46 | ThoughtNotFound(Id), 47 | #[error(transparent)] 48 | Invalidity(#[from] ThoughtInvalidity), 49 | #[error("Areas of life {0:?} not found")] 50 | AreasOfLifeNotFound(HashSet), 51 | } 52 | 53 | impl From for Error { 54 | fn from(err: SaveError) -> Self { 55 | match err { 56 | SaveError::Connection => Self::Repo, 57 | } 58 | } 59 | } 60 | 61 | impl From<(Id, GetError)> for Error { 62 | fn from((id, err): (Id, GetError)) -> Self { 63 | match err { 64 | GetError::Connection => Error::Repo, 65 | GetError::NotFound => Error::ThoughtNotFound(id), 66 | } 67 | } 68 | } 69 | 70 | impl From for Error { 71 | fn from(e: check_aol::Error) -> Self { 72 | use check_aol::Error as E; 73 | match e { 74 | E::Repo => Error::Repo, 75 | E::NotFound(aol_ids) => Error::AreasOfLifeNotFound(aol_ids), 76 | } 77 | } 78 | } 79 | 80 | impl<'r, R> UpdateThought<'r, R> 81 | where 82 | R: thought::Repo + area_of_life::Repo, 83 | { 84 | /// Update a thought. 85 | pub fn exec(&self, req: Request) -> Result { 86 | log::debug!("Update thought: {:?}", req); 87 | validate_thought_properties(&validate::Request { title: &req.title })?; 88 | CheckAreasOfLifeExistence::new(self.repo).exec(&req.areas_of_life)?; 89 | thought::Repo::get(self.repo, req.id).map_err(|err| (req.id, err))?; 90 | let title = Title::new(req.title); 91 | let thought = Thought::new(req.id, title, req.areas_of_life); 92 | let record = Record { thought }; 93 | thought::Repo::save(self.repo, record)?; 94 | Ok(()) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /crates/application/src/usecase/thought/validate.rs: -------------------------------------------------------------------------------- 1 | use cawr_domain::thought::Title; 2 | use thiserror::Error; 3 | 4 | #[derive(Debug)] 5 | pub struct Request<'a> { 6 | pub title: &'a str, 7 | } 8 | pub type Response = Result<(), ThoughtInvalidity>; 9 | 10 | #[derive(Debug, Error)] 11 | pub enum ThoughtInvalidity { 12 | #[error(transparent)] 13 | Title(#[from] TitleInvalidity), 14 | } 15 | 16 | #[derive(Debug, Error)] 17 | pub enum TitleInvalidity { 18 | #[error("The title must have at least {min} but has {actual} chars")] 19 | MinLength { min: usize, actual: usize }, 20 | #[error("The title must have at most {max} but has {actual} chars")] 21 | MaxLength { max: usize, actual: usize }, 22 | } 23 | 24 | pub fn validate_thought_properties(req: &Request) -> Response { 25 | log::debug!("Validate thought properties {:?}", req); 26 | validate_title(req.title).map_err(ThoughtInvalidity::Title)?; 27 | Ok(()) 28 | } 29 | 30 | const fn validate_title(title: &str) -> Result<(), TitleInvalidity> { 31 | let actual = title.len(); 32 | let min = Title::min_len(); 33 | 34 | if actual < min { 35 | return Err(TitleInvalidity::MinLength { min, actual }); 36 | } 37 | let max = Title::max_len(); 38 | if actual > max { 39 | return Err(TitleInvalidity::MaxLength { max, actual }); 40 | } 41 | Ok(()) 42 | } 43 | 44 | #[cfg(test)] 45 | mod tests { 46 | use super::*; 47 | 48 | #[cfg(test)] 49 | mod the_title { 50 | use super::*; 51 | 52 | #[test] 53 | fn should_have_min_3_chars() { 54 | let res = validate_title(""); 55 | assert!(matches!( 56 | res.err().unwrap(), 57 | TitleInvalidity::MinLength { min: 3, actual: 0 } 58 | )); 59 | 60 | let title = ["a"; 3].join(""); 61 | assert!(validate_title(&title).is_ok()); 62 | } 63 | 64 | #[test] 65 | fn should_have_max_80_chars() { 66 | let title = ["a"; 81].join(""); 67 | let res = validate_title(&title); 68 | assert!(matches!( 69 | res.err().unwrap(), 70 | TitleInvalidity::MaxLength { 71 | max: 80, 72 | actual: 81 73 | } 74 | )); 75 | 76 | let title = ["a"; 80].join(""); 77 | assert!(validate_title(&title).is_ok()); 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /crates/cli/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cawr-cli" 3 | version.workspace = true 4 | edition.workspace = true 5 | rust-version.workspace = true 6 | publish = false 7 | 8 | [dependencies] 9 | # Workspace dependencies 10 | cawr-adapter = "=0.0.0" 11 | 12 | # External dependencies 13 | clap = { version = "4.5", features = ["derive"] } 14 | -------------------------------------------------------------------------------- /crates/cli/src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashSet, sync::Arc}; 2 | 3 | use clap::Subcommand; 4 | 5 | use cawr_adapter::{api::Api, db::Db, presenter::cli::Presenter}; 6 | 7 | #[derive(Subcommand)] 8 | pub enum Command { 9 | #[clap(about = "Create a new thought")] 10 | Create { title: String }, 11 | #[clap(about = "Read an specific thought")] 12 | Read { id: String }, 13 | } 14 | 15 | pub fn run(db: Arc, cmd: Command) 16 | where 17 | D: Db, 18 | { 19 | let app_api = Api::new(db, Presenter); 20 | 21 | match cmd { 22 | Command::Create { title } => { 23 | let areas_of_life = HashSet::new(); // Areas of life needs to be added later 24 | let res = app_api.create_thought(title, &areas_of_life); 25 | println!("{res}"); 26 | } 27 | Command::Read { id } => { 28 | let res = app_api.find_thought(&id); 29 | println!("{res}"); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /crates/db/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cawr-db" 3 | version.workspace = true 4 | edition.workspace = true 5 | rust-version.workspace = true 6 | publish = false 7 | 8 | [dependencies] 9 | 10 | # Workspace dependencies 11 | cawr-adapter = "=0.0.0" 12 | cawr-application = "=0.0.0" 13 | cawr-domain = "=0.0.0" 14 | 15 | # External dependencies 16 | log = "0.4" 17 | jfs = "0.9" 18 | parking_lot = "0.12" 19 | serde = { version = "1.0", features = ["derive"] } 20 | 21 | [dev-dependencies] 22 | env_logger = "0.11" 23 | tempfile = "3.13" 24 | -------------------------------------------------------------------------------- /crates/db/src/in_memory.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use parking_lot::RwLock; 4 | 5 | use cawr_adapter::db::Db; 6 | use cawr_application::{ 7 | gateway::repository::{ 8 | area_of_life::Record as AreaOfLifeRecord, thought::Record as ThoughtRecord, 9 | }, 10 | identifier::{NewId, NewIdError}, 11 | }; 12 | 13 | #[derive(Default)] 14 | pub struct InMemory { 15 | thoughts: RwLock>, 16 | areas_of_life: RwLock>, 17 | } 18 | 19 | impl Db for InMemory {} 20 | 21 | mod thought { 22 | use super::{InMemory, NewId, NewIdError}; 23 | use cawr_application::gateway::repository::thought::{ 24 | DeleteError, GetAllError, GetError, Record, Repo, SaveError, 25 | }; 26 | use cawr_domain::thought::Id; 27 | 28 | impl Repo for InMemory { 29 | fn save(&self, record: Record) -> Result<(), SaveError> { 30 | self.thoughts.write().insert(record.thought.id(), record); 31 | Ok(()) 32 | } 33 | fn get(&self, id: Id) -> Result { 34 | self.thoughts 35 | .read() 36 | .get(&id) 37 | .cloned() 38 | .ok_or(GetError::NotFound) 39 | } 40 | fn get_all(&self) -> Result, GetAllError> { 41 | Ok(self 42 | .thoughts 43 | .read() 44 | .iter() 45 | .map(|(_, r)| r) 46 | .cloned() 47 | .collect()) 48 | } 49 | fn delete(&self, id: Id) -> Result<(), DeleteError> { 50 | self.thoughts 51 | .write() 52 | .remove(&id) 53 | .map(|_| ()) 54 | .ok_or(DeleteError::NotFound) 55 | } 56 | } 57 | 58 | impl NewId for InMemory { 59 | fn new_id(&self) -> Result { 60 | let next = self 61 | .thoughts 62 | .read() 63 | .iter() 64 | .map(|(id, _)| id.to_u64()) 65 | .max() 66 | .unwrap_or(0) 67 | + 1; 68 | Ok(Id::from(next)) 69 | } 70 | } 71 | } 72 | 73 | mod area_of_life { 74 | use super::{InMemory, NewId, NewIdError}; 75 | use cawr_application::gateway::repository::area_of_life::{ 76 | DeleteError, GetAllError, GetError, Record, Repo, SaveError, 77 | }; 78 | use cawr_domain::area_of_life::Id; 79 | 80 | impl Repo for InMemory { 81 | fn save(&self, record: Record) -> Result<(), SaveError> { 82 | self.areas_of_life 83 | .write() 84 | .insert(record.area_of_life.id(), record); 85 | Ok(()) 86 | } 87 | fn get(&self, id: Id) -> Result { 88 | self.areas_of_life 89 | .read() 90 | .get(&id) 91 | .cloned() 92 | .ok_or(GetError::NotFound) 93 | } 94 | fn get_all(&self) -> Result, GetAllError> { 95 | Ok(self 96 | .areas_of_life 97 | .read() 98 | .iter() 99 | .map(|(_, r)| r) 100 | .cloned() 101 | .collect()) 102 | } 103 | fn delete(&self, id: Id) -> Result<(), DeleteError> { 104 | self.areas_of_life 105 | .write() 106 | .remove(&id) 107 | .map(|_| ()) 108 | .ok_or(DeleteError::NotFound) 109 | } 110 | } 111 | 112 | impl NewId for InMemory { 113 | fn new_id(&self) -> Result { 114 | let next = self 115 | .areas_of_life 116 | .read() 117 | .iter() 118 | .map(|(id, _)| id.to_u64()) 119 | .max() 120 | .unwrap_or(0) 121 | + 1; 122 | Ok(Id::from(next)) 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /crates/db/src/json_file/area_of_life.rs: -------------------------------------------------------------------------------- 1 | use super::{models, JsonFile, LAST_AREA_OF_LIFE_ID_KEY, MAP_AREA_OF_LIFE_ID_KEY}; 2 | use cawr_adapter::model::app::area_of_life as app; 3 | use cawr_application::{ 4 | gateway::repository::{ 5 | area_of_life::{DeleteError, GetAllError, GetError, Record, Repo, SaveError}, 6 | thought::Repo as ThoughtRepo, 7 | }, 8 | identifier::{NewId, NewIdError}, 9 | }; 10 | use cawr_domain::{ 11 | area_of_life::{AreaOfLife, Id, Name}, 12 | thought::Thought, 13 | }; 14 | use std::io; 15 | 16 | impl NewId for JsonFile { 17 | fn new_id(&self) -> Result { 18 | let id = self.new_id(LAST_AREA_OF_LIFE_ID_KEY)?; 19 | Ok(id) 20 | } 21 | } 22 | 23 | impl Repo for JsonFile { 24 | fn save(&self, record: Record) -> Result<(), SaveError> { 25 | log::debug!("Save area of life {:?} to JSON file", record); 26 | let Record { area_of_life } = record; 27 | let name = area_of_life.name(); 28 | let id = area_of_life.id(); 29 | let model = models::AreaOfLife { 30 | area_of_life_id: id.to_string(), 31 | name: String::from(name.as_ref()), 32 | }; 33 | 34 | match self.storage_id(area_of_life.id(), MAP_AREA_OF_LIFE_ID_KEY) { 35 | Ok(storage_id) => { 36 | log::debug!("Update area of life {}", area_of_life.id()); 37 | let sid = self 38 | .areas_of_life 39 | .save_with_id(&model, &storage_id) 40 | .map_err(|err| { 41 | log::warn!("Unable to save area of life: {}", err); 42 | SaveError::Connection 43 | })?; 44 | debug_assert_eq!(sid, storage_id); 45 | } 46 | Err(err) => match err.kind() { 47 | io::ErrorKind::NotFound => { 48 | log::debug!("Create new area of life record"); 49 | let storage_id = self.areas_of_life.save(&model).map_err(|err| { 50 | log::warn!("Unable to save area of life: {}", err); 51 | SaveError::Connection 52 | })?; 53 | self.save_id(storage_id, id, MAP_AREA_OF_LIFE_ID_KEY) 54 | .map_err(|err| { 55 | log::warn!("Unable to save area of life ID: {}", err); 56 | SaveError::Connection 57 | })?; 58 | } 59 | _ => { 60 | return Err(SaveError::Connection); 61 | } 62 | }, 63 | } 64 | Ok(()) 65 | } 66 | fn get(&self, id: Id) -> Result { 67 | log::debug!("Get area of life {:?} from JSON file", id); 68 | let sid = self 69 | .storage_id(id, MAP_AREA_OF_LIFE_ID_KEY) 70 | .map_err(|err| { 71 | log::warn!("Unable to get area of life ID: {}", err); 72 | if err.kind() == io::ErrorKind::NotFound { 73 | GetError::NotFound 74 | } else { 75 | GetError::Connection 76 | } 77 | })?; 78 | let model = self 79 | .areas_of_life 80 | .get::(&sid) 81 | .map_err(|err| { 82 | log::warn!("Unable to fetch area of life: {}", err); 83 | if err.kind() == io::ErrorKind::NotFound { 84 | GetError::NotFound 85 | } else { 86 | GetError::Connection 87 | } 88 | })?; 89 | debug_assert_eq!(id.to_string(), model.area_of_life_id); 90 | Ok(Record { 91 | area_of_life: AreaOfLife::new(id, Name::new(model.name)), 92 | }) 93 | } 94 | fn get_all(&self) -> Result, GetAllError> { 95 | log::debug!("Get all areas of life from JSON file"); 96 | let areas_of_life = self 97 | .areas_of_life 98 | .all::() 99 | .map_err(|err| { 100 | log::warn!("Unable to load all areas of life: {}", err); 101 | GetAllError::Connection 102 | })? 103 | .into_iter() 104 | .filter_map(|(_, model)| { 105 | model 106 | .area_of_life_id 107 | .parse::() 108 | .ok() 109 | .map(Into::into) 110 | .map(|id| (id, model.name)) 111 | }) 112 | .map(|(id, name)| Record { 113 | area_of_life: AreaOfLife::new(id, Name::new(name)), 114 | }) 115 | .collect(); 116 | Ok(areas_of_life) 117 | } 118 | fn delete(&self, id: Id) -> Result<(), DeleteError> { 119 | log::debug!("Delete area of life {:?} from JSON file", id); 120 | let sid = self 121 | .storage_id(id, MAP_AREA_OF_LIFE_ID_KEY) 122 | .map_err(|err| { 123 | log::warn!("Unable to get area of life ID: {}", err); 124 | if err.kind() == io::ErrorKind::NotFound { 125 | DeleteError::NotFound 126 | } else { 127 | DeleteError::Connection 128 | } 129 | })?; 130 | self.areas_of_life.delete(&sid).map_err(|err| { 131 | log::warn!("Unable to delete area of life: {}", err); 132 | if err.kind() == io::ErrorKind::NotFound { 133 | DeleteError::NotFound 134 | } else { 135 | DeleteError::Connection 136 | } 137 | })?; 138 | 139 | let thoughts = (self as &dyn ThoughtRepo).get_all().map_err(|err| { 140 | log::warn!("Unable to load thoughts: {}", err); 141 | DeleteError::Connection 142 | })?; 143 | 144 | log::debug!("Delete area of life {id} from thoughts"); 145 | for mut rec in thoughts { 146 | if rec.thought.areas_of_life().iter().any(|x| x == &id) { 147 | log::debug!("Delete area of life {id} from {:?}", rec.thought); 148 | let mut areas_of_life = rec.thought.areas_of_life().clone(); 149 | areas_of_life.retain(|x| x != &id); 150 | let updated_thought = 151 | Thought::new(rec.thought.id(), rec.thought.title().clone(), areas_of_life); 152 | rec.thought = updated_thought; 153 | (self as &dyn ThoughtRepo).save(rec).map_err(|err| { 154 | log::warn!("Unable to save thought: {}", err); 155 | DeleteError::Connection 156 | })?; 157 | } 158 | } 159 | Ok(()) 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /crates/db/src/json_file/mod.rs: -------------------------------------------------------------------------------- 1 | use cawr_adapter::db::Db; 2 | use cawr_application::identifier::NewIdError; 3 | use jfs::{Config, Store}; 4 | use std::{collections::HashMap, fs, io, path::Path}; 5 | 6 | mod area_of_life; 7 | mod models; 8 | mod thought; 9 | 10 | const LAST_THOUGHT_ID_KEY: &str = "last-thought-id"; 11 | const LAST_AREA_OF_LIFE_ID_KEY: &str = "last-area-of-life-id"; 12 | const MAP_THOUGHT_ID_KEY: &str = "map-thought-id"; 13 | const MAP_AREA_OF_LIFE_ID_KEY: &str = "map-area-of-life-id"; 14 | 15 | pub struct JsonFile { 16 | thoughts: Store, 17 | areas_of_life: Store, 18 | ids: Store, 19 | } 20 | 21 | impl JsonFile { 22 | pub fn try_new>(dir: P) -> Result { 23 | let cfg = Config { 24 | single: true, 25 | pretty: true, 26 | ..Default::default() 27 | }; 28 | let dir = dir.as_ref(); 29 | fs::create_dir_all(dir)?; 30 | let thoughts = Store::new_with_cfg(dir.join("thoughts"), cfg)?; 31 | let areas_of_life = Store::new_with_cfg(dir.join("areas-of-life"), cfg)?; 32 | let ids = Store::new_with_cfg(dir.join("ids"), cfg)?; 33 | Ok(Self { 34 | thoughts, 35 | areas_of_life, 36 | ids, 37 | }) 38 | } 39 | fn save_id(&self, storage_id: StorageId, id: I, key: &str) -> Result<(), io::Error> 40 | where 41 | I: ToString, 42 | { 43 | let mut map = match self.ids.get::>(key) { 44 | Ok(map) => Ok(map), 45 | Err(err) => match err.kind() { 46 | io::ErrorKind::NotFound => Ok(HashMap::new()), 47 | _ => Err(err), 48 | }, 49 | }?; 50 | map.insert(id.to_string(), storage_id); 51 | self.ids.save_with_id(&map, key)?; 52 | Ok(()) 53 | } 54 | fn storage_id(&self, id: I, key: &str) -> Result 55 | where 56 | I: ToString, 57 | { 58 | let id = id.to_string(); 59 | self.ids 60 | .get::>(key)? 61 | .get(&id) 62 | .cloned() 63 | .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Storage ID not found")) 64 | } 65 | fn new_id(&self, key: &str) -> Result 66 | where 67 | I: From, 68 | { 69 | let id = match self.ids.get::(key) { 70 | Ok(id) => Ok(id), 71 | Err(err) => { 72 | if err.kind() == io::ErrorKind::NotFound { 73 | Ok(0) 74 | } else { 75 | log::warn!("Unable to fetch last ID key: {}", err); 76 | Err(NewIdError) 77 | } 78 | } 79 | }?; 80 | let new_id = id + 1; 81 | self.ids.save_with_id(&new_id, key).map_err(|err| { 82 | log::warn!("Unable to save new ID: {}", err); 83 | NewIdError 84 | })?; 85 | Ok(I::from(new_id)) 86 | } 87 | } 88 | 89 | type StorageId = String; 90 | 91 | impl Db for JsonFile {} 92 | 93 | #[cfg(test)] 94 | mod tests { 95 | use super::*; 96 | 97 | fn init() { 98 | let _ = env_logger::builder().is_test(true).try_init(); 99 | } 100 | 101 | mod area_of_life { 102 | use super::*; 103 | use cawr_domain::{ 104 | area_of_life::{AreaOfLife, Id as AolId, Name}, 105 | thought::{Id as ThoughtId, Thought, Title}, 106 | }; 107 | use std::collections::HashSet; 108 | use tempfile::TempDir; 109 | 110 | #[test] 111 | fn delete_references_in_thoughts() { 112 | use cawr_application::{ 113 | gateway::repository::{ 114 | area_of_life::{Record as AolRecord, Repo as AolRepo}, 115 | thought::{Record as ThoughtRecord, Repo as ThoughtRepo}, 116 | }, 117 | identifier::NewId, 118 | }; 119 | // -- setup -- 120 | init(); 121 | let test_dir = TempDir::new().unwrap(); 122 | log::debug!("Test directory: {}", test_dir.path().display()); 123 | let db = JsonFile::try_new(&test_dir).unwrap(); 124 | let aol_id = (&db as &dyn NewId).new_id().unwrap(); 125 | let name = Name::new("test aol".to_string()); 126 | let area_of_life = AreaOfLife::new(aol_id, name); 127 | let record = AolRecord { area_of_life }; 128 | (&db as &dyn AolRepo).save(record).unwrap(); 129 | let mut areas_of_life = HashSet::new(); 130 | areas_of_life.insert(aol_id); 131 | let id = (&db as &dyn NewId).new_id().unwrap(); 132 | let thought = Thought::new(id, Title::new("foo".to_string()), areas_of_life); 133 | let record = ThoughtRecord { thought }; 134 | (&db as &dyn ThoughtRepo).save(record).unwrap(); 135 | // -- test -- 136 | (&db as &dyn AolRepo).delete(aol_id).unwrap(); 137 | let rec = (&db as &dyn ThoughtRepo).get(id).unwrap(); 138 | assert!(rec.thought.areas_of_life().is_empty()); 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /crates/db/src/json_file/models.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Debug, Serialize, Deserialize)] 4 | pub struct Thought { 5 | pub(crate) thought_id: String, 6 | pub(crate) title: String, 7 | pub(crate) areas_of_life: Vec, 8 | } 9 | 10 | #[derive(Debug, Serialize, Deserialize)] 11 | pub struct AreaOfLife { 12 | pub(crate) area_of_life_id: String, 13 | pub(crate) name: String, 14 | } 15 | -------------------------------------------------------------------------------- /crates/db/src/json_file/thought.rs: -------------------------------------------------------------------------------- 1 | use super::{models, JsonFile, LAST_THOUGHT_ID_KEY, MAP_THOUGHT_ID_KEY}; 2 | use cawr_adapter::model::app::{area_of_life as aol, thought as app}; 3 | use cawr_application::{ 4 | gateway::repository::thought::{DeleteError, GetAllError, GetError, Record, Repo, SaveError}, 5 | identifier::{NewId, NewIdError}, 6 | }; 7 | use cawr_domain::thought::{Id, Thought, Title}; 8 | use std::io; 9 | 10 | impl NewId for JsonFile { 11 | fn new_id(&self) -> Result { 12 | let id = self.new_id(LAST_THOUGHT_ID_KEY)?; 13 | Ok(id) 14 | } 15 | } 16 | 17 | impl Repo for JsonFile { 18 | fn save(&self, record: Record) -> Result<(), SaveError> { 19 | log::debug!("Save thought {:?} to JSON file", record); 20 | let Record { thought } = record; 21 | let thought_id = thought.id().to_string(); 22 | let title = String::from(thought.title().as_ref()); 23 | let areas_of_life = thought 24 | .areas_of_life() 25 | .iter() 26 | .map(ToString::to_string) 27 | .collect(); 28 | let model = models::Thought { 29 | thought_id, 30 | title, 31 | areas_of_life, 32 | }; 33 | 34 | match self.storage_id(thought.id(), MAP_THOUGHT_ID_KEY) { 35 | Ok(storage_id) => { 36 | log::debug!("Update thought {}", thought.id()); 37 | let sid = self 38 | .thoughts 39 | .save_with_id(&model, &storage_id) 40 | .map_err(|err| { 41 | log::warn!("Unable to save thought: {}", err); 42 | SaveError::Connection 43 | })?; 44 | debug_assert_eq!(sid, storage_id); 45 | } 46 | Err(err) => match err.kind() { 47 | io::ErrorKind::NotFound => { 48 | log::debug!("Create new thought record"); 49 | let storage_id = self.thoughts.save(&model).map_err(|err| { 50 | log::warn!("Unable to save thought: {}", err); 51 | SaveError::Connection 52 | })?; 53 | self.save_id(storage_id, thought.id(), MAP_THOUGHT_ID_KEY) 54 | .map_err(|err| { 55 | log::warn!("Unable to save thought ID: {}", err); 56 | SaveError::Connection 57 | })?; 58 | } 59 | _ => { 60 | return Err(SaveError::Connection); 61 | } 62 | }, 63 | } 64 | 65 | Ok(()) 66 | } 67 | fn get(&self, id: Id) -> Result { 68 | log::debug!("Get thought {:?} from JSON file", id); 69 | let sid = self.storage_id(id, MAP_THOUGHT_ID_KEY).map_err(|err| { 70 | log::warn!("Unable to get thought ID: {}", err); 71 | if err.kind() == io::ErrorKind::NotFound { 72 | GetError::NotFound 73 | } else { 74 | GetError::Connection 75 | } 76 | })?; 77 | let model = self.thoughts.get::(&sid).map_err(|err| { 78 | log::warn!("Unable to fetch thought: {}", err); 79 | if err.kind() == io::ErrorKind::NotFound { 80 | GetError::NotFound 81 | } else { 82 | GetError::Connection 83 | } 84 | })?; 85 | debug_assert_eq!(id.to_string(), model.thought_id); 86 | let areas_of_life = model 87 | .areas_of_life 88 | .into_iter() 89 | .filter_map(|id| { 90 | id.parse::() 91 | .map_err(|err| { 92 | log::warn!("{}", err); 93 | }) 94 | .map(Into::into) 95 | .ok() 96 | }) 97 | .collect(); 98 | Ok(Record { 99 | thought: Thought::new(id, Title::new(model.title), areas_of_life), 100 | }) 101 | } 102 | fn get_all(&self) -> Result, GetAllError> { 103 | log::debug!("Get all thoughts from JSON file"); 104 | let thoughts = self 105 | .thoughts 106 | .all::() 107 | .map_err(|err| { 108 | log::warn!("Unable to load all thoughts: {}", err); 109 | GetAllError::Connection 110 | })? 111 | .into_iter() 112 | .filter_map(|(_, model)| { 113 | let areas_of_life = model 114 | .areas_of_life 115 | .into_iter() 116 | .filter_map(|id| { 117 | id.parse::() 118 | .map_err(|err| { 119 | log::warn!("{}", err); 120 | }) 121 | .map(Into::into) 122 | .ok() 123 | }) 124 | .collect(); 125 | 126 | model 127 | .thought_id 128 | .parse::() 129 | .ok() 130 | .map(Into::into) 131 | .map(|id| (id, model.title, areas_of_life)) 132 | }) 133 | .map(|(id, title, areas_of_life)| Thought::new(id, Title::new(title), areas_of_life)) 134 | .map(|thought| Record { thought }) 135 | .collect(); 136 | Ok(thoughts) 137 | } 138 | fn delete(&self, id: Id) -> Result<(), DeleteError> { 139 | log::debug!("Delete thought {:?} from JSON file", id); 140 | let sid = self.storage_id(id, MAP_THOUGHT_ID_KEY).map_err(|err| { 141 | log::warn!("Unable to get thought ID: {}", err); 142 | if err.kind() == io::ErrorKind::NotFound { 143 | DeleteError::NotFound 144 | } else { 145 | DeleteError::Connection 146 | } 147 | })?; 148 | self.thoughts.delete(&sid).map_err(|err| { 149 | log::warn!("Unable to delete thought: {}", err); 150 | if err.kind() == io::ErrorKind::NotFound { 151 | DeleteError::NotFound 152 | } else { 153 | DeleteError::Connection 154 | } 155 | })?; 156 | Ok(()) 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /crates/db/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod in_memory; 2 | pub mod json_file; 3 | -------------------------------------------------------------------------------- /crates/desktop-egui/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cawr-desktop-egui" 3 | version.workspace = true 4 | edition.workspace = true 5 | rust-version.workspace = true 6 | publish = false 7 | 8 | [dependencies] 9 | # Workspace dependencies 10 | cawr-adapter = "=0.0.0" 11 | 12 | # External dependencies 13 | anyhow = "1.0" 14 | eframe = "0.29" 15 | log = "0.4" 16 | tokio = { version = "1.40", features = ["rt-multi-thread"] } 17 | -------------------------------------------------------------------------------- /crates/desktop-egui/src/actions.rs: -------------------------------------------------------------------------------- 1 | use crate::{ui::Msg, Api}; 2 | use cawr_adapter::db::Db; 3 | 4 | pub fn read_all_areas_of_life(api: Api) -> Option 5 | where 6 | D: Db, 7 | { 8 | match api.read_all_areas_of_life() { 9 | Ok(resp) => { 10 | let msg = Msg::AreasOfLifeChanged(resp.data.unwrap()); 11 | return Some(msg); 12 | } 13 | Err(err) => { 14 | log::error!("Unable to read areas of life: {err:?}"); 15 | } 16 | } 17 | None 18 | } 19 | 20 | pub fn read_all_thoughts(api: Api) -> Option 21 | where 22 | D: Db, 23 | { 24 | match api.read_all_thoughts() { 25 | Ok(resp) => { 26 | let msg = Msg::ThoughtsChanged(resp.data.unwrap()); 27 | return Some(msg); 28 | } 29 | Err(err) => { 30 | log::error!("Unable to read thoughts: {err:?}"); 31 | } 32 | } 33 | None 34 | } 35 | -------------------------------------------------------------------------------- /crates/desktop-egui/src/lib.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{anyhow, Result}; 2 | use cawr_adapter::{api, db::Db, presenter::http_json_api::Presenter}; 3 | use eframe::egui; 4 | use std::sync::{mpsc, Arc}; 5 | use tokio::runtime; 6 | 7 | mod actions; 8 | mod ui; 9 | 10 | // ----- ------ 11 | // Start 12 | // ----- ------ 13 | 14 | const TITLE: &str = "Clean Architecture with Rust"; 15 | 16 | pub fn run(db: Arc) -> Result<()> 17 | where 18 | D: Db, 19 | { 20 | log::debug!("Start desktop application"); 21 | let rt = runtime::Builder::new_multi_thread() 22 | .enable_all() 23 | .build() 24 | .unwrap(); 25 | let app_api = Api::new(db, Presenter); 26 | let options = eframe::NativeOptions::default(); 27 | eframe::run_native( 28 | TITLE, 29 | options, 30 | Box::new(|cc| { 31 | let ctx = cc.egui_ctx.clone(); 32 | let mut app = App::new(app_api, rt, ctx); 33 | let init_cmds = vec![ui::Cmd::ReadAllAreasOfLife, ui::Cmd::ReadAllThoughts]; 34 | handle_commands(init_cmds, &mut app); 35 | Ok(Box::new(app)) 36 | }), 37 | ) 38 | .map_err(|err| anyhow!("Unable to start dektop application: {err}")) 39 | } 40 | 41 | // ----- ------ 42 | // Model 43 | // ----- ------ 44 | 45 | type Api = api::Api; 46 | 47 | struct App { 48 | ui: ui::Mdl, 49 | api: Api, 50 | egui: egui::Context, 51 | rt: runtime::Runtime, 52 | msg_tx: mpsc::Sender, 53 | msg_rx: mpsc::Receiver, 54 | } 55 | 56 | impl App 57 | where 58 | D: Db, 59 | { 60 | fn new(api: Api, rt: runtime::Runtime, egui_ctx: egui::Context) -> Self { 61 | let ui = ui::Mdl::default(); 62 | let (msg_tx, msg_rx) = mpsc::channel(); 63 | let egui = egui_ctx; 64 | Self { 65 | ui, 66 | api, 67 | egui, 68 | rt, 69 | msg_tx, 70 | msg_rx, 71 | } 72 | } 73 | fn spawn_action(&self, f: F) 74 | where 75 | F: Fn(Api) -> Option + Send + 'static, 76 | { 77 | let tx = self.msg_tx.clone(); 78 | let api = self.api.clone(); 79 | let egui = self.egui.clone(); 80 | self.rt.spawn(async move { 81 | if let Some(msg) = f(api) { 82 | tx.send(msg).unwrap(); 83 | egui.request_repaint(); 84 | } 85 | }); 86 | } 87 | } 88 | 89 | impl eframe::App for App 90 | where 91 | D: Db, 92 | { 93 | fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { 94 | match self.msg_rx.try_recv() { 95 | Ok(msg) => { 96 | ui::update(msg, &mut self.ui); 97 | } 98 | Err(mpsc::TryRecvError::Empty) => { /* nothing to do */ } 99 | Err(mpsc::TryRecvError::Disconnected) => { 100 | log::error!("Unable to receive messages"); 101 | } 102 | } 103 | let cmds = ui::view(&mut self.ui, ctx); 104 | handle_commands(cmds, self); 105 | } 106 | } 107 | 108 | // ----- ------ 109 | // UI Commands 110 | // ----- ------ 111 | 112 | fn handle_commands(cmds: Vec, app: &mut App) 113 | where 114 | D: Db, 115 | { 116 | for cmd in cmds { 117 | log::debug!("Handle UI command {cmd:?}"); 118 | match cmd { 119 | ui::Cmd::ReadAllAreasOfLife => { 120 | app.spawn_action(actions::read_all_areas_of_life); 121 | } 122 | ui::Cmd::ReadAllThoughts => { 123 | app.spawn_action(actions::read_all_thoughts); 124 | } 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /crates/desktop-egui/src/ui.rs: -------------------------------------------------------------------------------- 1 | use cawr_adapter::model::view::json::{area_of_life::AreaOfLife, thought::Thought}; 2 | use eframe::egui; 3 | 4 | // ----- ------ 5 | // Model 6 | // ----- ------ 7 | 8 | #[derive(Default, Debug)] 9 | pub struct Mdl { 10 | thoughts: Vec, 11 | areas_of_life: Vec, 12 | } 13 | 14 | // ----- ------ 15 | // Messages 16 | // ----- ------ 17 | 18 | #[derive(Debug)] 19 | pub enum Msg { 20 | ThoughtsChanged(Vec), 21 | AreasOfLifeChanged(Vec), 22 | } 23 | 24 | // ----- ------ 25 | // Commands 26 | // ----- ------ 27 | 28 | #[derive(Debug)] 29 | pub enum Cmd { 30 | ReadAllAreasOfLife, 31 | ReadAllThoughts, 32 | } 33 | 34 | // ----- ------ 35 | // Update 36 | // ----- ------ 37 | 38 | pub fn update(msg: Msg, mdl: &mut Mdl) { 39 | log::debug!("update model"); 40 | match msg { 41 | Msg::ThoughtsChanged(data) => { 42 | mdl.thoughts = data; 43 | } 44 | Msg::AreasOfLifeChanged(data) => { 45 | mdl.areas_of_life = data; 46 | } 47 | } 48 | } 49 | 50 | // ----- ------ 51 | // View 52 | // ----- ------ 53 | 54 | pub fn view(mdl: &mut Mdl, ctx: &egui::Context) -> Vec { 55 | let cmds = vec![]; 56 | egui::SidePanel::left("left_panel").show(ctx, |ui| { 57 | for aol in &mdl.areas_of_life { 58 | ui.label(&aol.name); 59 | } 60 | }); 61 | egui::CentralPanel::default().show(ctx, |ui| { 62 | for t in &mdl.thoughts { 63 | ui.label(&t.title); 64 | } 65 | }); 66 | cmds 67 | } 68 | -------------------------------------------------------------------------------- /crates/domain/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cawr-domain" 3 | version.workspace = true 4 | edition.workspace = true 5 | rust-version.workspace = true 6 | publish = false 7 | -------------------------------------------------------------------------------- /crates/domain/src/entity/area_of_life.rs: -------------------------------------------------------------------------------- 1 | //! All value objects and information that 2 | //! belong to [`AreaOfLife`]s. 3 | 4 | use crate::value_object; 5 | 6 | pub type Id = value_object::Id; 7 | pub type Name = value_object::Name; 8 | 9 | /// An area of your life 10 | #[derive(Debug, Clone)] 11 | pub struct AreaOfLife { 12 | id: Id, 13 | name: Name, 14 | } 15 | 16 | impl AreaOfLife { 17 | #[must_use] 18 | pub fn new(id: Id, name: Name) -> Self { 19 | // Never construct an area of life with invalid name 20 | debug_assert!(name.as_ref().len() <= Name::max_len()); 21 | debug_assert!(name.as_ref().len() >= Name::min_len()); 22 | Self { id, name } 23 | } 24 | #[must_use] 25 | pub const fn id(&self) -> Id { 26 | self.id 27 | } 28 | #[must_use] 29 | pub const fn name(&self) -> &Name { 30 | &self.name 31 | } 32 | } 33 | 34 | const MAX_NAME_LEN: usize = 30; 35 | const MIN_NAME_LEN: usize = 5; 36 | 37 | impl Name { 38 | pub const fn min_len() -> usize { 39 | MIN_NAME_LEN 40 | } 41 | pub const fn max_len() -> usize { 42 | MAX_NAME_LEN 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /crates/domain/src/entity/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod area_of_life; 2 | pub mod thought; 3 | -------------------------------------------------------------------------------- /crates/domain/src/entity/thought.rs: -------------------------------------------------------------------------------- 1 | //! All value objects and information that 2 | //! belong to [Thought]s. 3 | 4 | use crate::{entity::area_of_life as aol, value_object}; 5 | use std::collections::HashSet; 6 | 7 | pub type Id = value_object::Id; 8 | pub type Title = value_object::Name; 9 | 10 | /// Anything you want to remember 11 | #[derive(Debug, Clone)] 12 | pub struct Thought { 13 | id: Id, 14 | title: Title, 15 | areas_of_life: HashSet, 16 | } 17 | 18 | impl Thought { 19 | #[must_use] 20 | pub fn new(id: Id, title: Title, areas_of_life: HashSet) -> Self { 21 | // Never construct a thought with invalid title 22 | debug_assert!(title.as_ref().len() <= Title::max_len()); 23 | debug_assert!(title.as_ref().len() >= Title::min_len()); 24 | Self { 25 | id, 26 | title, 27 | areas_of_life, 28 | } 29 | } 30 | #[must_use] 31 | pub const fn id(&self) -> Id { 32 | self.id 33 | } 34 | #[must_use] 35 | pub const fn title(&self) -> &Title { 36 | &self.title 37 | } 38 | #[must_use] 39 | pub const fn areas_of_life(&self) -> &HashSet { 40 | &self.areas_of_life 41 | } 42 | } 43 | 44 | const MAX_TITLE_LEN: usize = 80; 45 | const MIN_TITLE_LEN: usize = 3; 46 | 47 | impl Title { 48 | pub const fn min_len() -> usize { 49 | MIN_TITLE_LEN 50 | } 51 | pub const fn max_len() -> usize { 52 | MAX_TITLE_LEN 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /crates/domain/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! # Domain 2 | //! 3 | //! The domain of this projects describes the shape of data 4 | //! that helps self-employed people organize their lives. 5 | //! 6 | //! ## Entity IDs 7 | //! 8 | //! Most projects that follow [DDD](https://en.wikipedia.org/wiki/Domain-driven_design) 9 | //! use IDs within the domain layer.\ 10 | //! Nevertheless, there is also the view that 11 | //! [you should not use IDs in your domain entities](https://enterprisecraftsmanship.com/posts/dont-use-ids-domain-entities/), 12 | //! but [references](https://enterprisecraftsmanship.com/posts/link-to-an-aggregate-reference-or-id/). 13 | //! The problem with references is that you either have to fully load all the associated data of an entity or 14 | //! rely on a lazy loading technique. However, the latter would create a dependency on a persistence layer, 15 | //! which must not be the case. 16 | //! 17 | //! One can also see it in such a way that references are finally also only IDs, 18 | //! which keep a unique memory address. 19 | //! 20 | //! In this project, therefore, all entites (or root aggregates) use an ID. 21 | 22 | mod entity; 23 | mod value_object; 24 | 25 | pub use self::entity::{area_of_life::AreaOfLife, thought::Thought, *}; 26 | -------------------------------------------------------------------------------- /crates/domain/src/value_object/id.rs: -------------------------------------------------------------------------------- 1 | //! A generalised ID type for entities and aggregates. 2 | 3 | use std::{ 4 | fmt, 5 | hash::{Hash, Hasher}, 6 | marker::PhantomData, 7 | }; 8 | 9 | #[derive(Debug)] 10 | pub struct Id { 11 | id: u64, 12 | // The `fn() -> T` is a trick to tell the compiler that we don't own anything. 13 | marker: PhantomData T>, 14 | } 15 | 16 | impl Id { 17 | pub const fn new(id: u64) -> Self { 18 | Self { 19 | id, 20 | marker: PhantomData, 21 | } 22 | } 23 | pub const fn to_u64(self) -> u64 { 24 | self.id 25 | } 26 | } 27 | 28 | impl Clone for Id { 29 | fn clone(&self) -> Self { 30 | *self 31 | } 32 | } 33 | 34 | impl Copy for Id {} 35 | 36 | impl PartialEq for Id { 37 | fn eq(&self, other: &Self) -> bool { 38 | self.id == other.id 39 | } 40 | } 41 | 42 | impl Eq for Id {} 43 | 44 | impl Hash for Id { 45 | fn hash(&self, hasher: &mut H) 46 | where 47 | H: Hasher, 48 | { 49 | self.id.hash(hasher); 50 | } 51 | } 52 | 53 | impl From for Id { 54 | fn from(from: u64) -> Self { 55 | Self::new(from) 56 | } 57 | } 58 | 59 | impl fmt::Display for Id { 60 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 61 | write!(f, "{}", self.id) 62 | } 63 | } 64 | 65 | #[cfg(test)] 66 | mod tests { 67 | use super::*; 68 | 69 | #[test] 70 | fn format_id() { 71 | let id: Id<()> = Id::new(33); 72 | assert_eq!(format!("{id}"), "33"); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /crates/domain/src/value_object/mod.rs: -------------------------------------------------------------------------------- 1 | mod id; 2 | mod name; 3 | 4 | pub use id::*; 5 | pub use name::*; 6 | -------------------------------------------------------------------------------- /crates/domain/src/value_object/name.rs: -------------------------------------------------------------------------------- 1 | use std::marker::PhantomData; 2 | 3 | #[derive(Debug, Clone)] 4 | pub struct Name(String, PhantomData); 5 | 6 | impl Name { 7 | pub const fn new(name: String) -> Self { 8 | Self(name, PhantomData) 9 | } 10 | } 11 | 12 | impl AsRef for Name { 13 | fn as_ref(&self) -> &str { 14 | &self.0 15 | } 16 | } 17 | 18 | impl From> for String { 19 | fn from(from: Name) -> Self { 20 | from.0 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /crates/infrastructure/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cawr-infrastructure" 3 | version.workspace = true 4 | edition.workspace = true 5 | rust-version.workspace = true 6 | publish = false 7 | 8 | [dependencies] 9 | 10 | # Workspace dependencies 11 | cawr-adapter = "=0.0.0" 12 | cawr-cli = "=0.0.0" 13 | cawr-db = "=0.0.0" 14 | cawr-desktop-egui = "=0.0.0" 15 | cawr-web-server-warp = "=0.0.0" 16 | 17 | # External dependencies 18 | anyhow = "1.0" 19 | clap = { version = "4.5", features = ["derive"] } 20 | directories = "5.0" 21 | log = "0.4" 22 | pretty_env_logger = "0.5" 23 | tokio = { version = "1.40", features = ["full"] } 24 | -------------------------------------------------------------------------------- /crates/infrastructure/src/cli.rs: -------------------------------------------------------------------------------- 1 | use crate::storage::data_storage; 2 | use cawr_cli::Command; 3 | use clap::Parser; 4 | use std::{path::PathBuf, sync::Arc}; 5 | 6 | #[derive(Parser)] 7 | struct Args { 8 | #[clap(subcommand)] 9 | command: Command, 10 | #[clap(help = "Directory to store data ", long)] 11 | data_dir: Option, 12 | } 13 | 14 | pub fn run() { 15 | let args = Args::parse(); 16 | let db = Arc::new(data_storage(args.data_dir)); 17 | cawr_cli::run(db, args.command); 18 | } 19 | -------------------------------------------------------------------------------- /crates/infrastructure/src/desktop.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use anyhow::Result; 4 | 5 | use crate::storage::data_storage; 6 | 7 | pub fn run() -> Result<()> { 8 | let db = Arc::new(data_storage(None)); 9 | cawr_desktop_egui::run(db) 10 | } 11 | -------------------------------------------------------------------------------- /crates/infrastructure/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod cli; 2 | pub mod desktop; 3 | pub mod logger; 4 | pub mod storage; 5 | pub mod web; 6 | -------------------------------------------------------------------------------- /crates/infrastructure/src/logger.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | 3 | pub fn init_default_logger() { 4 | if env::var("RUST_LOG").is_err() { 5 | env::set_var("RUST_LOG", "info"); 6 | } 7 | pretty_env_logger::init(); 8 | } 9 | -------------------------------------------------------------------------------- /crates/infrastructure/src/storage.rs: -------------------------------------------------------------------------------- 1 | use cawr_db::json_file::JsonFile; 2 | use directories::UserDirs; 3 | use std::path::{Path, PathBuf}; 4 | 5 | #[must_use] 6 | pub fn data_storage(data_dir: Option) -> JsonFile { 7 | let data_dir = data_storage_directory(data_dir); 8 | log::info!("Use data directory: {data_dir:?}"); 9 | JsonFile::try_new(data_dir).expect("JSON file store") 10 | } 11 | 12 | const DEFAULT_STORAGE_DIR_NAME: &str = "clean-architecture-with-rust-data"; 13 | 14 | // Get storage directory with the following priority: 15 | // 1. Custom (passed by the CLI) 16 | // 2. HOME/DOCUMENTS/clean-architecture-with-rust-data 17 | // 3. HOME/clean-architecture-with-rust-data 18 | // 4. Relative to the executable: ./clean-architecture-with-rust-data 19 | #[must_use] 20 | pub fn data_storage_directory(data_dir: Option) -> PathBuf { 21 | if let Some(data_dir) = data_dir { 22 | data_dir 23 | } else { 24 | let base_path = if let Some(users_dir) = UserDirs::new() { 25 | users_dir 26 | .document_dir() 27 | .unwrap_or_else(|| users_dir.home_dir()) 28 | .to_path_buf() 29 | } else { 30 | Path::new(".").to_path_buf() 31 | }; 32 | base_path.join(DEFAULT_STORAGE_DIR_NAME) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /crates/infrastructure/src/web.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | net::{IpAddr, SocketAddr}, 3 | path::PathBuf, 4 | sync::Arc, 5 | }; 6 | 7 | use clap::Parser; 8 | use tokio::runtime::Runtime; 9 | 10 | use crate::storage::data_storage; 11 | 12 | #[derive(Parser)] 13 | struct Args { 14 | #[clap(default_value = "127.0.0.1", help = "IP address", long)] 15 | bind: IpAddr, 16 | #[clap(default_value = "3030", help = "TCP port", long)] 17 | port: u16, 18 | #[clap(help = "Directory to store data ", long)] 19 | data_dir: Option, 20 | } 21 | 22 | pub fn run() { 23 | let args = Args::parse(); 24 | let db = Arc::new(data_storage(args.data_dir)); 25 | let rt = Runtime::new().expect("tokio runtime"); 26 | let addr = SocketAddr::from((args.bind, args.port)); 27 | rt.block_on(cawr_web_server_warp::run(db, addr)); 28 | } 29 | -------------------------------------------------------------------------------- /crates/json-boundary/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cawr-json-boundary" 3 | version.workspace = true 4 | edition.workspace = true 5 | rust-version.workspace = true 6 | publish = false 7 | 8 | [dependencies] 9 | serde = { version = "1.0", features = ["derive"] } 10 | -------------------------------------------------------------------------------- /crates/json-boundary/src/domain.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use std::fmt; 3 | 4 | #[derive(Debug, Clone, Serialize, Deserialize)] 5 | pub struct Thought { 6 | pub id: ThoughtId, 7 | pub title: String, 8 | pub areas_of_life: Vec, 9 | } 10 | 11 | #[derive(Debug, Clone, Serialize, Deserialize)] 12 | pub struct AreaOfLife { 13 | pub id: AreaOfLifeId, 14 | pub name: String, 15 | } 16 | 17 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] 18 | pub struct ThoughtId(pub u64); 19 | 20 | impl From for ThoughtId { 21 | fn from(id: u64) -> Self { 22 | Self(id) 23 | } 24 | } 25 | 26 | impl fmt::Display for ThoughtId { 27 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 28 | self.0.fmt(f) 29 | } 30 | } 31 | 32 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] 33 | pub struct AreaOfLifeId(pub u64); 34 | 35 | impl From for AreaOfLifeId { 36 | fn from(id: u64) -> Self { 37 | Self(id) 38 | } 39 | } 40 | 41 | impl fmt::Display for AreaOfLifeId { 42 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 43 | self.0.fmt(f) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /crates/json-boundary/src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::result; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | mod status_code; 6 | pub use self::status_code::StatusCode; 7 | 8 | pub mod domain; 9 | pub mod usecase; 10 | 11 | #[derive(Debug, Serialize, Deserialize)] 12 | pub struct Error { 13 | /// Short error message 14 | #[serde(skip_serializing_if = "Option::is_none")] 15 | pub msg: Option, 16 | 17 | /// HTTP status code 18 | pub status: StatusCode, 19 | 20 | /// Structured error details 21 | #[serde(skip_serializing_if = "Option::is_none")] 22 | pub details: Option, 23 | } 24 | 25 | impl Error { 26 | #[must_use] 27 | pub const fn internal() -> Self { 28 | Self { 29 | msg: None, // We really want to hide internal details 30 | status: StatusCode::INTERNAL_SERVER_ERROR, 31 | details: None, // We really want to hide internal details 32 | } 33 | } 34 | } 35 | 36 | #[derive(Debug, Serialize, Deserialize)] 37 | pub struct Response { 38 | #[serde(skip_serializing_if = "Option::is_none")] 39 | pub data: Option, 40 | pub status: StatusCode, 41 | } 42 | 43 | pub type Result = result::Result, Error>; 44 | -------------------------------------------------------------------------------- /crates/json-boundary/src/status_code.rs: -------------------------------------------------------------------------------- 1 | // NOTE: 2 | // Originally we used `http` and `http-serde` here, 3 | // but then we ran into problems because `warp` `v0.3` 4 | // was always dependent on the outdated `http` `v0.2` 5 | // and therefore other parts of the system could not be updated 6 | // to the new `http` version `v1.x`. 7 | // Now this crate is only dependent on `serde`. 8 | // However, the mapping must now be done elsewhere. 9 | 10 | use std::num::NonZeroU16; 11 | 12 | use serde::{Deserialize, Serialize}; 13 | 14 | /// HTTP status code 15 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 16 | pub struct StatusCode(pub NonZeroU16); 17 | 18 | pub struct InvalidStatusCode; 19 | 20 | impl InvalidStatusCode { 21 | const fn new() -> Self { 22 | Self {} 23 | } 24 | } 25 | 26 | impl StatusCode { 27 | pub fn from_u16(src: u16) -> Result { 28 | if !(100..1000).contains(&src) { 29 | return Err(InvalidStatusCode::new()); 30 | } 31 | NonZeroU16::new(src) 32 | .map(StatusCode) 33 | .ok_or_else(InvalidStatusCode::new) 34 | } 35 | 36 | #[must_use] 37 | pub fn as_u16(&self) -> u16 { 38 | u16::from(self.0) 39 | } 40 | } 41 | 42 | macro_rules! status_codes { 43 | ( 44 | $( 45 | ($num:expr, $konst:ident); 46 | )+ 47 | ) => 48 | { 49 | impl StatusCode { 50 | $( 51 | pub const $konst: StatusCode = StatusCode(unsafe { NonZeroU16::new_unchecked($num) }); 52 | )+ 53 | } 54 | } 55 | } 56 | 57 | status_codes! { 58 | (100, CONTINUE); 59 | (101, SWITCHING_PROTOCOLS); 60 | (102, PROCESSING); 61 | (200, OK); 62 | (201, CREATED); 63 | (202, ACCEPTED); 64 | (203, NON_AUTHORITATIVE_INFORMATION); 65 | (204, NO_CONTENT); 66 | (205, RESET_CONTENT); 67 | (206, PARTIAL_CONTENT); 68 | (207, MULTI_STATUS); 69 | (208, ALREADY_REPORTED); 70 | (226, IM_USED); 71 | (300, MULTIPLE_CHOICES); 72 | (301, MOVED_PERMANENTLY); 73 | (302, FOUND); 74 | (303, SEE_OTHER); 75 | (304, NOT_MODIFIED); 76 | (305, USE_PROXY); 77 | (307, TEMPORARY_REDIRECT); 78 | (308, PERMANENT_REDIRECT); 79 | (400, BAD_REQUEST); 80 | (401, UNAUTHORIZED); 81 | (402, PAYMENT_REQUIRED); 82 | (403, FORBIDDEN); 83 | (404, NOT_FOUND); 84 | (405, METHOD_NOT_ALLOWED); 85 | (406, NOT_ACCEPTABLE); 86 | (407, PROXY_AUTHENTICATION_REQUIRED); 87 | (408, REQUEST_TIMEOUT); 88 | (409, CONFLICT); 89 | (410, GONE); 90 | (411, LENGTH_REQUIRED); 91 | (412, PRECONDITION_FAILED); 92 | (413, PAYLOAD_TOO_LARGE); 93 | (414, URI_TOO_LONG); 94 | (415, UNSUPPORTED_MEDIA_TYPE); 95 | (416, RANGE_NOT_SATISFIABLE); 96 | (417, EXPECTATION_FAILED); 97 | (418, IM_A_TEAPOT); 98 | (421, MISDIRECTED_REQUEST); 99 | (422, UNPROCESSABLE_ENTITY); 100 | (423, LOCKED); 101 | (424, FAILED_DEPENDENCY); 102 | (426, UPGRADE_REQUIRED); 103 | (428, PRECONDITION_REQUIRED); 104 | (429, TOO_MANY_REQUESTS); 105 | (431, REQUEST_HEADER_FIELDS_TOO_LARGE); 106 | (451, UNAVAILABLE_FOR_LEGAL_REASONS); 107 | (500, INTERNAL_SERVER_ERROR); 108 | (501, NOT_IMPLEMENTED); 109 | (502, BAD_GATEWAY); 110 | (503, SERVICE_UNAVAILABLE); 111 | (504, GATEWAY_TIMEOUT); 112 | (505, HTTP_VERSION_NOT_SUPPORTED); 113 | (506, VARIANT_ALSO_NEGOTIATES); 114 | (507, INSUFFICIENT_STORAGE); 115 | (508, LOOP_DETECTED); 116 | (510, NOT_EXTENDED); 117 | (511, NETWORK_AUTHENTICATION_REQUIRED); 118 | } 119 | -------------------------------------------------------------------------------- /crates/json-boundary/src/usecase/area_of_life/create.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Debug, Deserialize, Serialize)] 4 | pub struct Request { 5 | pub name: String, 6 | } 7 | 8 | #[derive(Debug, Deserialize, Serialize)] 9 | pub enum Error { 10 | NameMinLength { min: usize, actual: usize }, 11 | NameMaxLength { max: usize, actual: usize }, 12 | } 13 | -------------------------------------------------------------------------------- /crates/json-boundary/src/usecase/area_of_life/delete.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Debug, Deserialize, Serialize)] 4 | pub enum Error { 5 | Id, 6 | NotFound, 7 | } 8 | -------------------------------------------------------------------------------- /crates/json-boundary/src/usecase/area_of_life/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod create; 2 | pub mod delete; 3 | pub mod read_all; 4 | pub mod update; 5 | -------------------------------------------------------------------------------- /crates/json-boundary/src/usecase/area_of_life/read_all.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Debug, Deserialize, Serialize)] 4 | pub enum Error { 5 | // TODO 6 | } 7 | -------------------------------------------------------------------------------- /crates/json-boundary/src/usecase/area_of_life/update.rs: -------------------------------------------------------------------------------- 1 | use crate::domain::AreaOfLifeId; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(Debug, Deserialize, Serialize)] 5 | pub struct Request { 6 | pub id: AreaOfLifeId, 7 | pub name: String, 8 | } 9 | 10 | #[derive(Debug, Deserialize, Serialize)] 11 | pub enum Error { 12 | Id, 13 | NotFound, 14 | NameMinLength { min: usize, actual: usize }, 15 | NameMaxLength { max: usize, actual: usize }, 16 | } 17 | -------------------------------------------------------------------------------- /crates/json-boundary/src/usecase/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod area_of_life; 2 | pub mod thought; 3 | -------------------------------------------------------------------------------- /crates/json-boundary/src/usecase/thought/create.rs: -------------------------------------------------------------------------------- 1 | use crate::domain::AreaOfLifeId; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(Debug, Deserialize, Serialize)] 5 | pub struct Request { 6 | pub title: String, 7 | pub areas_of_life: Vec, 8 | } 9 | 10 | #[derive(Debug, Deserialize, Serialize)] 11 | pub enum Error { 12 | AreaOfLifeId, 13 | TitleMinLength { min: usize, actual: usize }, 14 | TitleMaxLength { max: usize, actual: usize }, 15 | AreasOfLifeNotFound(Vec), 16 | } 17 | -------------------------------------------------------------------------------- /crates/json-boundary/src/usecase/thought/delete.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Debug, Deserialize, Serialize)] 4 | pub enum Error { 5 | Id, 6 | NotFound, 7 | } 8 | -------------------------------------------------------------------------------- /crates/json-boundary/src/usecase/thought/find_by_id.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Debug, Deserialize, Serialize)] 4 | pub enum Error { 5 | Id, 6 | NotFound, 7 | } 8 | -------------------------------------------------------------------------------- /crates/json-boundary/src/usecase/thought/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod create; 2 | pub mod delete; 3 | pub mod find_by_id; 4 | pub mod read_all; 5 | pub mod update; 6 | -------------------------------------------------------------------------------- /crates/json-boundary/src/usecase/thought/read_all.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Debug, Deserialize, Serialize)] 4 | pub enum Error { 5 | // TODO 6 | } 7 | -------------------------------------------------------------------------------- /crates/json-boundary/src/usecase/thought/update.rs: -------------------------------------------------------------------------------- 1 | use crate::domain::{AreaOfLifeId, ThoughtId}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(Debug, Deserialize, Serialize)] 5 | pub struct Request { 6 | pub id: ThoughtId, 7 | pub title: String, 8 | pub areas_of_life: Vec, 9 | } 10 | 11 | #[derive(Debug, Deserialize, Serialize)] 12 | pub enum Error { 13 | Id, 14 | NotFound(ThoughtId), 15 | AreaOfLifeId, 16 | TitleMinLength { min: usize, actual: usize }, 17 | TitleMaxLength { max: usize, actual: usize }, 18 | AreasOfLifeNotFound(Vec), 19 | } 20 | -------------------------------------------------------------------------------- /crates/web-app-api/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cawr-web-app-api" 3 | version.workspace = true 4 | edition.workspace = true 5 | rust-version.workspace = true 6 | publish = false 7 | 8 | [dependencies] 9 | cawr-json-boundary = { version = "=0.0.0", path = "../json-boundary" } 10 | serde = "1.0" 11 | thiserror = "1.0" 12 | 13 | [dependencies.gloo-net] 14 | version = "0.6" 15 | default-features = false 16 | features = ["http", "json"] 17 | -------------------------------------------------------------------------------- /crates/web-app-api/src/http.rs: -------------------------------------------------------------------------------- 1 | use std::result; 2 | 3 | use gloo_net::http::{Request, Response}; 4 | use serde::{Deserialize, Serialize}; 5 | use thiserror::Error; 6 | 7 | use cawr_json_boundary as boundary; 8 | 9 | #[derive(Debug, Error)] 10 | pub enum Error { 11 | Fetch(#[from] gloo_net::Error), 12 | Api(boundary::Error), 13 | } 14 | 15 | pub(crate) type Result = result::Result>; 16 | 17 | pub async fn get_json(url: &str) -> Result 18 | where 19 | T: for<'de> Deserialize<'de> + 'static, 20 | E: for<'de> Deserialize<'de> + 'static, 21 | { 22 | let res = Request::get(url).send().await?; 23 | to_result(res).await 24 | } 25 | 26 | pub async fn post_json(url: &str, req: &R) -> Result 27 | where 28 | R: Serialize, 29 | T: for<'de> Deserialize<'de> + 'static, 30 | E: for<'de> Deserialize<'de> + 'static, 31 | { 32 | let req = Request::post(url).json(req)?; 33 | let res = req.send().await?; 34 | to_result(res).await 35 | } 36 | 37 | pub async fn put_json(url: &str, req: &R) -> Result 38 | where 39 | R: Serialize, 40 | T: for<'de> Deserialize<'de> + 'static, 41 | E: for<'de> Deserialize<'de> + 'static, 42 | { 43 | let req = Request::put(url).json(req)?; 44 | let res = req.send().await?; 45 | to_result(res).await 46 | } 47 | 48 | pub async fn delete_json(url: &str, req: &R) -> Result 49 | where 50 | R: Serialize, 51 | T: for<'de> Deserialize<'de> + 'static, 52 | E: for<'de> Deserialize<'de> + 'static, 53 | { 54 | let req = Request::delete(url).json(req)?; 55 | let res = req.send().await?; 56 | to_result(res).await 57 | } 58 | 59 | async fn to_result(res: Response) -> Result 60 | where 61 | T: for<'de> Deserialize<'de> + 'static, 62 | E: for<'de> Deserialize<'de> + 'static, 63 | { 64 | if res.ok() { 65 | let data = res.json().await?; 66 | Ok(data) 67 | } else { 68 | let error = res.json().await?; 69 | Err(Error::Api(error)) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /crates/web-app-api/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod http; 2 | 3 | pub use self::{area_of_life::*, thought::*}; 4 | pub use http::Error; 5 | 6 | mod thought { 7 | use crate::http::{self, Result}; 8 | use cawr_json_boundary::{ 9 | domain::{AreaOfLifeId, Thought, ThoughtId}, 10 | usecase::thought::{create, delete, find_by_id, read_all, update}, 11 | }; 12 | 13 | pub async fn fetch_thought(id: &ThoughtId) -> Result { 14 | http::get_json(&format!("/api/thought/{}", id.0)).await 15 | } 16 | 17 | pub async fn fetch_all_thoughts() -> Result, read_all::Error> { 18 | http::get_json("/api/thought").await 19 | } 20 | 21 | pub async fn create_thought( 22 | title: String, 23 | areas_of_life: Vec, 24 | ) -> Result { 25 | http::post_json( 26 | "/api/thought", 27 | &create::Request { 28 | title, 29 | areas_of_life, 30 | }, 31 | ) 32 | .await 33 | } 34 | 35 | pub async fn update_thought( 36 | id: ThoughtId, 37 | title: String, 38 | areas_of_life: Vec, 39 | ) -> Result<(), update::Error> { 40 | http::put_json( 41 | &format!("/api/thought/{id}"), 42 | &update::Request { 43 | id, 44 | title, 45 | areas_of_life, 46 | }, 47 | ) 48 | .await 49 | } 50 | 51 | pub async fn delete_thought(id: &ThoughtId) -> Result<(), delete::Error> { 52 | http::delete_json(&format!("/api/thought/{}", id.0), &()).await 53 | } 54 | } 55 | 56 | mod area_of_life { 57 | use crate::http::{self, Result}; 58 | use cawr_json_boundary::{ 59 | domain::{AreaOfLife, AreaOfLifeId}, 60 | usecase::area_of_life::{create, delete, read_all, update}, 61 | }; 62 | const RESOURCE: &str = "area-of-life"; 63 | 64 | pub async fn fetch_all_areas_of_life() -> Result, read_all::Error> { 65 | http::get_json(&format!("/api/{RESOURCE}")).await 66 | } 67 | 68 | pub async fn create_area_of_life(name: String) -> Result { 69 | http::post_json(&format!("/api/{RESOURCE}"), &create::Request { name }).await 70 | } 71 | 72 | pub async fn update_area_of_life(id: AreaOfLifeId, name: String) -> Result<(), update::Error> { 73 | http::put_json( 74 | &format!("/api/{RESOURCE}/{id}"), 75 | &update::Request { id, name }, 76 | ) 77 | .await 78 | } 79 | 80 | pub async fn delete_area_of_life(id: &AreaOfLifeId) -> Result<(), delete::Error> { 81 | http::delete_json(&format!("/api/{RESOURCE}/{id}"), &()).await 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /crates/web-app-kern/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cawr-web-app-kern" 3 | authors.workspace = true 4 | version.workspace = true 5 | edition.workspace = true 6 | rust-version.workspace = true 7 | 8 | [dependencies] 9 | cawr-json-boundary = "=0.0.0" 10 | cawr-web-app-api = "=0.0.0" 11 | derive_more = { version = "1.0.0", features = ["from"] } 12 | gloo-net = { version = "0.6.0", default-features = false } 13 | -------------------------------------------------------------------------------- /crates/web-app-kern/src/domain.rs: -------------------------------------------------------------------------------- 1 | pub use cawr_json_boundary::domain::{AreaOfLife, AreaOfLifeId, Thought, ThoughtId}; 2 | -------------------------------------------------------------------------------- /crates/web-app-kern/src/lib.rs: -------------------------------------------------------------------------------- 1 | use cawr_web_app_api as api; 2 | 3 | mod usecase; 4 | 5 | pub mod domain; 6 | 7 | use self::domain::{AreaOfLife, AreaOfLifeId, Thought, ThoughtId}; 8 | 9 | // ------ ------ 10 | // Message 11 | // ------ ------ 12 | 13 | type Error = String; 14 | type Result = std::result::Result; 15 | 16 | #[derive(derive_more::From, Debug)] 17 | pub enum Msg { 18 | View(V), 19 | #[from] 20 | Usecase(UsecaseResult), 21 | } 22 | 23 | #[derive(Debug)] 24 | pub enum UsecaseResult { 25 | CreateThought(Result), 26 | UpdateThought(ThoughtId, Result<()>), 27 | CreateAreaOfLife(Result), 28 | UpdateAreaOfLife(AreaOfLifeId, Result<()>), 29 | FetchAllThoughts(Result>), 30 | FetchAllAreasOfLife(Result>), 31 | FindThought(Result), 32 | DeleteThought(Result), 33 | DeleteAreaOfLife(Result), 34 | } 35 | 36 | // -- Map usecases to messages -- // 37 | 38 | pub async fn create_thought(title: String, area_of_life: Option) -> UsecaseResult { 39 | let areas_of_life = area_of_life.map(|id| vec![id]).unwrap_or_default(); 40 | let res = usecase::thought::create(title, areas_of_life).await; 41 | UsecaseResult::CreateThought(res) 42 | } 43 | 44 | pub async fn update_thought(thought: Thought) -> UsecaseResult { 45 | let id = thought.id; 46 | let res = usecase::thought::update(thought).await; 47 | UsecaseResult::UpdateThought(id, res) 48 | } 49 | 50 | pub async fn fetch_all_thoughts() -> UsecaseResult { 51 | let res = usecase::thought::fetch_all().await; 52 | UsecaseResult::FetchAllThoughts(res) 53 | } 54 | 55 | pub async fn find_thought_by_id(id: domain::ThoughtId) -> UsecaseResult { 56 | let res = usecase::thought::find_by_id(&id).await; 57 | UsecaseResult::FindThought(res) 58 | } 59 | 60 | pub async fn delete_thought(id: domain::ThoughtId) -> UsecaseResult { 61 | let res = usecase::thought::delete(&id).await; 62 | UsecaseResult::DeleteThought(res.map(|()| id)) 63 | } 64 | 65 | pub async fn create_area_of_life(name: String) -> UsecaseResult { 66 | let res = usecase::area_of_life::create(name).await; 67 | UsecaseResult::CreateAreaOfLife(res) 68 | } 69 | 70 | pub async fn update_area_of_life(aol: AreaOfLife) -> UsecaseResult { 71 | let id = aol.id; 72 | let res = usecase::area_of_life::update(aol).await; 73 | UsecaseResult::UpdateAreaOfLife(id, res) 74 | } 75 | 76 | pub async fn fetch_all_areas_of_life() -> UsecaseResult { 77 | let res = usecase::area_of_life::fetch_all().await; 78 | UsecaseResult::FetchAllAreasOfLife(res) 79 | } 80 | 81 | pub async fn delete_area_of_life(id: domain::AreaOfLifeId) -> UsecaseResult { 82 | let res = usecase::area_of_life::delete(&id).await; 83 | UsecaseResult::DeleteAreaOfLife(res.map(|()| id)) 84 | } 85 | -------------------------------------------------------------------------------- /crates/web-app-kern/src/usecase/area_of_life.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | api, 3 | domain::{AreaOfLife, AreaOfLifeId}, 4 | usecase::{ErrorPresenter, Present}, 5 | }; 6 | 7 | // ------ ------ 8 | // Controller 9 | // ------ ------ 10 | 11 | pub async fn create(name: String) -> Result { 12 | let presenter = ErrorPresenter; 13 | api::create_area_of_life(name) 14 | .await 15 | .map_err(|e| presenter.present(e)) 16 | } 17 | 18 | pub async fn update(aol: AreaOfLife) -> Result<(), String> { 19 | let AreaOfLife { id, name } = aol; 20 | let presenter = ErrorPresenter; 21 | api::update_area_of_life(id, name) 22 | .await 23 | .map_err(|e| presenter.present(e)) 24 | } 25 | 26 | pub async fn fetch_all() -> Result, String> { 27 | let presenter = ErrorPresenter; 28 | api::fetch_all_areas_of_life() 29 | .await 30 | .map_err(|e| presenter.present(e)) 31 | } 32 | 33 | pub async fn delete(id: &AreaOfLifeId) -> Result<(), String> { 34 | let presenter = ErrorPresenter; 35 | api::delete_area_of_life(id) 36 | .await 37 | .map_err(|e| presenter.present(e)) 38 | } 39 | -------------------------------------------------------------------------------- /crates/web-app-kern/src/usecase/mod.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | use crate::api; 4 | 5 | pub mod area_of_life; 6 | pub mod thought; 7 | 8 | // ------ ------ 9 | // Presenter 10 | // ------ ------ 11 | 12 | trait Present { 13 | type ViewModel; 14 | fn present(&self, data: T) -> Self::ViewModel; 15 | } 16 | 17 | #[derive(Default)] 18 | struct ErrorPresenter; 19 | 20 | impl Present> for ErrorPresenter { 21 | type ViewModel = String; 22 | fn present(&self, err: api::Error) -> Self::ViewModel { 23 | use gloo_net::Error as F; 24 | match err { 25 | api::Error::Fetch(e) => match e { 26 | F::JsError(_) | F::GlooError(_) => { 27 | "A communication problem with the server has occured".to_string() 28 | } 29 | F::SerdeError(_) => { 30 | "A problem has arisen in the interpretation of the data".to_string() 31 | } 32 | }, 33 | api::Error::Api(e) => { 34 | if let Some(d) = &e.details { 35 | format!("{d:?}") // TODO 36 | } else if let Some(m) = &e.msg { 37 | m.to_string() 38 | } else { 39 | format!("{e:?}") // TODO 40 | } 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /crates/web-app-kern/src/usecase/thought.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | api, 3 | domain::{AreaOfLifeId, Thought, ThoughtId}, 4 | usecase::{ErrorPresenter, Present}, 5 | }; 6 | 7 | // ------ ------ 8 | // Controller 9 | // ------ ------ 10 | 11 | pub async fn create(title: String, areas_of_life: Vec) -> Result { 12 | let presenter = ErrorPresenter; 13 | api::create_thought(title, areas_of_life) 14 | .await 15 | .map_err(|e| presenter.present(e)) 16 | } 17 | 18 | pub async fn update(thought: Thought) -> Result<(), String> { 19 | let Thought { 20 | id, 21 | title, 22 | areas_of_life, 23 | } = thought; 24 | let presenter = ErrorPresenter; 25 | api::update_thought(id, title, areas_of_life) 26 | .await 27 | .map_err(|e| presenter.present(e)) 28 | } 29 | 30 | pub async fn find_by_id(id: &ThoughtId) -> Result { 31 | let presenter = ErrorPresenter; 32 | api::fetch_thought(id) 33 | .await 34 | .map_err(|e| presenter.present(e)) 35 | } 36 | 37 | pub async fn fetch_all() -> Result, String> { 38 | let presenter = ErrorPresenter; 39 | api::fetch_all_thoughts() 40 | .await 41 | .map_err(|e| presenter.present(e)) 42 | } 43 | 44 | pub async fn delete(id: &ThoughtId) -> Result<(), String> { 45 | let presenter = ErrorPresenter; 46 | api::delete_thought(id) 47 | .await 48 | .map_err(|e| presenter.present(e)) 49 | } 50 | -------------------------------------------------------------------------------- /crates/web-app-seed/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cawr-web-app-seed" 3 | authors.workspace = true 4 | version.workspace = true 5 | edition.workspace = true 6 | rust-version.workspace = true 7 | publish = false 8 | 9 | [dependencies] 10 | # Workspace dependencies 11 | cawr-json-boundary = "=0.0.0" 12 | cawr-web-app-kern = "=0.0.0" 13 | 14 | # External dependencies 15 | log = "0.4" 16 | seed = "0.10" 17 | -------------------------------------------------------------------------------- /crates/web-app-seed/src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::future::Future; 2 | 3 | use seed::prelude::*; 4 | 5 | use cawr_web_app_kern::{self as kern, domain, UsecaseResult}; 6 | 7 | mod view; 8 | 9 | // ------ ------ 10 | // Model 11 | // ------ ------ 12 | 13 | #[derive(Debug, Default)] 14 | pub struct Mdl { 15 | view: view::Mdl, 16 | } 17 | 18 | // ------ ------ 19 | // Message 20 | // ------ ------ 21 | 22 | pub type Msg = kern::Msg; 23 | 24 | impl From for Msg { 25 | fn from(from: view::Msg) -> Self { 26 | Msg::View(from) 27 | } 28 | } 29 | 30 | // ------ ------ 31 | // Update 32 | // ------ ------ 33 | 34 | fn update(msg: Msg, mdl: &mut Mdl, orders: &mut impl Orders) { 35 | log::debug!("{msg:?}"); 36 | match msg { 37 | Msg::View(msg) => { 38 | if let Some(cmd) = view::update(msg, &mut mdl.view) { 39 | match cmd { 40 | view::Cmd::CreateThought(title, areas_of_life) => { 41 | run_usecase(orders, kern::create_thought(title, areas_of_life)); 42 | } 43 | view::Cmd::UpdateThought(thought) => { 44 | run_usecase(orders, kern::update_thought(thought)); 45 | } 46 | view::Cmd::CreateAreaOfLife(name) => { 47 | run_usecase(orders, kern::create_area_of_life(name)); 48 | } 49 | view::Cmd::DeleteThought(id) => { 50 | run_usecase(orders, kern::delete_thought(id)); 51 | } 52 | view::Cmd::DeleteAreaOfLife(id) => { 53 | run_usecase(orders, kern::delete_area_of_life(id)); 54 | } 55 | view::Cmd::UpdateAreaOfLife(aol) => { 56 | run_usecase(orders, kern::update_area_of_life(aol)); 57 | } 58 | view::Cmd::SendMessages(messages) => { 59 | orders.skip(); 60 | for m in messages { 61 | orders.send_msg(Msg::View(m)); 62 | } 63 | } 64 | } 65 | } 66 | } 67 | Msg::Usecase(msg) => match msg { 68 | UsecaseResult::CreateThought(res) => { 69 | if let Ok(id) = &res { 70 | run_usecase(orders, kern::find_thought_by_id(*id)); 71 | } 72 | let msg = view::Msg::CreateThoughtResult(res); 73 | orders.send_msg(msg.into()); 74 | } 75 | UsecaseResult::UpdateThought(id, res) => { 76 | // Re-fetch the thought 77 | run_usecase(orders, kern::find_thought_by_id(id)); 78 | let msg = view::Msg::UpdateThoughtResult(res); 79 | orders.send_msg(msg.into()); 80 | } 81 | UsecaseResult::CreateAreaOfLife(res) => { 82 | if res.is_ok() { 83 | run_usecase(orders, kern::fetch_all_areas_of_life()); 84 | } 85 | let msg = view::Msg::CreateAreaOfLifeResult(res); 86 | orders.send_msg(msg.into()); 87 | } 88 | UsecaseResult::UpdateAreaOfLife(_id, res) => { 89 | // Re-fetch 90 | run_usecase(orders, kern::fetch_all_areas_of_life()); 91 | let msg = view::Msg::UpdateAreaOfLifeResult(res); 92 | orders.send_msg(msg.into()); 93 | } 94 | UsecaseResult::FindThought(res) => { 95 | let msg = view::Msg::FindThoughtResult(res); 96 | orders.send_msg(msg.into()); 97 | } 98 | UsecaseResult::FetchAllThoughts(res) => { 99 | let msg = view::Msg::FetchAllThoughtsResult(res); 100 | orders.send_msg(msg.into()); 101 | } 102 | UsecaseResult::FetchAllAreasOfLife(res) => { 103 | let msg = view::Msg::FetchAllAreasOfLifeResult(res); 104 | orders.send_msg(msg.into()); 105 | } 106 | UsecaseResult::DeleteThought(res) => { 107 | let msg = view::Msg::DeleteThoughtResult(res); 108 | orders.send_msg(msg.into()); 109 | } 110 | UsecaseResult::DeleteAreaOfLife(res) => { 111 | let msg = view::Msg::DeleteAreaOfLifeResult(res); 112 | orders.send_msg(msg.into()); 113 | } 114 | }, 115 | } 116 | } 117 | 118 | fn run_usecase(orders: &mut O, usecase: F) 119 | where 120 | F: Future + 'static, 121 | O: Orders, 122 | { 123 | orders.perform_cmd(async { 124 | let result = usecase.await; 125 | Msg::Usecase(result) 126 | }); 127 | } 128 | 129 | // ------ ------ 130 | // Init 131 | // ------ ------ 132 | 133 | fn init(_: Url, orders: &mut impl Orders) -> Mdl { 134 | run_usecase(orders, kern::fetch_all_thoughts()); 135 | run_usecase(orders, kern::fetch_all_areas_of_life()); 136 | Mdl::default() 137 | } 138 | 139 | // ------ ------ 140 | // View 141 | // ------ ------ 142 | 143 | fn view(mdl: &Mdl) -> impl IntoNodes { 144 | view::view(&mdl.view).map_msg(Msg::View) 145 | } 146 | 147 | // ------ ------ 148 | // Start 149 | // ------ ------ 150 | 151 | pub fn start(mount: web_sys::Element) { 152 | App::start(mount, init, update, view); 153 | } 154 | -------------------------------------------------------------------------------- /crates/web-app-seed/src/view/mod.rs: -------------------------------------------------------------------------------- 1 | use seed::prelude::*; 2 | 3 | use crate::domain::{AreaOfLife, AreaOfLifeId, Thought, ThoughtId}; 4 | 5 | pub mod new_area_of_life_dialog; 6 | pub mod page; 7 | 8 | // ------ ------ 9 | // Model 10 | // ------ ------ 11 | 12 | #[derive(Debug, Default)] 13 | pub struct Mdl { 14 | page: page::Mdl, 15 | } 16 | 17 | // ------ ------ 18 | // Message 19 | // ------ ------ 20 | 21 | type Error = String; 22 | type Result = std::result::Result; 23 | 24 | #[derive(Debug)] 25 | pub enum Msg { 26 | Page(page::Msg), 27 | CreateThoughtResult(Result), 28 | UpdateThoughtResult(Result<()>), 29 | CreateAreaOfLifeResult(Result), 30 | FindThoughtResult(Result), 31 | FetchAllThoughtsResult(Result>), 32 | FetchAllAreasOfLifeResult(Result>), 33 | DeleteThoughtResult(Result), 34 | DeleteAreaOfLifeResult(Result), 35 | UpdateAreaOfLifeResult(Result<()>), 36 | } 37 | 38 | // ------ ------ 39 | // Command 40 | // ------ ------ 41 | 42 | #[derive(Debug)] 43 | pub enum Cmd { 44 | CreateThought(String, Option), 45 | UpdateThought(Thought), 46 | DeleteThought(ThoughtId), 47 | CreateAreaOfLife(String), 48 | DeleteAreaOfLife(AreaOfLifeId), 49 | UpdateAreaOfLife(AreaOfLife), 50 | SendMessages(Vec), 51 | } 52 | 53 | impl From for Cmd { 54 | fn from(cmd: page::Cmd) -> Self { 55 | use page::Cmd as C; 56 | match cmd { 57 | C::CreateThought(title, aol) => Self::CreateThought(title, aol), 58 | C::UpdateThought(thought) => Self::UpdateThought(thought), 59 | C::DeleteThought(id) => Self::DeleteThought(id), 60 | C::CreateAreaOfLife(name) => Self::CreateAreaOfLife(name), 61 | C::DeleteAreaOfLife(id) => Self::DeleteAreaOfLife(id), 62 | C::UpdateAreaOfLife(aol) => Self::UpdateAreaOfLife(aol), 63 | C::SendMessages(m) => Self::SendMessages(m.into_iter().map(Msg::Page).collect()), 64 | } 65 | } 66 | } 67 | 68 | // ------ ------ 69 | // Update 70 | // ------ ------ 71 | 72 | #[must_use] 73 | pub fn update(msg: Msg, mdl: &mut Mdl) -> Option { 74 | let page_msg = match msg { 75 | Msg::Page(msg) => msg, 76 | Msg::CreateThoughtResult(res) => page::Msg::Home(page::home::Msg::CreateThoughtResult(res)), 77 | Msg::UpdateThoughtResult(res) => page::Msg::Home(page::home::Msg::UpdateThoughtResult(res)), 78 | Msg::CreateAreaOfLifeResult(res) => { 79 | page::Msg::Home(page::home::Msg::CreateAreaOfLifeResult(res)) 80 | } 81 | Msg::FindThoughtResult(res) => page::Msg::Home(page::home::Msg::FindThoughtResult(res)), 82 | Msg::DeleteThoughtResult(res) => page::Msg::Home(page::home::Msg::DeleteThoughtResult(res)), 83 | Msg::DeleteAreaOfLifeResult(res) => { 84 | page::Msg::Home(page::home::Msg::DeleteAreaOfLifeResult(res)) 85 | } 86 | Msg::UpdateAreaOfLifeResult(res) => { 87 | page::Msg::Home(page::home::Msg::UpdateAreaOfLifeResult(res)) 88 | } 89 | Msg::FetchAllThoughtsResult(res) => { 90 | page::Msg::Home(page::home::Msg::FetchAllThoughtsResult(res)) 91 | } 92 | Msg::FetchAllAreasOfLifeResult(res) => { 93 | page::Msg::Home(page::home::Msg::FetchAllAreasOfLifeResult(res)) 94 | } 95 | }; 96 | page::update(page_msg, &mut mdl.page).map(Cmd::from) 97 | } 98 | 99 | // ------ ------ 100 | // View 101 | // ------ ------ 102 | 103 | pub fn view(mdl: &Mdl) -> Vec> { 104 | page::view(&mdl.page).map_msg(Msg::Page) 105 | } 106 | -------------------------------------------------------------------------------- /crates/web-app-seed/src/view/new_area_of_life_dialog.rs: -------------------------------------------------------------------------------- 1 | use seed::{ 2 | attrs, button, div, empty, footer, header, i, input, p, prelude::*, section, span, C, IF, 3 | }; 4 | 5 | // ------ ------ 6 | // Model 7 | // ------ ------ 8 | 9 | #[derive(Debug, Default)] 10 | pub struct Mdl { 11 | pub active: bool, 12 | pub wait: bool, 13 | pub name: String, 14 | pub error: Option, 15 | } 16 | 17 | // ------ ------ 18 | // Message 19 | // ------ ------ 20 | 21 | #[derive(Debug)] 22 | pub enum Msg { 23 | Input(String), 24 | Add, 25 | Cancel, 26 | } 27 | 28 | // ------ ------ 29 | // Command 30 | // ------ ------ 31 | 32 | #[derive(Debug)] 33 | pub enum Cmd { 34 | Add(String), 35 | } 36 | 37 | // ------ ------ 38 | // Update 39 | // ------ ------ 40 | 41 | pub fn update(msg: Msg, mdl: &mut Mdl) -> Option { 42 | match msg { 43 | Msg::Cancel => { 44 | mdl.active = false; 45 | mdl.name.clear(); 46 | mdl.wait = false; 47 | mdl.error = None; 48 | } 49 | Msg::Input(txt) => { 50 | mdl.name = txt; 51 | } 52 | Msg::Add => { 53 | mdl.wait = true; 54 | return Some(Cmd::Add(mdl.name.clone())); 55 | } 56 | } 57 | None 58 | } 59 | 60 | // ------ ------ 61 | // View 62 | // ------ ------ 63 | 64 | pub fn view(mdl: &Mdl) -> Node { 65 | div![ 66 | C!["modal", IF!(mdl.active => "is-active")], 67 | div![C!["modal-background"]], 68 | div![ 69 | C!["modal-card"], 70 | header![ 71 | C!["modal-card-head"], 72 | p![C!["modal-card-title"], "Add new area of life"], 73 | button![ev(Ev::Click, |_| Msg::Cancel), C!["delete"]] 74 | ], 75 | section![ 76 | C!["modal-card-body"], 77 | div![ 78 | C!["field"], 79 | div![ 80 | C!["control", IF!(mdl.error.is_some() => "has-icons-right")], 81 | input![ 82 | C!["input", IF!(mdl.error.is_some() => "is-danger")], 83 | input_ev(Ev::Input, Msg::Input), 84 | keyboard_ev(Ev::KeyUp, |ev| { 85 | if ev.key() == "Enter" { 86 | return Some(Msg::Add); 87 | } 88 | None 89 | }), 90 | attrs! { 91 | At::Value => mdl.name; 92 | At::ReadOnly => mdl.wait.as_at_value(); 93 | At::Disabled => mdl.wait.as_at_value(); 94 | At::Placeholder => "Name of the area of life"; 95 | }, 96 | ], 97 | if mdl.error.is_some() { 98 | span![ 99 | C!["icon", "is-small", "is-right"], 100 | i![C!["fas", "fa-exclamation-triangle"]] 101 | ] 102 | } else { 103 | empty!() 104 | } 105 | ], 106 | if let Some(err) = &mdl.error { 107 | p![C!["help", "is-danger"], err] 108 | } else { 109 | empty!() 110 | } 111 | ] 112 | ], 113 | footer![ 114 | C!["modal-card-foot"], 115 | button![ 116 | ev(Ev::Click, |_| Msg::Add), 117 | attrs! { At::Disabled => mdl.name.is_empty().as_at_value(); }, 118 | C!["button", "is-success", IF!(mdl.wait => "is-loading")], 119 | "Add" 120 | ], 121 | button![ev(Ev::Click, |_| Msg::Cancel), C!["button"], "Cancel"] 122 | ] 123 | ], 124 | ] 125 | } 126 | -------------------------------------------------------------------------------- /crates/web-app-seed/src/view/page/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::domain::{AreaOfLife, AreaOfLifeId, Thought, ThoughtId}; 2 | use seed::prelude::*; 3 | 4 | pub mod home; 5 | 6 | // ------ ------ 7 | // Model 8 | // ------ ------ 9 | 10 | #[derive(Debug)] 11 | pub enum Mdl { 12 | Home(home::Mdl), 13 | } 14 | 15 | impl Default for Mdl { 16 | fn default() -> Self { 17 | Self::Home(home::Mdl::default()) 18 | } 19 | } 20 | 21 | // ------ ------ 22 | // Message 23 | // ------ ------ 24 | 25 | #[derive(Debug)] 26 | pub enum Msg { 27 | Home(home::Msg), 28 | } 29 | 30 | // ------ ------ 31 | // Command 32 | // ------ ------ 33 | 34 | #[derive(Debug)] 35 | pub enum Cmd { 36 | CreateThought(String, Option), 37 | UpdateThought(Thought), 38 | CreateAreaOfLife(String), 39 | DeleteThought(ThoughtId), 40 | DeleteAreaOfLife(AreaOfLifeId), 41 | UpdateAreaOfLife(AreaOfLife), 42 | SendMessages(Vec), 43 | } 44 | 45 | impl From for Cmd { 46 | fn from(cmd: home::Cmd) -> Self { 47 | use home::Cmd as C; 48 | match cmd { 49 | C::CreateThought(title, area_of_life) => Self::CreateThought(title, area_of_life), 50 | C::UpdateThought(thought) => Self::UpdateThought(thought), 51 | C::CreateAreaOfLife(name) => Self::CreateAreaOfLife(name), 52 | C::DeleteThought(id) => Self::DeleteThought(id), 53 | C::DeleteAreaOfLife(id) => Self::DeleteAreaOfLife(id), 54 | C::UpdateAreaOfLife(aol) => Self::UpdateAreaOfLife(aol), 55 | C::SendMessages(m) => Self::SendMessages(m.into_iter().map(Msg::Home).collect()), 56 | } 57 | } 58 | } 59 | 60 | // ------ ------ 61 | // Update 62 | // ------ ------ 63 | 64 | pub fn update(msg: Msg, mdl: &mut Mdl) -> Option { 65 | match msg { 66 | Msg::Home(msg) => { 67 | let Mdl::Home(mdl) = mdl; 68 | home::update(msg, mdl).map(Cmd::from) 69 | } 70 | } 71 | } 72 | 73 | // ------ ------ 74 | // View 75 | // ------ ------ 76 | 77 | pub fn view(mdl: &Mdl) -> Vec> { 78 | match mdl { 79 | Mdl::Home(mdl) => home::view(mdl).map_msg(Msg::Home), 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /crates/web-app/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /dist 3 | -------------------------------------------------------------------------------- /crates/web-app/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cawr-web-app" 3 | version = "0.0.0" 4 | edition = "2021" 5 | rust-version= "1.79" 6 | publish = false 7 | 8 | [dependencies] 9 | console_error_panic_hook = "0.1.7" 10 | console_log = "1.0.0" 11 | gloo-utils = { version = "0.2.0", default-features = false } 12 | log = "0.4.22" 13 | 14 | [dependencies.cawr-web-app-seed] 15 | version = "=0.0.0" 16 | 17 | [dev-dependencies] 18 | wasm-bindgen-test = "0.3" 19 | 20 | [patch.crates-io] 21 | cawr-json-boundary = { path = "../json-boundary" } 22 | cawr-web-app-api = { path = "../web-app-api" } 23 | cawr-web-app-kern = { path = "../web-app-kern" } 24 | cawr-web-app-seed = { path = "../web-app-seed" } 25 | 26 | [profile.release] 27 | lto = true 28 | opt-level = 'z' 29 | codegen-units= 1 30 | -------------------------------------------------------------------------------- /crates/web-app/Trunk.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | release = true 3 | -------------------------------------------------------------------------------- /crates/web-app/assets/fa-solid.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Font Awesome Free 5.15.3 by @fontawesome - https://fontawesome.com 3 | * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) 4 | */ 5 | @font-face{font-family:"Font Awesome 5 Free";font-style:normal;font-weight:900;font-display:block;src:url(../webfonts/fa-solid-900.eot);src:url(../webfonts/fa-solid-900.eot?#iefix) format("embedded-opentype"),url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.woff) format("woff"),url(../webfonts/fa-solid-900.ttf) format("truetype"),url(../webfonts/fa-solid-900.svg#fontawesome) format("svg")}.fa,.fas{font-family:"Font Awesome 5 Free";font-weight:900} -------------------------------------------------------------------------------- /crates/web-app/assets/webfonts/fa-solid-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flosse/clean-architecture-with-rust/3a2f2ba8dca5bf59e6796b6cf41c1e25238ee85d/crates/web-app/assets/webfonts/fa-solid-900.woff2 -------------------------------------------------------------------------------- /crates/web-app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Full-Stack Clean Architecture with Rust 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |

15 | 16 | 17 | -------------------------------------------------------------------------------- /crates/web-app/main.sass: -------------------------------------------------------------------------------- 1 | @import "assets/bulma.min" 2 | 3 | html 4 | overflow-y: auto 5 | 6 | $header-height: 64px 7 | $sidebar-width: 250px 8 | $edit-sidebar-width: 300px 9 | 10 | #app 11 | display: grid 12 | min-height: 100vh 13 | grid-template-rows: $header-height auto 14 | grid-template-columns: $sidebar-width auto $edit-sidebar-width 15 | 16 | #main-navbar 17 | grid-column-start: 1 18 | grid-column-end: -1 19 | box-shadow: 0 0 5px rgba(0,0,0,0.3) 20 | .title, .subtitle 21 | margin: 0 22 | .subtitle 23 | margin-left: 1.25em 24 | 25 | #main 26 | grid-column-start: 2 27 | 28 | .thoughts ul li 29 | cursor: pointer 30 | margin-bottom: 0.3em 31 | padding-left: 0.5em 32 | border-radius: 0.3em 33 | &:hover 34 | background: #eee 35 | 36 | .error-message 37 | border-radius: 0.3em 38 | margin: 0.3em 39 | color: #f14668 40 | background-color: #fee 41 | padding: 0.5em 42 | font-size: 0.875rem 43 | 44 | #edit-sidebar 45 | grid-column-start: 3 46 | border-left: 1px solid #ddd 47 | padding: 0.5em 48 | 49 | input 50 | border: none 51 | border-radius: 0 52 | border-bottom: 1px solid transparent 53 | box-shadow: none 54 | padding: 0.2em 0 55 | 56 | &:focus 57 | border-bottom: 1px solid #ddd 58 | 59 | #main-sidebar 60 | grid-column-start: 1 61 | padding: 2em 0.5em 0px 62 | border-right: 1px solid #ddd 63 | 64 | .menu-label 65 | .button 66 | color: #aaa 67 | float: right 68 | margin-top: -0.75em 69 | border-color: transparent 70 | 71 | &:hover 72 | color: #000 73 | border-color: #b5b5b5 74 | 75 | .menu-list.aol 76 | margin-top: 1em 77 | margin-left: 1em 78 | 79 | li 80 | font-size: 0.9em 81 | margin-bottom: 1em 82 | padding-bottom: 0.3em 83 | cursor: pointer 84 | color: #777 85 | 86 | &.active 87 | color: #222 88 | border-bottom: 2px solid #b5b5b5 89 | 90 | &:hover 91 | color: #000 92 | 93 | #main-sidebar, #edit-sidebar, #main 94 | transition: all 0.2s ease-out 0s 95 | -------------------------------------------------------------------------------- /crates/web-app/src/main.rs: -------------------------------------------------------------------------------- 1 | use cawr_web_app_seed::start; 2 | 3 | // ------ ------ 4 | // Start 5 | // ------ ------ 6 | 7 | fn main() { 8 | _ = console_log::init_with_level(log::Level::Debug); // TODO: use 'Info' in release mode 9 | console_error_panic_hook::set_once(); 10 | let mount = gloo_utils::document() 11 | .get_element_by_id("app") 12 | .expect("#app node"); 13 | log::info!("Start web application"); 14 | start(mount); 15 | } 16 | -------------------------------------------------------------------------------- /crates/web-server-warp/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cawr-web-server-warp" 3 | version.workspace = true 4 | edition.workspace = true 5 | rust-version.workspace = true 6 | publish = false 7 | 8 | [dependencies] 9 | 10 | # Workspace dependencies 11 | cawr-adapter = "=0.0.0" 12 | 13 | # External dependencies 14 | log = "0.4" 15 | mime_guess = "2.0" 16 | rust-embed = "8.5" 17 | serde = { version = "1.0", features = ["derive"] } 18 | warp = "0.3" 19 | 20 | [dev-dependencies] 21 | 22 | # Workspace dependencies 23 | cawr-application = "=0.0.0" 24 | cawr-db = "=0.0.0" 25 | cawr-domain = "=0.0.0" 26 | 27 | # External dependencies 28 | anyhow = "1.0" 29 | hyper = "=0.14" # warp still uses this version 30 | serde_json = "1.0" 31 | tokio = { version = "1.40", features = ["full"] } 32 | -------------------------------------------------------------------------------- /crates/web-server-warp/src/handler/area_of_life/create.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | handler::{reply_error, reply_json, Result}, 3 | AppApi, 4 | }; 5 | use cawr_adapter::{db::Db, model::view::json::area_of_life::create::Request}; 6 | use warp::Reply; 7 | 8 | pub async fn handle(req: Request, api: AppApi) -> Result 9 | where 10 | D: Db, 11 | { 12 | match api.create_area_of_life(req.name) { 13 | Ok(res) => Ok(reply_json(&res.data, res.status)), 14 | Err(err) => Ok(reply_error(err)), 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /crates/web-server-warp/src/handler/area_of_life/delete.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | handler::{reply_error, reply_json, Result}, 3 | AppApi, 4 | }; 5 | use cawr_adapter::db::Db; 6 | use warp::Reply; 7 | 8 | pub type Request = String; 9 | 10 | pub async fn handle(req: Request, api: AppApi) -> Result 11 | where 12 | D: Db, 13 | { 14 | match api.delete_area_of_life(&req) { 15 | Ok(res) => Ok(reply_json(&res.data, res.status)), 16 | Err(err) => Ok(reply_error(err)), 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /crates/web-server-warp/src/handler/area_of_life/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod create; 2 | pub mod delete; 3 | pub mod read_all; 4 | pub mod update; 5 | -------------------------------------------------------------------------------- /crates/web-server-warp/src/handler/area_of_life/read_all.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | handler::{reply_error, reply_json, Result}, 3 | AppApi, 4 | }; 5 | use cawr_adapter::db::Db; 6 | use warp::Reply; 7 | 8 | pub async fn handle(api: AppApi) -> Result 9 | where 10 | D: Db, 11 | { 12 | match api.read_all_areas_of_life() { 13 | Ok(res) => Ok(reply_json(&res.data, res.status)), 14 | Err(err) => Ok(reply_error(err)), 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /crates/web-server-warp/src/handler/area_of_life/update.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | handler::{reply_error, reply_json, Result}, 3 | AppApi, 4 | }; 5 | use cawr_adapter::{db::Db, model::view::json::area_of_life::update::Request}; 6 | use warp::Reply; 7 | 8 | pub async fn handle(id: String, req: Request, api: AppApi) -> Result 9 | where 10 | D: Db, 11 | { 12 | match api.update_area_of_life(&id, req.name) { 13 | Ok(res) => Ok(reply_json(&res.data, res.status)), 14 | Err(err) => Ok(reply_error(err)), 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /crates/web-server-warp/src/handler/error.rs: -------------------------------------------------------------------------------- 1 | use std::result; 2 | 3 | use serde::Serialize; 4 | use warp::{reply, Rejection}; 5 | 6 | use cawr_adapter::model::view::json::{Error, StatusCode}; 7 | 8 | pub type Result = result::Result; 9 | 10 | pub fn reply_error(err: Error) -> reply::WithStatus 11 | where 12 | T: serde::Serialize, 13 | { 14 | let status = into_warp_status_code(err.status); 15 | let reply = reply::json(&err); 16 | reply::with_status(reply, status) 17 | } 18 | 19 | pub fn reply_json(data: &T, status: StatusCode) -> reply::WithStatus 20 | where 21 | T: Serialize, 22 | { 23 | let json = reply::json(data); 24 | let status = into_warp_status_code(status); 25 | reply::with_status(json, status) 26 | } 27 | 28 | fn into_warp_status_code(status: StatusCode) -> warp::http::StatusCode { 29 | // This must never fail because `StatusCode::as_u16` should return a valid code. 30 | warp::http::StatusCode::from_u16(status.as_u16()).expect("HTTP status code") 31 | } 32 | -------------------------------------------------------------------------------- /crates/web-server-warp/src/handler/mod.rs: -------------------------------------------------------------------------------- 1 | mod error; 2 | use self::error::{reply_error, reply_json, Result}; 3 | 4 | pub mod area_of_life; 5 | pub mod thought; 6 | -------------------------------------------------------------------------------- /crates/web-server-warp/src/handler/thought/create.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | handler::{reply_error, reply_json, Result}, 3 | AppApi, 4 | }; 5 | use cawr_adapter::{db::Db, model::view::json::thought::create::Request}; 6 | use warp::Reply; 7 | 8 | pub async fn handle(req: Request, api: AppApi) -> Result 9 | where 10 | D: Db, 11 | { 12 | let areas_of_life = req 13 | .areas_of_life 14 | .into_iter() 15 | .map(|id| id.0.to_string()) 16 | .collect(); 17 | match api.create_thought(req.title, &areas_of_life) { 18 | Ok(res) => Ok(reply_json(&res.data, res.status)), 19 | Err(err) => Ok(reply_error(err)), 20 | } 21 | } 22 | 23 | #[cfg(test)] 24 | mod tests { 25 | use super::{handle, Request}; 26 | use crate::tests::{app_api, blank_db, response_json_body}; 27 | use cawr_adapter::model::view::json::{self as json, thought::create as uc, Error}; 28 | use cawr_application::gateway::repository::thought::Repo; 29 | use serde_json::Value; 30 | use warp::{http::StatusCode, Reply}; 31 | 32 | #[tokio::test] 33 | async fn create() { 34 | let db = blank_db(); 35 | let app_api = app_api(db.clone()); 36 | let req = Request { 37 | title: "test 1".to_string(), 38 | areas_of_life: vec![], 39 | }; 40 | let res = handle(req, app_api).await.unwrap().into_response(); 41 | 42 | assert_eq!(res.status(), StatusCode::CREATED); 43 | 44 | let body: Value = response_json_body(res).await.unwrap(); 45 | let id = body.as_u64().unwrap(); 46 | let record = db.as_ref().get(id.into()).unwrap(); 47 | 48 | assert_eq!(record.thought.title().as_ref(), "test 1"); 49 | } 50 | 51 | #[tokio::test] 52 | async fn create_with_too_short_title() { 53 | let db = blank_db(); 54 | let app_api = app_api(db); 55 | let req = Request { 56 | title: "t".to_string(), 57 | areas_of_life: vec![], 58 | }; 59 | let res = handle(req, app_api).await.unwrap().into_response(); 60 | 61 | assert_eq!(res.status(), StatusCode::BAD_REQUEST); 62 | 63 | let err: Error = response_json_body(res).await.unwrap(); 64 | 65 | assert_eq!( 66 | err.msg.unwrap(), 67 | "The title must have at least 3 but has 1 chars" 68 | ); 69 | assert_eq!(err.status, json::StatusCode::BAD_REQUEST); 70 | assert!(matches!( 71 | err.details.unwrap(), 72 | uc::Error::TitleMinLength { actual: 1, min: 3 } 73 | )); 74 | } 75 | 76 | #[tokio::test] 77 | async fn create_with_too_long_title() { 78 | let db = blank_db(); 79 | let app_api = app_api(db); 80 | let req = Request { 81 | title: ["t"; 100].join(""), 82 | areas_of_life: vec![], 83 | }; 84 | let res = handle(req, app_api).await.unwrap().into_response(); 85 | 86 | assert_eq!(res.status(), StatusCode::BAD_REQUEST); 87 | 88 | let err: Error = response_json_body(res).await.unwrap(); 89 | 90 | assert_eq!( 91 | err.msg.unwrap(), 92 | "The title must have at most 80 but has 100 chars" 93 | ); 94 | assert_eq!(err.status, json::StatusCode::BAD_REQUEST); 95 | assert!(matches!( 96 | err.details.unwrap(), 97 | uc::Error::TitleMaxLength { 98 | actual: 100, 99 | max: 80 100 | } 101 | )); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /crates/web-server-warp/src/handler/thought/delete.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | handler::{reply_error, reply_json, Result}, 3 | AppApi, 4 | }; 5 | use cawr_adapter::db::Db; 6 | use warp::Reply; 7 | 8 | pub type Request = String; 9 | 10 | pub async fn handle(req: Request, api: AppApi) -> Result 11 | where 12 | D: Db, 13 | { 14 | match api.delete_thought(&req) { 15 | Ok(res) => Ok(reply_json(&res.data, res.status)), 16 | Err(err) => Ok(reply_error(err)), 17 | } 18 | } 19 | 20 | #[cfg(test)] 21 | mod tests { 22 | use super::handle; 23 | use crate::tests::{add_thought_to_db, app_api, blank_db}; 24 | use cawr_adapter::model::app::thought as app; 25 | use cawr_application::gateway::repository::thought::Repo; 26 | use warp::{http::StatusCode, Reply}; 27 | 28 | #[tokio::test] 29 | async fn delete() { 30 | let db = blank_db(); 31 | add_thought_to_db(&db, "foo"); 32 | add_thought_to_db(&db, "bar"); 33 | 34 | let id = "2".parse::().unwrap().into(); 35 | 36 | assert!(db.get(id).is_ok()); 37 | 38 | let app_api = app_api(db.clone()); 39 | let req = id.to_string(); 40 | let res = handle(req, app_api).await.unwrap().into_response(); 41 | 42 | assert_eq!(res.status(), StatusCode::OK); 43 | assert!(db.get(id).is_err()); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /crates/web-server-warp/src/handler/thought/find_by_id.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | handler::{reply_error, reply_json, Result}, 3 | AppApi, 4 | }; 5 | use cawr_adapter::db::Db; 6 | use warp::Reply; 7 | 8 | pub type Request = String; 9 | 10 | pub async fn handle(req: Request, api: AppApi) -> Result 11 | where 12 | D: Db, 13 | { 14 | match api.find_thought(&req) { 15 | Ok(res) => Ok(reply_json(&res.data, res.status)), 16 | Err(err) => Ok(reply_error(err)), 17 | } 18 | } 19 | 20 | #[cfg(test)] 21 | mod tests { 22 | use super::handle; 23 | use crate::tests::{add_thought_to_db, app_api, blank_db, corrupt_db, response_json_body}; 24 | use cawr_adapter::model::view::json::{self as json, thought::find_by_id as uc, Error}; 25 | use serde_json::Value; 26 | use warp::{http::StatusCode, Reply}; 27 | 28 | #[tokio::test] 29 | async fn read() { 30 | let db = blank_db(); 31 | add_thought_to_db(&db, "foo"); 32 | add_thought_to_db(&db, "bar"); 33 | 34 | let app_api = app_api(db.clone()); 35 | let req = "2".to_string(); 36 | let res = handle(req, app_api).await.unwrap().into_response(); 37 | 38 | assert_eq!(res.status(), StatusCode::OK); 39 | 40 | let body: Value = response_json_body(res).await.unwrap(); 41 | let thought = body.as_object().unwrap(); 42 | let title = thought.get("title").unwrap().as_str().unwrap(); 43 | 44 | assert_eq!(title, "bar"); 45 | } 46 | 47 | #[tokio::test] 48 | async fn read_non_existent() { 49 | let db = blank_db(); 50 | 51 | let app_api = app_api(db.clone()); 52 | let req = "5".to_string(); 53 | let res = handle(req, app_api).await.unwrap().into_response(); 54 | 55 | assert_eq!(res.status(), StatusCode::NOT_FOUND); 56 | 57 | let err: Error = response_json_body(res).await.unwrap(); 58 | 59 | assert_eq!(err.msg.unwrap(), "Could not find thought"); 60 | assert_eq!(err.status, json::StatusCode::NOT_FOUND); 61 | assert!(matches!(err.details.unwrap(), uc::Error::NotFound)); 62 | } 63 | 64 | #[tokio::test] 65 | async fn read_invalid_id() { 66 | let db = blank_db(); 67 | 68 | let app_api = app_api(db.clone()); 69 | let req = "invalid-id".to_string(); 70 | let res = handle(req, app_api).await.unwrap().into_response(); 71 | 72 | assert_eq!(res.status(), StatusCode::BAD_REQUEST); 73 | 74 | let err: Error = response_json_body(res).await.unwrap(); 75 | assert_eq!(err.msg.unwrap(), "Unable to parse thought ID"); 76 | assert_eq!(err.status, json::StatusCode::BAD_REQUEST); 77 | assert!(matches!(err.details.unwrap(), uc::Error::Id)); 78 | } 79 | 80 | #[tokio::test] 81 | async fn read_with_corrupt_db() { 82 | let db = corrupt_db(); 83 | 84 | let app_api = app_api(db.clone()); 85 | let req = "1".to_string(); 86 | let res = handle(req, app_api).await.unwrap().into_response(); 87 | 88 | assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR); 89 | 90 | let err: Error = response_json_body(res).await.unwrap(); 91 | 92 | assert_eq!(err.msg, None); 93 | assert_eq!(err.status, json::StatusCode::INTERNAL_SERVER_ERROR); 94 | assert!(err.details.is_none()); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /crates/web-server-warp/src/handler/thought/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod create; 2 | pub mod delete; 3 | pub mod find_by_id; 4 | pub mod read_all; 5 | pub mod update; 6 | -------------------------------------------------------------------------------- /crates/web-server-warp/src/handler/thought/read_all.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | handler::{reply_error, reply_json, Result}, 3 | AppApi, 4 | }; 5 | use cawr_adapter::db::Db; 6 | use warp::Reply; 7 | 8 | pub async fn handle(api: AppApi) -> Result 9 | where 10 | D: Db, 11 | { 12 | match api.read_all_thoughts() { 13 | Ok(res) => Ok(reply_json(&res.data, res.status)), 14 | Err(err) => Ok(reply_error(err)), 15 | } 16 | } 17 | 18 | #[cfg(test)] 19 | mod tests { 20 | use super::*; 21 | use crate::tests::{add_thought_to_db, app_api, blank_db, response_json_body}; 22 | use serde_json::Value; 23 | use warp::{http::StatusCode, Reply}; 24 | 25 | #[tokio::test] 26 | async fn read_all() { 27 | let db = blank_db(); 28 | add_thought_to_db(&db, "foo"); 29 | add_thought_to_db(&db, "bar"); 30 | 31 | let api = app_api(db.clone()); 32 | let res = handle(api).await.unwrap().into_response(); 33 | 34 | assert_eq!(res.status(), StatusCode::OK); 35 | 36 | let body: Value = response_json_body(res).await.unwrap(); 37 | let thoughts = body.as_array().unwrap(); 38 | 39 | assert_eq!(thoughts.len(), 2); 40 | 41 | let t = thoughts[0].as_object().unwrap(); 42 | 43 | assert!(t.get("title").unwrap().is_string()); 44 | assert!(t.get("id").unwrap().is_number()); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /crates/web-server-warp/src/handler/thought/update.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | handler::{reply_error, reply_json, Result}, 3 | AppApi, 4 | }; 5 | use cawr_adapter::{db::Db, model::view::json::thought::update::Request}; 6 | use warp::Reply; 7 | 8 | pub async fn handle(id: String, req: Request, api: AppApi) -> Result 9 | where 10 | D: Db, 11 | { 12 | let areas_of_life = req 13 | .areas_of_life 14 | .into_iter() 15 | .map(|id| id.0.to_string()) 16 | .collect(); 17 | match api.update_thought(&id, req.title, &areas_of_life) { 18 | Ok(res) => Ok(reply_json(&res.data, res.status)), 19 | Err(err) => Ok(reply_error(err)), 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /crates/web-server-warp/src/lib.rs: -------------------------------------------------------------------------------- 1 | use cawr_adapter::{api::Api, db::Db, presenter::http_json_api::Presenter}; 2 | use std::{net::SocketAddr, sync::Arc}; 3 | use warp::Filter; 4 | 5 | mod handler; 6 | mod route; 7 | #[cfg(test)] 8 | mod tests; 9 | mod webapp; 10 | 11 | type AppApi = Api; 12 | 13 | pub async fn run(db: Arc, addr: SocketAddr) 14 | where 15 | D: Db, 16 | { 17 | let web_app_api = Api::new(db, Presenter); 18 | let api = route::api(web_app_api); 19 | let routes = api.or(webapp::get_index()).or(webapp::get_assets()); 20 | warp::serve(routes).run(addr).await; 21 | } 22 | -------------------------------------------------------------------------------- /crates/web-server-warp/src/route.rs: -------------------------------------------------------------------------------- 1 | use crate::{handler, AppApi}; 2 | use cawr_adapter::db::Db; 3 | use std::convert::Infallible; 4 | use warp::{body, path, Filter, Rejection, Reply}; 5 | 6 | pub fn api(app: AppApi) -> impl Filter + Clone 7 | where 8 | D: Db, 9 | { 10 | // POST /api/thought 11 | let post_thought = warp::post() 12 | .and(path::end()) 13 | .and(body::json()) 14 | .and(with_app(app.clone())) 15 | .and_then(handler::thought::create::handle); 16 | 17 | // PUT /api/thought/ 18 | let put_thought = warp::put() 19 | .and(path!(String)) 20 | .and(path::end()) 21 | .and(body::json()) 22 | .and(with_app(app.clone())) 23 | .and_then(handler::thought::update::handle); 24 | 25 | // GET /api/thought 26 | let get_thoughts = warp::get() 27 | .and(path::end()) 28 | .and(with_app(app.clone())) 29 | .and_then(handler::thought::read_all::handle); 30 | 31 | // GET /api/thought/ 32 | let get_thought = warp::get() 33 | .and(path!(String)) 34 | .and(path::end()) 35 | .and(with_app(app.clone())) 36 | .and_then(handler::thought::find_by_id::handle); 37 | 38 | // DELETE /api/thought/ 39 | let delete_thought = warp::delete() 40 | .and(path!(String)) 41 | .and(path::end()) 42 | .and(with_app(app.clone())) 43 | .and_then(handler::thought::delete::handle); 44 | 45 | // POST /api/area-of-life 46 | let post_area_of_life = warp::post() 47 | .and(path::end()) 48 | .and(body::json()) 49 | .and(with_app(app.clone())) 50 | .and_then(handler::area_of_life::create::handle); 51 | 52 | // PUT /api/area-of-life/ 53 | let put_area_of_life = warp::put() 54 | .and(path!(String)) 55 | .and(path::end()) 56 | .and(body::json()) 57 | .and(with_app(app.clone())) 58 | .and_then(handler::area_of_life::update::handle); 59 | 60 | // GET /api/area-of-life 61 | let get_areas_of_life = warp::get() 62 | .and(path::end()) 63 | .and(with_app(app.clone())) 64 | .and_then(handler::area_of_life::read_all::handle); 65 | 66 | // DELETE /api/area-of-life/ 67 | let delete_area_of_life = warp::delete() 68 | .and(path!(String)) 69 | .and(path::end()) 70 | .and(with_app(app)) 71 | .and_then(handler::area_of_life::delete::handle); 72 | 73 | let base_path = path("api"); 74 | let thought = path("thought").and( 75 | post_thought 76 | .or(put_thought) 77 | .or(get_thoughts) 78 | .or(get_thought) 79 | .or(delete_thought), 80 | ); 81 | let area_of_life = path("area-of-life").and( 82 | post_area_of_life 83 | .or(put_area_of_life) 84 | .or(get_areas_of_life) 85 | .or(delete_area_of_life), 86 | ); 87 | base_path.and(thought.or(area_of_life)) 88 | } 89 | 90 | fn with_app(app: AppApi) -> impl Filter,), Error = Infallible> + Clone 91 | where 92 | C: Send + Sync, 93 | { 94 | warp::any().map(move || app.clone()) 95 | } 96 | -------------------------------------------------------------------------------- /crates/web-server-warp/src/tests.rs: -------------------------------------------------------------------------------- 1 | use crate::AppApi; 2 | use anyhow::Result; 3 | use cawr_adapter::{api::Api, db::Db, presenter::http_json_api::Presenter}; 4 | use cawr_application::{ 5 | gateway::repository::thought::Record as ThoughtRecord, 6 | identifier::{NewId, NewIdError}, 7 | }; 8 | use cawr_db::in_memory::InMemory; 9 | use serde::Deserialize; 10 | use std::sync::Arc; 11 | use warp::reply::Response; 12 | 13 | pub fn blank_db() -> Arc { 14 | Arc::new(InMemory::default()) 15 | } 16 | 17 | pub fn corrupt_db() -> Arc { 18 | Arc::new(CorruptTestDb) 19 | } 20 | 21 | pub const fn app_api(db: Arc) -> AppApi 22 | where 23 | D: Db, 24 | { 25 | Api::new(db, Presenter) 26 | } 27 | 28 | #[derive(Default)] 29 | pub struct CorruptTestDb; 30 | 31 | impl Db for CorruptTestDb {} 32 | 33 | mod thought { 34 | use super::*; 35 | use cawr_application::gateway::repository::thought::{self as repo, Record, Repo}; 36 | use cawr_domain::thought::Id; 37 | 38 | impl Repo for CorruptTestDb { 39 | fn save(&self, _: Record) -> Result<(), repo::SaveError> { 40 | Err(repo::SaveError::Connection) 41 | } 42 | fn get(&self, _: Id) -> Result { 43 | Err(repo::GetError::Connection) 44 | } 45 | fn get_all(&self) -> Result, repo::GetAllError> { 46 | Err(repo::GetAllError::Connection) 47 | } 48 | fn delete(&self, _: Id) -> Result<(), repo::DeleteError> { 49 | Err(repo::DeleteError::Connection) 50 | } 51 | } 52 | 53 | impl NewId for CorruptTestDb { 54 | fn new_id(&self) -> Result { 55 | Err(NewIdError) 56 | } 57 | } 58 | } 59 | 60 | mod area_of_life { 61 | use super::*; 62 | use cawr_application::gateway::repository::area_of_life::{self as repo, Record, Repo}; 63 | use cawr_domain::area_of_life::Id; 64 | 65 | impl Repo for CorruptTestDb { 66 | fn save(&self, _: Record) -> Result<(), repo::SaveError> { 67 | Err(repo::SaveError::Connection) 68 | } 69 | fn get(&self, _: Id) -> Result { 70 | Err(repo::GetError::Connection) 71 | } 72 | fn get_all(&self) -> Result, repo::GetAllError> { 73 | Err(repo::GetAllError::Connection) 74 | } 75 | fn delete(&self, _: Id) -> Result<(), repo::DeleteError> { 76 | Err(repo::DeleteError::Connection) 77 | } 78 | } 79 | 80 | impl NewId for CorruptTestDb { 81 | fn new_id(&self) -> Result { 82 | Err(NewIdError) 83 | } 84 | } 85 | } 86 | 87 | pub async fn response_json_body(mut res: Response) -> Result 88 | where 89 | for<'de> T: Deserialize<'de>, 90 | { 91 | let body = res.body_mut(); 92 | let bytes = hyper::body::to_bytes(body).await?; 93 | let json = serde_json::from_slice(&bytes)?; 94 | Ok(json) 95 | } 96 | 97 | pub fn add_thought_to_db(db: &Arc, title: &str) { 98 | use cawr_application::gateway::repository::thought::Repo; 99 | use cawr_domain::thought::*; 100 | use std::collections::HashSet; 101 | let thought = Thought::new( 102 | db.new_id().unwrap(), 103 | Title::new(title.to_string()), 104 | HashSet::new(), 105 | ); 106 | let thought = ThoughtRecord { thought }; 107 | db.as_ref().save(thought).unwrap(); 108 | } 109 | -------------------------------------------------------------------------------- /crates/web-server-warp/src/webapp.rs: -------------------------------------------------------------------------------- 1 | use rust_embed::RustEmbed; 2 | use warp::{http::HeaderValue, hyper::header::CONTENT_TYPE, path::Tail, Filter, Rejection, Reply}; 3 | 4 | #[derive(RustEmbed)] 5 | #[folder = "../web-app/dist/"] 6 | struct Asset; 7 | 8 | pub fn get_index() -> impl Filter + Clone { 9 | let index = warp::path("index.html") 10 | .and(warp::path::end()) 11 | .or(warp::path::end()); 12 | warp::get().and(index).and_then(|_| serve_index()) 13 | } 14 | 15 | pub fn get_assets() -> impl Filter + Clone { 16 | warp::get().and(warp::path::tail()).and_then(serve) 17 | } 18 | 19 | async fn serve_index() -> Result { 20 | serve_impl("index.html") 21 | } 22 | 23 | async fn serve(path: Tail) -> Result { 24 | serve_impl(path.as_str()) 25 | } 26 | 27 | fn serve_impl(path: &str) -> Result { 28 | let asset = Asset::get(path).ok_or_else(warp::reject::not_found)?; 29 | let mime = mime_guess::from_path(path).first_or_octet_stream(); 30 | let mut res = warp::reply::Response::new(asset.data.into()); 31 | match HeaderValue::from_str(mime.as_ref()) { 32 | Ok(mime) => { 33 | res.headers_mut().insert(CONTENT_TYPE, mime); 34 | } 35 | Err(_) => { 36 | log::warn!("Unexpected content type: {}", mime); 37 | } 38 | } 39 | Ok(res) 40 | } 41 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | # just manual: https://github.com/casey/just/#readme 2 | 3 | _default: 4 | @just --list 5 | 6 | # Installs the trunk packager 7 | install-trunk: 8 | cargo install trunk 9 | 10 | # Run the web server 11 | run-web: web-app 12 | cargo run --bin clean-architecture-with-rust-web 13 | 14 | # Run the CLI 15 | run-cli: 16 | cargo run --bin clean-architecture-with-rust-cli 17 | 18 | # Run the desktop app 19 | run-desktop: 20 | cargo run --bin clean-architecture-with-rust-desktop 21 | 22 | # Build the web server 23 | build-web: web-app 24 | cargo build --bin clean-architecture-with-rust-web --release 25 | 26 | # Build the CLI 27 | build-cli: 28 | cargo build --bin clean-architecture-with-rust-cli --release 29 | 30 | # Build the desktop app 31 | build-desktop: 32 | cargo build --bin clean-architecture-with-rust-desktop --release 33 | 34 | # Build the web app 35 | web-app: 36 | cd crates/web-app/ && trunk build 37 | 38 | # Read version from Cargo.toml 39 | pkg-version := `sed -En 's/version[[:space:]]*=[[:space:]]*"([^"]+)"/v\1/p' Cargo.toml | head -1` 40 | 41 | # Create a tarball with the webserver 42 | pack-web: build-web 43 | tar -C target/release/ \ 44 | -cvpJf clean-architecture-with-rust-web_{{pkg-version}}.tar.xz \ 45 | clean-architecture-with-rust-web 46 | 47 | # Create a tarball with the CLI 48 | pack-cli: build-cli 49 | tar -C target/release/ \ 50 | -cvpJf clean-architecture-with-rust-cli_{{pkg-version}}.tar.xz \ 51 | clean-architecture-with-rust-cli 52 | 53 | # Format source code 54 | fmt: 55 | cargo fmt --all 56 | cd crates/web-app/ && cargo fmt --all 57 | 58 | # Run clippy linter 59 | clippy: 60 | cargo clippy --workspace --fix --allow-dirty --allow-staged 61 | cargo fix --workspace --allow-dirty --allow-staged 62 | cd crates/web-app/ && cargo clippy --workspace --fix --allow-dirty --allow-staged 63 | cd crates/web-app/ && cargo fix --workspace --allow-dirty --allow-staged 64 | 65 | # Fix lint warnings 66 | fix: 67 | cargo fix --workspace --all-targets 68 | cargo clippy --workspace --all-targets --fix 69 | cd crates/web-app && cargo fix --workspace --all-targets 70 | cd crates/web-app && cargo clippy --workspace --all-targets --fix 71 | 72 | # Run tests 73 | test: 74 | RUST_BACKTRACE=1 cargo test --locked --workspace -- --nocapture 75 | RUST_BACKTRACE=1 cargo test --locked --workspace --manifest-path crates/web-app/Cargo.toml -- --nocapture 76 | RUST_BACKTRACE=1 wasm-pack test --chrome --headless crates/web-app/ 77 | 78 | # Upgrade (and update) dependencies and tools 79 | upgrade: 80 | cargo upgrade --incompatible 81 | cargo update 82 | cd crates/web-app && cargo upgrade --incompatible 83 | cd crates/web-app && cargo update 84 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | let 2 | moz_overlay = import (builtins.fetchTarball https://github.com/mozilla/nixpkgs-mozilla/archive/master.tar.gz); 3 | pkgs = import { overlays = [ moz_overlay ]; }; 4 | rustChannel = pkgs.rustChannelOf { 5 | channel = "stable"; 6 | }; 7 | rust = (rustChannel.rust.override { 8 | targets = [ 9 | "wasm32-unknown-unknown" # required for the web-app 10 | ]; 11 | }); 12 | 13 | # required for the desktop 14 | runtime_deps = with pkgs; [ 15 | libGL 16 | libxkbcommon 17 | wayland 18 | ] ++ (with pkgs.xorg; [ 19 | libX11 20 | libXcursor 21 | libXrandr 22 | libXi 23 | ]); 24 | 25 | in 26 | with pkgs; 27 | mkShell { 28 | buildInputs = [ 29 | 30 | rust 31 | just 32 | trunk 33 | 34 | pkg-config 35 | 36 | # required for the web-app 37 | dart-sass 38 | wasm-pack 39 | 40 | # required for the desktop 41 | freetype 42 | expat 43 | fontconfig 44 | ]; 45 | 46 | LD_LIBRARY_PATH = "${lib.makeLibraryPath runtime_deps}"; 47 | } 48 | -------------------------------------------------------------------------------- /src/bin/cli.rs: -------------------------------------------------------------------------------- 1 | use cawr_infrastructure::{cli, logger}; 2 | 3 | pub fn main() { 4 | logger::init_default_logger(); 5 | cli::run(); 6 | } 7 | -------------------------------------------------------------------------------- /src/bin/desktop.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release 2 | 3 | use cawr_infrastructure::{desktop, logger}; 4 | use std::error::Error; 5 | 6 | pub fn main() -> Result<(), Box> { 7 | logger::init_default_logger(); 8 | Ok(desktop::run()?) 9 | } 10 | -------------------------------------------------------------------------------- /src/bin/web.rs: -------------------------------------------------------------------------------- 1 | use cawr_infrastructure::{logger, web}; 2 | 3 | pub fn main() { 4 | logger::init_default_logger(); 5 | web::run(); 6 | } 7 | --------------------------------------------------------------------------------