diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000..4615b8f --- /dev/null +++ b/src/model.rs @@ -0,0 +1,21 @@ +use serde::{Serialize, Deserialize}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Suggestion { + pub author: Option, + pub contact: Option, + pub body: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Acknowledgement { + Sent(String), + Refused(String), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PageOptions { + pub offset: Option, + pub limit: Option, +} + diff --git a/src/notifications/console.rs b/src/notifications/console.rs new file mode 100644 index 0000000..c4c64cc --- /dev/null +++ b/src/notifications/console.rs @@ -0,0 +1,22 @@ +use crate::model::Suggestion; + +use super::NotificationProcessor; + + +pub struct ConsoleTracingNotifier {} + +#[async_trait::async_trait] +impl NotificationProcessor for ConsoleTracingNotifier { + async fn process(&self, suggestion: &Suggestion) { + tracing::info!(" >> {:?}", suggestion); + } +} + +pub struct ConsolePrettyNotifier {} + +#[async_trait::async_trait] +impl NotificationProcessor for ConsolePrettyNotifier { + async fn process(&self, suggestion: &Suggestion) { + println!("{} -- {} <{}>", suggestion.body, suggestion.author.as_deref().unwrap_or("anon"), suggestion.contact.as_deref().unwrap_or("")); + } +} diff --git a/src/routes.rs b/src/routes.rs new file mode 100644 index 0000000..00566e0 --- /dev/null +++ b/src/routes.rs @@ -0,0 +1,61 @@ +use std::sync::Arc; + +use axum::{http::StatusCode, Json, Form, Router, routing::{put, post, get}, extract::{State, Query}, response::IntoResponse}; +use tokio::sync::RwLock; + +use crate::{notifications::NotificationProcessor, model::{Suggestion, Acknowledgement, PageOptions}, storage::StorageStrategy}; + +pub fn create_router_with_app_routes(state: Context) -> Router { + Router::new() + .route("/send", get(get_suggestion)) + .route("/send", post(send_suggestion_form)) + .route("/send", put(send_suggestion_json)) + .with_state(Arc::new(RwLock::new(state))) +} + +type SafeContext = Arc>; + +pub struct Context { + providers: Vec>>, + storage: Box>, +} + +impl Context { + pub fn new(storage: Box>) -> Self { + Context { providers: Vec::new(), storage } + } + + pub fn register(mut self, notifier: Box>) -> Self { + self.providers.push(notifier); + self + } + + async fn process(&self, x: &Suggestion) { + for p in self.providers.iter() { + p.process(x).await; + } + } +} + +async fn send_suggestion(payload: Suggestion, state: SafeContext) -> impl IntoResponse { + let mut lock = state.write().await; + lock.process(&payload).await; + match lock.storage.archive(payload).await { + Ok(()) => (StatusCode::OK, Json(Acknowledgement::Sent("".into()))), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(Acknowledgement::Refused(e.to_string()))), + } +} + +async fn send_suggestion_json(State(state): State, Json(payload): Json) -> impl IntoResponse { send_suggestion(payload, state).await } +async fn send_suggestion_form(State(state): State, Form(payload): Form) -> impl IntoResponse { send_suggestion(payload, state).await } + + +async fn get_suggestion(State(state): State, Query(page): Query) -> Result>, String> { + let offset = page.offset.unwrap_or(0); + let limit = std::cmp::min(page.limit.unwrap_or(20), 20); + + match state.read().await.storage.extract(offset, limit).await { + Ok(x) => Ok(Json(x)), + Err(e) => Err(e.to_string()), + } +} diff --git a/src/storage.rs b/src/storage.rs new file mode 100644 index 0000000..7c71e95 --- /dev/null +++ b/src/storage.rs @@ -0,0 +1,53 @@ +use crate::model::Suggestion; + + +#[derive(Debug, thiserror::Error)] +pub enum StorageStrategyError { + #[error("could not interact with filesystem: {0}")] + IOError(#[from] std::io::Error), + #[error("could not serialize/deserialize data: {0}")] + JsonSerializeError(#[from] serde_json::Error), +} + +#[async_trait::async_trait] +pub trait StorageStrategy : Send + Sync { + async fn archive(&mut self, payload: T) -> Result<(), StorageStrategyError>; + async fn extract(&self, offset: usize, window: usize) -> Result, StorageStrategyError>; +} + + +/// this strategy is rather inefficient since it has to iterate the whole file every time, but it +/// requires literally zero effort +pub struct JsonFileStorageStrategy { + path: String, +} + +impl JsonFileStorageStrategy { + pub fn new(path: &str) -> Self { + JsonFileStorageStrategy { path: path.to_string() } + } +} + + +#[async_trait::async_trait] +impl StorageStrategy for JsonFileStorageStrategy { + async fn archive(&mut self, payload: Suggestion) -> Result<(), StorageStrategyError> { + let file_content = std::fs::read_to_string(&self.path)?; + let mut current_content : Vec = serde_json::from_str(&file_content)?; + current_content.push(payload); + let updated_content = serde_json::to_string(¤t_content)?; + std::fs::write(&self.path, updated_content)?; + Ok(()) + } + + async fn extract(&self, offset: usize, window: usize) -> Result, StorageStrategyError> { + let file_content = std::fs::read_to_string(&self.path)?; + let current_content : Vec = serde_json::from_str(&file_content)?; + let mut out = Vec::new(); + for sugg in current_content.iter().rev().skip(offset) { + out.push(sugg.clone()); + if out.len() >= window { break }; + } + Ok(out) + } +}