geeks_event_sourcing 0.3.1

Event sourcing crate for geeks productions.
Documentation
use std::collections::HashMap;
use std::io;
use std::path::{Path, PathBuf};
use std::str::{from_utf8, Utf8Error};

use async_trait::async_trait;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use serde_json::{from_str, to_value};
use tokio::fs::{read, write};

use crate::{Aggregate, AggregateRoot, Command, Event, Snapshot, Timestamp, Version};

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "name")]
pub enum TodoEvent {
  TodoCreated {
    id: String,
    title: String,
    status: TodoStatus,
  },
  TodoTitleUpdated {
    title: String,
  },
  TodoStatusUpdated {
    status: TodoStatus,
  },
}

impl Event for TodoEvent {
  fn name(&self) -> &'static str {
    match self {
      TodoEvent::TodoCreated { .. } => "TodoCreated",
      TodoEvent::TodoTitleUpdated { .. } => "TodoTitleUpdated",
      TodoEvent::TodoStatusUpdated { .. } => "TodoStatusUpdated",
    }
  }
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "name")]
pub enum TodoCommand {
  CreateTodo {
    id: String,
    title: String,
    status: Option<TodoStatus>,
  },
  UpdateTodoTitle {
    id: String,
    title: String,
  },
  UpdateTodoStatus {
    id: String,
    status: TodoStatus,
  },
}

impl Command for TodoCommand {
  fn name(&self) -> &'static str {
    match self {
      TodoCommand::CreateTodo { .. } => "CreateTodo",
      TodoCommand::UpdateTodoTitle { .. } => "UpdateTodoTitle",
      TodoCommand::UpdateTodoStatus { .. } => "UpdateTodoStatus",
    }
  }

  fn aggregate_id(&self) -> &str {
    match self {
      TodoCommand::CreateTodo { id, .. } => id,
      TodoCommand::UpdateTodoTitle { id, .. } => id,
      TodoCommand::UpdateTodoStatus { id, .. } => id,
    }
  }
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum TodoStatus {
  #[serde(rename = "todo")]
  Todo,
  #[serde(rename = "in-progress")]
  InProgress,
  #[serde(rename = "done")]
  Done,
}

#[derive(thiserror::Error, Debug, PartialEq, Eq, Clone)]
pub enum TodoError {
  #[error("Todo already exists")]
  AlreadyExists,
  #[error("Todo not exists")]
  NotExists,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Todo {
  pub id: String,
  pub title: String,
  pub status: TodoStatus,
  pub created_at: Timestamp,
  pub updated_at: Timestamp,
}

impl Aggregate for Todo {
  type Command = TodoCommand;
  type Event = TodoEvent;
  type Error = TodoError;

  fn id(&self) -> &str {
    &self.id
  }

  fn handle_command(
    this: Option<&Self>,
    command: Self::Command,
  ) -> Result<Self::Event, Self::Error> {
    match command {
      TodoCommand::CreateTodo { id, title, status } => {
        if this.is_some() {
          return Err(TodoError::AlreadyExists);
        }

        Ok(TodoEvent::TodoCreated {
          id,
          title,
          status: status.unwrap_or(TodoStatus::Todo),
        })
      }
      TodoCommand::UpdateTodoTitle { title, .. } => Ok(TodoEvent::TodoTitleUpdated { title }),
      TodoCommand::UpdateTodoStatus { status, .. } => Ok(TodoEvent::TodoStatusUpdated { status }),
    }
  }

  fn apply_event(this: Option<Self>, event: Self::Event) -> Result<Self, Self::Error> {
    let timestamp = Utc::now().timestamp();

    match event {
      TodoEvent::TodoCreated { id, title, status } => {
        if this.is_some() {
          return Err(TodoError::AlreadyExists);
        }
        Ok(Todo {
          id,
          title,
          status,
          created_at: timestamp,
          updated_at: timestamp,
        })
      }
      TodoEvent::TodoTitleUpdated { title } => match this {
        Some(mut todo) => {
          todo.title = title;
          Ok(todo)
        }
        None => Err(TodoError::NotExists),
      },
      TodoEvent::TodoStatusUpdated { status } => match this {
        Some(mut todo) => {
          todo.status = status;
          Ok(todo)
        }
        None => Err(TodoError::NotExists),
      },
    }
  }
}

pub struct TodoSnapshot {
  file_path: PathBuf,
}

impl TodoSnapshot {
  pub fn new(dir: &Path) -> Self {
    Self {
      file_path: dir.join("todo"),
    }
  }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
struct TodoSnapshotData {
  states: HashMap<String, Todo>,
  versions: HashMap<String, Version>,
}

impl From<AggregateRoot<Todo>> for TodoSnapshotData {
  fn from(root: AggregateRoot<Todo>) -> Self {
    Self {
      states: root.states,
      versions: root.versions,
    }
  }
}

#[derive(thiserror::Error, Debug)]
pub enum TodoSnapshotError {
  #[error("io error: {0}")]
  IoError(#[source] io::Error),

  #[error("utf8 error: {0}")]
  Utf8Error(#[source] Utf8Error),

  #[error("json parse error: {0}")]
  JsonParseError(#[source] serde_json::Error),
}

#[async_trait]
impl Snapshot<Todo> for TodoSnapshot {
  type Error = TodoSnapshotError;

  async fn load(&self) -> Result<AggregateRoot<Todo>, Self::Error> {
    let raw = read(&self.file_path)
      .await
      .map_err(TodoSnapshotError::IoError)?;
    let raw_str = from_utf8(&raw).map_err(TodoSnapshotError::Utf8Error)?;
    let data = from_str::<TodoSnapshotData>(raw_str).map_err(TodoSnapshotError::JsonParseError)?;

    Ok(AggregateRoot::new(data.states, data.versions))
  }

  async fn save(&self, root: AggregateRoot<Todo>) -> Result<(), Self::Error> {
    let data: TodoSnapshotData = root.into();
    let raw_data = to_value(data).map_err(TodoSnapshotError::JsonParseError)?;
    write(&self.file_path, raw_data.to_string().as_bytes())
      .await
      .map_err(TodoSnapshotError::IoError)?;

    Ok(())
  }
}