Skip to content

feat(examples): add a todo-list app #252

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ members = [

# Crates as examples
"examples/bank-accounting",
"examples/todo-list-app",
]
2 changes: 2 additions & 0 deletions eventually/src/command/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ where
where
F: Fn(event::store::Tracking<event::store::InMemory<Id, Evt>, Id, Evt>) -> H,
H: command::Handler<Cmd>,
<H as command::Handler<Cmd>>::Error: Debug,
{
let event_store = event::store::InMemory::<Id, Evt>::default();
let tracking_event_store = event_store.clone().with_recorded_events_tracking();
Expand All @@ -159,6 +160,7 @@ where
ScenarioThenCase::Produces(events) => {
let recorded_events = tracking_event_store.recorded_events();
assert_eq!(events, recorded_events);
result.expect("should not be an error");
}
ScenarioThenCase::Fails => assert!(result.is_err()),
};
Expand Down
33 changes: 33 additions & 0 deletions examples/todo-list-app/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
[package]
name = "todo-list-app"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
anyhow = "1.0.69"
async-trait = "0.1.65"
eventually = { path = "../../eventually", features = ["serde-prost", "tracing"] }
eventually-macros = { path = "../../eventually-macros" }
opentelemetry = { version = "0.18.0", features = ["rt-tokio"] }
opentelemetry-jaeger = { version = "0.17.0", features = ["rt-tokio"] }
prost = "0.11.8"
prost-types = "0.11.8"
thiserror = "1.0.38"
tokio = { version = "1.13.0", features = ["macros", "rt-multi-thread"] }
tonic = { version = "0.8.1", features = ["gzip", "transport"] }
tonic-health = "0.8.0"
tonic-reflection = "0.6.0"
tower = "0.4.11"
tower-http = { version = "0.4.0", features = ["trace"] }
tracing = "0.1.29"
tracing-opentelemetry = "0.18.0"
tracing-subscriber = { version = "0.3.3", features = ["fmt", "std", "registry", "env-filter"] }
uuid = { version = "1.3.0", features = ["v4", "fast-rng"] }

[dev-dependencies]
lazy_static = "1.4.0"

[build-dependencies]
tonic-build = { version = "0.8.0", features = ["prost"] }
18 changes: 18 additions & 0 deletions examples/todo-list-app/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
use std::{env, path::PathBuf};

fn main() {
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());

tonic_build::configure()
.file_descriptor_set_path(out_dir.join("todolist.v1_descriptor.bin"))
.build_server(true)
.build_client(false)
.compile(
&[
"proto/todolist/v1/todo_list.proto",
"proto/todolist/v1/todo_list_api.proto",
],
&["proto"],
)
.unwrap();
}
7 changes: 7 additions & 0 deletions examples/todo-list-app/proto/buf.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Generated by buf. DO NOT EDIT.
version: v1
deps:
- remote: buf.build
owner: googleapis
repository: googleapis
commit: 75b4300737fb4efca0831636be94e517
12 changes: 12 additions & 0 deletions examples/todo-list-app/proto/buf.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
version: v1
deps:
- buf.build/googleapis/googleapis
breaking:
use:
- FILE
lint:
use:
- DEFAULT
# - COMMENTS
- UNARY_RPC
- PACKAGE_NO_IMPORT_CYCLE
19 changes: 19 additions & 0 deletions examples/todo-list-app/proto/todolist/v1/todo_list.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
syntax = "proto3";

package todolist.v1;

import "google/protobuf/timestamp.proto";

message TodoItem {
string description = 1;
bool completed = 2;
google.protobuf.Timestamp due_date = 3;
google.protobuf.Timestamp creation_time = 4;
}

message TodoList {
string title = 1;
string owner = 2;
map<string, TodoItem> items = 3;
google.protobuf.Timestamp creation_time = 4;
}
55 changes: 55 additions & 0 deletions examples/todo-list-app/proto/todolist/v1/todo_list_api.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
syntax = "proto3";

package todolist.v1;

import "google/protobuf/timestamp.proto";
import "todolist/v1/todo_list.proto";

service TodoListService {
rpc CreateTodoList(CreateTodoListRequest) returns (CreateTodoListResponse);
rpc GetTodoList(GetTodoListRequest) returns (GetTodoListResponse);
rpc AddTodoItem(AddTodoItemRequest) returns (AddTodoItemResponse);
rpc ToggleTodoItem(ToggleTodoItemRequest) returns (ToggleTodoItemResponse);
rpc DeleteTodoItem(DeleteTodoItemRequest) returns (DeleteTodoItemResponse);
}

message CreateTodoListRequest {
string title = 1;
string owner = 2;
}

message CreateTodoListResponse {
string todo_list_id = 1;
}

message GetTodoListRequest {
string todo_list_id = 1;
}

message GetTodoListResponse {
TodoList todo_list = 1;
}

message AddTodoItemRequest {
string todo_list_id = 1;
string description = 2;
google.protobuf.Timestamp due_date = 3;
}

message AddTodoItemResponse {
string todo_item_id = 1;
}

message ToggleTodoItemRequest {
string todo_list_id = 1;
string todo_item_id = 2;
}

message ToggleTodoItemResponse {}

message DeleteTodoItemRequest {
string todo_list_id = 1;
string todo_item_id = 2;
}

message DeleteTodoItemResponse {}
202 changes: 202 additions & 0 deletions examples/todo-list-app/src/command/add_todo_list_item.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
use std::{sync::Arc, time::Instant};

use async_trait::async_trait;
use eventually::{aggregate, command, message::Message};

use crate::domain::todo;

#[derive(Debug, Clone, Eq, PartialEq)]
pub struct Command {
pub todo_list_id: String,
pub todo_item_id: String,
pub title: String,
pub description: Option<String>,
pub due_date: Option<Instant>,
}

impl Message for Command {
fn name(&self) -> &'static str {
"AddTodoListItem"
}
}

#[derive(Debug, Clone)]
pub struct Handler<R> {
clock: Arc<fn() -> Instant>,
repository: R,
}

impl<R> Handler<R>
where
R: aggregate::repository::Repository<todo::list::List>,
{
pub fn new(clock: fn() -> Instant, repository: R) -> Handler<R> {
Self {
clock: Arc::new(clock),
repository,
}
}
}

#[derive(Debug, thiserror::Error)]
pub enum Error<GetErr, SaveErr> {
#[error("failed to create add new item to todo::List: {0}")]
AddItem(#[source] todo::list::Error),
#[error("failed to get the todo::List from repository: {0}")]
Get(#[source] aggregate::repository::GetError<GetErr>),
#[error("failed to save new todo::List to repository: {0}")]
Save(#[source] SaveErr),
}

#[async_trait]
impl<R> command::Handler<Command> for Handler<R>
where
R: aggregate::repository::Repository<todo::list::List>,
{
type Error = Error<
<R as aggregate::repository::Getter<todo::list::List>>::Error,
<R as aggregate::repository::Saver<todo::list::List>>::Error,
>;

async fn handle(&self, envelope: command::Envelope<Command>) -> Result<(), Self::Error> {
let cmd = envelope.message;
let now = (self.clock)();

let mut todo_list = self
.repository
.get(&cmd.todo_list_id)
.await
.map(todo::list::ListRoot::from)
.map_err(Error::Get)?;

todo_list
.add_item(todo::list::AddItem {
id: cmd.todo_item_id,
title: cmd.title,
description: cmd.description,
due_date: cmd.due_date,
now,
})
.map_err(Error::AddItem)?;

self.repository
.save(&mut todo_list)
.await
.map_err(Error::Save)?;

Ok(())
}
}

#[cfg(test)]
mod test {
use std::time::Instant;

use eventually::{aggregate, command, event};
use lazy_static::lazy_static;

use crate::{command::add_todo_list_item, domain::todo};

lazy_static! {
static ref TEST_TODO_LIST_ID: String = String::from("new-todo-list");
static ref TEST_TITLE: String = String::from("my-list");
static ref TEST_OWNER: String = String::from("[email protected]");
static ref TEST_TODO_ITEM_ID: String = String::from("new-todo-item");
static ref TEST_TODO_ITEM_TITLE: String = String::from("do something please");
static ref NOW: Instant = Instant::now();
static ref CLOCK: fn() -> Instant = || *NOW;
}

#[tokio::test]
async fn it_works() {
command::test::Scenario
.given(vec![event::Persisted {
stream_id: TEST_TODO_LIST_ID.clone(),
version: 1,
event: todo::list::Event::WasCreated(todo::list::WasCreated {
id: TEST_TODO_LIST_ID.clone(),
title: TEST_TITLE.clone(),
owner: TEST_OWNER.clone(),
creation_time: *NOW,
})
.into(),
}])
.when(
add_todo_list_item::Command {
todo_list_id: TEST_TODO_LIST_ID.clone(),
todo_item_id: TEST_TODO_ITEM_ID.clone(),
title: TEST_TITLE.clone(),
description: None,
due_date: None,
}
.into(),
)
.then(vec![event::Persisted {
stream_id: TEST_TODO_LIST_ID.clone(),
version: 2,
event: todo::list::Event::ItemWasAdded(todo::item::WasAdded {
id: TEST_TODO_ITEM_ID.clone(),
title: TEST_TITLE.clone(),
description: None,
due_date: None,
creation_time: *NOW,
})
.into(),
}])
.assert_on(|event_store| {
add_todo_list_item::Handler::new(
*CLOCK,
aggregate::EventSourcedRepository::from(event_store),
)
})
.await;
}

#[tokio::test]
async fn it_fails_when_adding_twice_the_same_todo_item() {
command::test::Scenario
.given(vec![
event::Persisted {
stream_id: TEST_TODO_LIST_ID.clone(),
version: 1,
event: todo::list::Event::WasCreated(todo::list::WasCreated {
id: TEST_TODO_LIST_ID.clone(),
title: TEST_TITLE.clone(),
owner: TEST_OWNER.clone(),
creation_time: *NOW,
})
.into(),
},
event::Persisted {
stream_id: TEST_TODO_LIST_ID.clone(),
version: 2,
event: todo::list::Event::ItemWasAdded(todo::item::WasAdded {
id: TEST_TODO_ITEM_ID.clone(),
title: TEST_TITLE.clone(),
description: None,
due_date: None,
creation_time: *NOW,
})
.into(),
},
])
.when(
add_todo_list_item::Command {
todo_list_id: TEST_TODO_LIST_ID.clone(),
todo_item_id: TEST_TODO_ITEM_ID.clone(),
title: TEST_TITLE.clone(),
description: None,
due_date: None,
}
.into(),
)
.then_fails()
.assert_on(|event_store| {
add_todo_list_item::Handler::new(
*CLOCK,
aggregate::EventSourcedRepository::from(event_store),
)
})
.await;
}
}
Loading