diff --git a/src/routes/category.rs b/src/routes/category.rs index a2e1669..b36ff76 100644 --- a/src/routes/category.rs +++ b/src/routes/category.rs @@ -2,17 +2,17 @@ use askama::Template; use askama_web::WebTemplate; use axum::Form; use axum::extract::Path; -use axum::response::{IntoResponse, Redirect}; use axum_extra::extract::CookieJar; use serde::{Deserialize, Serialize}; use snafu::prelude::*; use crate::database::category; -use crate::database::category::CategoryError; use crate::database::content_folder::PathBreadcrumb; use crate::extractors::normalized_path::*; use crate::filesystem::FileSystemEntry; -use crate::state::flash_message::{OperationStatus, get_cookie}; +use crate::state::flash_message::{ + FallibleTemplate, FlashRedirect, FlashTemplate, OperationStatus, StatusCookie, +}; use crate::state::{AppStateContext, error::*}; #[derive(Clone, Debug, Deserialize, Serialize)] @@ -26,73 +26,50 @@ pub struct CategoryForm { pub struct NewCategoryTemplate { /// Global application state pub state: AppStateContext, - /// Error - pub error: Option, /// Default form with value pub category_form: Option, } -pub async fn new( - app_state_context: AppStateContext, -) -> Result { - Ok(NewCategoryTemplate { +pub async fn new(app_state_context: AppStateContext) -> NewCategoryTemplate { + NewCategoryTemplate { state: app_state_context, category_form: None, - error: None, - }) + } } pub async fn delete( context: AppStateContext, Path(id): Path, jar: CookieJar, -) -> Result { - let operation_status = match context.db.category().delete(id).await { - Ok(name) => OperationStatus { - success: true, - message: format!("The category {} has been successfully deleted", name), - }, - Err(error) => OperationStatus { - success: false, - message: format!("{}", error), - }, +) -> FlashRedirect { + let status = match context.db.category().delete(id).await { + Ok(name) => StatusCookie::success( + jar, + format!("The category {} has been successfully deleted", name), + ), + Err(error) => StatusCookie::error(jar, error.to_string()), }; - let jar = operation_status.set_cookie(jar); - - Ok((jar, Redirect::to("/categories"))) + status.redirect("/categories") } pub async fn create( context: AppStateContext, jar: CookieJar, Form(form): Form, -) -> Result { - match context.db.category().create(&form).await { - Ok(created) => { - let operation_status = OperationStatus { - success: true, - message: format!( - "The category {} has been successfully created (ID {})", - created.name, created.id - ), - }; - - let jar = operation_status.set_cookie(jar); - - Ok((jar, Redirect::to("/").into_response())) - } - Err(error) => { - let operation_status = OperationStatus { - success: false, - message: format!("{}", error), - }; - - let jar = operation_status.set_cookie(jar); - - Ok((jar, Redirect::to("/").into_response())) - } - } +) -> FlashRedirect { + let status = match context.db.category().create(&form).await { + Ok(created) => StatusCookie::success( + jar, + format!( + "The category {} has been successfully created (ID {})", + created.name, created.id + ), + ), + Err(error) => StatusCookie::error(jar, error.to_string()), + }; + + status.redirect("/") } #[derive(Template, WebTemplate)] @@ -110,11 +87,17 @@ pub struct CategoryShowTemplate { pub breadcrumbs: Vec, } +impl FallibleTemplate for CategoryShowTemplate { + fn with_optional_flash(&mut self, flash: Option) { + self.flash = flash; + } +} + pub async fn show( context: AppStateContext, Path(category_name): Path, - jar: CookieJar, -) -> Result { + status: StatusCookie, +) -> Result, AppStateError> { let categories = context.db.category(); let category = categories @@ -130,18 +113,13 @@ pub async fn show( let children = FileSystemEntry::from_content_folders(&category, &content_folders); - let (jar, operation_status) = get_cookie(jar); - let breadcrumbs = PathBreadcrumb::for_filesystem_path(category.name.as_str()); - Ok(( - jar, - CategoryShowTemplate { - category, - children, - state: context, - flash: operation_status, - breadcrumbs, - }, - )) + Ok(status.with_template(CategoryShowTemplate { + category, + children, + state: context, + flash: None, + breadcrumbs, + })) } diff --git a/src/routes/content_folder.rs b/src/routes/content_folder.rs index ff85078..e52479d 100644 --- a/src/routes/content_folder.rs +++ b/src/routes/content_folder.rs @@ -1,7 +1,7 @@ use askama::Template; use askama_web::WebTemplate; use axum::Form; -use axum::response::{IntoResponse, Redirect}; +use axum::response::Redirect; use axum_extra::extract::CookieJar; use camino::Utf8PathBuf; use serde::{Deserialize, Serialize}; @@ -11,7 +11,9 @@ use crate::database::content_folder::PathBreadcrumb; use crate::database::{category, content_folder}; use crate::extractors::folder_request::FolderRequest; use crate::filesystem::FileSystemEntry; -use crate::state::flash_message::{OperationStatus, get_cookie}; +use crate::state::flash_message::{ + FallibleTemplate, FlashRedirect, FlashTemplate, OperationStatus, StatusCookie, +}; use crate::state::{AppStateContext, error::*}; #[derive(Clone, Debug, Deserialize, Serialize)] @@ -39,31 +41,32 @@ pub struct ContentFolderShowTemplate { pub flash: Option, } +impl FallibleTemplate for ContentFolderShowTemplate { + fn with_optional_flash(&mut self, flash: Option) { + self.flash = flash; + } +} + pub async fn show( context: AppStateContext, folder: FolderRequest, - jar: CookieJar, -) -> Result<(CookieJar, ContentFolderShowTemplate), AppStateError> { - let (jar, operation_status) = get_cookie(jar); - - Ok(( - jar, - ContentFolderShowTemplate { - breadcrumbs: folder.breadcrumbs, - children: folder.children, - current_content_folder: folder.folder, - category: folder.category, - state: context, - flash: operation_status, - }, - )) + status: StatusCookie, +) -> FlashTemplate { + status.with_template(ContentFolderShowTemplate { + breadcrumbs: folder.breadcrumbs, + children: folder.children, + current_content_folder: folder.folder, + category: folder.category, + state: context, + flash: None, + }) } pub async fn create( context: AppStateContext, jar: CookieJar, Form(mut form): Form, -) -> Result { +) -> Result { let categories = context.db.category(); let content_folders = context.db.content_folder(); @@ -86,18 +89,16 @@ pub async fn create( // If name contains "/" returns an error if form.name.contains("/") { - let operation_status = OperationStatus { - success: false, - message: format!( + let status = StatusCookie::error( + jar, + format!( "Failed to create Folder, {} is not valid (it contains '/')", form.name ), - }; - let jar = operation_status.set_cookie(jar); + ); let uri = format!("/folders/{}{}", category.name, parent_path.into_string()); - - return Ok((jar, Redirect::to(uri.as_str()).into_response())); + return Ok(status.redirect(&uri)); } // build final path with parent_path and path of form @@ -111,19 +112,18 @@ pub async fn create( .await .context(IOSnafu)?; - let operation_status = OperationStatus { - success: true, - message: format!( + let status = StatusCookie::success( + jar, + format!( "The folder {} has been successfully created (ID: {})", created.name, created.id ), - }; + ); - let jar = operation_status.set_cookie(jar); let uri = format!("/folders/{}{}", category.name, created.path); - - Ok((jar, Redirect::to(uri.as_str()).into_response())) + Ok(status.redirect(&uri)) } - Err(_error) => Ok((jar, Redirect::to("/").into_response())), + // TODO: why don't we produce an error here? + Err(_error) => Ok((jar, Redirect::to("/"))), } } diff --git a/src/routes/index.rs b/src/routes/index.rs index c4130f5..aa6f73d 100644 --- a/src/routes/index.rs +++ b/src/routes/index.rs @@ -1,11 +1,10 @@ use askama::Template; use askama_web::WebTemplate; -use axum_extra::extract::CookieJar; use snafu::prelude::*; // TUTORIAL: https://github.com/SeaQL/sea-orm/blob/master/examples/axum_example/ use crate::filesystem::FileSystemEntry; -use crate::state::flash_message::{OperationStatus, get_cookie}; +use crate::state::flash_message::{FallibleTemplate, FlashTemplate, OperationStatus, StatusCookie}; use crate::state::{AppStateContext, error::*}; #[derive(Template, WebTemplate)] @@ -19,6 +18,12 @@ pub struct IndexTemplate { pub flash: Option, } +impl FallibleTemplate for IndexTemplate { + fn with_optional_flash(&mut self, flash: Option) { + self.flash = flash; + } +} + #[derive(Template, WebTemplate)] #[template(path = "upload.html")] pub struct UploadTemplate { @@ -31,21 +36,16 @@ pub struct UploadTemplate { impl IndexTemplate { pub async fn new( context: AppStateContext, - jar: CookieJar, - ) -> Result<(CookieJar, Self), AppStateError> { + status: StatusCookie, + ) -> Result, AppStateError> { let categories = context.db.category().list().await.context(CategorySnafu)?; let children = FileSystemEntry::from_categories(&categories); - let (jar, operation_status) = get_cookie(jar); - - Ok(( - jar, - IndexTemplate { - state: context, - flash: operation_status, - children, - }, - )) + Ok(status.with_template(IndexTemplate { + state: context, + flash: None, + children, + })) } } @@ -70,9 +70,9 @@ impl UploadTemplate { pub async fn index( context: AppStateContext, - jar: CookieJar, -) -> Result<(CookieJar, IndexTemplate), AppStateError> { - IndexTemplate::new(context, jar).await + status: StatusCookie, +) -> Result, AppStateError> { + IndexTemplate::new(context, status).await } pub async fn upload(context: AppStateContext) -> Result { diff --git a/src/state/flash_message.rs b/src/state/flash_message.rs index 0694d23..fa2419b 100644 --- a/src/state/flash_message.rs +++ b/src/state/flash_message.rs @@ -1,52 +1,208 @@ +use axum::extract::FromRequestParts; +use axum::http::request::Parts; +use axum::response::Redirect; use axum_extra::extract::{CookieJar, cookie::Cookie}; +use std::boxed::Box; + +use crate::state::{AppState, AppStateError}; + +pub type FlashRedirect = (CookieJar, Redirect); +pub type FlashTemplate = (CookieJar, T); + +#[derive(Debug)] +pub enum MessageOrError { + Message(String), + Error(Box), +} + +impl MessageOrError { + pub fn messages(&self) -> Vec { + match self { + Self::Message(s) => vec![s.to_string()], + Self::Error(e) => { + let mut messages = vec![]; + messages.push(e.to_string()); + let mut error = e.source(); + while let Some(source) = error { + messages.push(source.to_string()); + error = source.source(); + } + messages + } + } + } +} + +/// A template which has an optional [`OperationStatus`]. +pub trait FallibleTemplate: Sized { + fn with_optional_flash(&mut self, flash: Option); + + fn add_error(&mut self, error: impl snafu::Error + Sized + 'static) { + self.with_optional_flash(Some(OperationStatus { + success: false, + message: MessageOrError::Error(Box::new(error)), + })) + } +} + +/// An operation success or failure, as seen in a template. #[derive(Debug)] pub struct OperationStatus { /// Status of operation pub success: bool, /// Message for confirmation alert - pub message: String, + pub message: MessageOrError, } impl OperationStatus { - pub fn set_cookie(&self, jar: CookieJar) -> CookieJar { - let mut cookie_operation_status_success = - Cookie::new("operation_status_success", self.success.to_string()); - - let mut cookie_operation_status_message = - Cookie::new("operation_status_message", self.message.clone()); - cookie_operation_status_success.set_path("/"); - cookie_operation_status_message.set_path("/"); - - jar.add(cookie_operation_status_success) - .add(cookie_operation_status_message) - } -} - -pub fn get_cookie(jar: CookieJar) -> (CookieJar, Option) { - let operation_status = match ( - jar.get("operation_status_success"), - jar.get("operation_status_message"), - ) { - (Some(success), Some(message)) => Some(OperationStatus { - success: if let Ok(success) = success.value().parse() { - success - } else { - return (jar, None); - }, - message: message.value().to_string(), - }), - _ => None, - }; - - let mut operation_status_success_cookie = Cookie::from("operation_status_success"); - operation_status_success_cookie.set_path("/"); - let mut operation_status_message_cookie = Cookie::from("operation_status_message"); - operation_status_message_cookie.set_path("/"); - - let jar = jar - .remove(operation_status_success_cookie) - .remove(operation_status_message_cookie); - - (jar, operation_status) + pub fn success(message: String) -> Self { + Self { + success: true, + message: MessageOrError::Message(message), + } + } + + pub fn error(error: impl snafu::Error + Sized + 'static) -> Self { + Self { + success: false, + message: MessageOrError::Error(Box::new(error)), + } + } + + pub fn error_message(message: String) -> Self { + Self { + success: false, + message: MessageOrError::Message(message), + } + } +} + +/// An operation status passed as a cookie. +/// +/// to be passed between pages as a cookie (flash message). +/// +/// On a route which may reads a flash message: +/// +/// - use `StatusCookie` as an extractor, which will extract the status +/// from the cookie jar +/// - use [`StatusCookie::with_template`] to display the error and pass +/// along the emptied cookie jar +/// +/// On a route which sets a flash message: +/// +/// - use `CookieJar` as an extractor +/// - use [`StatusCookie::error`] or [`StatusCookie::success`] to add the cookie +/// - use [`StatusCookie::redirect`] to redirect with the extra cookie +#[derive(Clone, Debug)] +pub struct StatusCookie { + pub cookies: CookieJar, + pub message: Option, +} + +impl StatusCookie { + fn remove_cookies(&mut self) { + let mut success = Cookie::new("operation_status_success", "".to_string()); + success.set_path("/"); + + let mut message = Cookie::new("operation_status_message", "".to_string()); + message.set_path("/"); + + self.cookies = self.cookies.clone().remove(success).remove(message); + } + + fn add_cookies(&mut self, success: bool, message: String) { + let mut success = Cookie::new("operation_status_success", success.to_string()); + success.set_path("/"); + + let mut message = Cookie::new("operation_status_message", message); + message.set_path("/"); + + self.cookies = self.cookies.clone().add(success).add(message); + } + + pub fn error(cookies: CookieJar, s: String) -> Self { + let mut status = Self { + cookies, + message: Some(StatusMessage { + success: false, + message: s.clone(), + }), + }; + status.add_cookies(false, s); + status + } + + pub fn success(cookies: CookieJar, s: String) -> Self { + let mut status = Self { + cookies, + message: Some(StatusMessage { + success: true, + message: s.clone(), + }), + }; + status.add_cookies(true, s); + status + } + + pub fn redirect(self, url: &str) -> FlashRedirect { + (self.cookies, Redirect::to(url)) + } + + pub fn with_template(self, mut template: T) -> (CookieJar, T) { + let cookies = self.cookies.clone(); + template.with_optional_flash(self.message.map(|m| m.into())); + (cookies, template) + } +} + +impl From for OperationStatus { + fn from(s: StatusMessage) -> Self { + Self { + success: s.success, + message: MessageOrError::Message(s.message), + } + } +} + +#[derive(Clone, Debug)] +pub struct StatusMessage { + success: bool, + message: String, +} + +impl FromRequestParts for StatusCookie { + type Rejection = AppStateError; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let jar = CookieJar::from_request_parts(parts, state).await.unwrap(); + + let status_message = match ( + jar.get("operation_status_success"), + jar.get("operation_status_message"), + ) { + (Some(success), Some(message)) => Some(StatusMessage { + success: if let Ok(success) = success.value().parse() { + success + } else { + return Ok(StatusCookie { + cookies: jar, + message: None, + }); + }, + message: message.value().to_string(), + }), + _ => None, + }; + + let mut status = StatusCookie { + cookies: jar, + message: status_message, + }; + status.remove_cookies(); + Ok(status) + } } diff --git a/templates/categories/new.html b/templates/categories/new.html index a3e90f6..79a998b 100644 --- a/templates/categories/new.html +++ b/templates/categories/new.html @@ -9,12 +9,6 @@

Create Category

- {% if let Some(error) = error %} -
-

{{ error }}

-
- {% endif %} - {% include "categories/form.html" %}
diff --git a/templates/shared/alert_operation_status.html b/templates/shared/alert_operation_status.html index dfb668e..16d8151 100644 --- a/templates/shared/alert_operation_status.html +++ b/templates/shared/alert_operation_status.html @@ -1,6 +1,8 @@ {% if let Some(flash) = flash %}
-

{{ flash.message }}

+ {% for message in flash.message.messages() %} +

{{ message }}

+ {% endfor %}
{% endif %}