diff --git a/domain/src/agreement.rs b/domain/src/agreement.rs new file mode 100644 index 0000000..7b2f07a --- /dev/null +++ b/domain/src/agreement.rs @@ -0,0 +1,14 @@ +use crate::error::Error; +use entity::agreements::Model; +pub use entity_api::agreement::{create, delete_by_id, find_by_id, update}; +use entity_api::{agreement, IntoQueryFilterMap}; +use sea_orm::DatabaseConnection; + +pub async fn find_by( + db: &DatabaseConnection, + params: impl IntoQueryFilterMap, +) -> Result, Error> { + let agreements = agreement::find_by(db, params.into_query_filter_map()).await?; + + Ok(agreements) +} diff --git a/domain/src/lib.rs b/domain/src/lib.rs index c364c4e..2ffb5bf 100644 --- a/domain/src/lib.rs +++ b/domain/src/lib.rs @@ -1,3 +1,12 @@ +//! This module re-exports `IntoQueryFilterMap` and `QueryFilterMap` from the `entity_api` crate. +//! +//! The purpose of this re-export is to ensure that consumers of the `domain` crate do not need to +//! directly depend on the `entity_api` crate. By re-exporting these items, we provide a clear and +//! consistent interface for working with query filters within the domain layer, while encapsulating +//! the underlying implementation details remain in the `entity_api` crate. +pub use entity_api::{IntoQueryFilterMap, QueryFilterMap}; + +pub mod agreement; pub mod coaching_session; pub mod error; pub mod jwt; diff --git a/entity_api/src/agreement.rs b/entity_api/src/agreement.rs index f5e90c5..01fbc28 100644 --- a/entity_api/src/agreement.rs +++ b/entity_api/src/agreement.rs @@ -1,13 +1,12 @@ use super::error::{EntityApiErrorKind, Error}; -use crate::uuid_parse_str; +use crate::QueryFilterMap; use entity::agreements::{self, ActiveModel, Entity, Model}; use entity::Id; use sea_orm::{ entity::prelude::*, ActiveValue::{Set, Unchanged}, - DatabaseConnection, TryIntoModel, + DatabaseConnection, Iterable, TryIntoModel, }; -use std::collections::HashMap; use log::*; @@ -109,23 +108,13 @@ pub async fn find_by_id(db: &DatabaseConnection, id: Id) -> Result pub async fn find_by( db: &DatabaseConnection, - query_params: HashMap, + query_filter_map: QueryFilterMap, ) -> Result, Error> { let mut query = Entity::find(); - for (key, value) in query_params { - match key.as_str() { - "coaching_session_id" => { - let coaching_session_id = uuid_parse_str(&value)?; - - query = query.filter(agreements::Column::CoachingSessionId.eq(coaching_session_id)); - } - _ => { - return Err(Error { - source: None, - error_kind: EntityApiErrorKind::InvalidQueryTerm, - }); - } + for column in agreements::Column::iter() { + if let Some(value) = query_filter_map.get(&column.to_string()) { + query = query.filter(column.eq(value)); } } @@ -140,7 +129,7 @@ pub async fn find_by( mod tests { use super::*; use entity::{agreements::Model, Id}; - use sea_orm::{DatabaseBackend, MockDatabase, Transaction}; + use sea_orm::{DatabaseBackend, MockDatabase, Transaction, Value}; #[tokio::test] async fn create_returns_a_new_agreement_model() -> Result<(), Error> { @@ -216,15 +205,15 @@ mod tests { async fn find_by_returns_all_agreements_associated_with_coaching_session() -> Result<(), Error> { let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); - let mut query_params = HashMap::new(); + let mut query_filter_map = QueryFilterMap::new(); let coaching_session_id = Id::new_v4(); - query_params.insert( + query_filter_map.insert( "coaching_session_id".to_owned(), - coaching_session_id.to_string(), + Some(Value::Uuid(Some(Box::new(coaching_session_id)))), ); - let _ = find_by(&db, query_params).await; + let _ = find_by(&db, query_filter_map).await; assert_eq!( db.into_transaction_log(), diff --git a/entity_api/src/lib.rs b/entity_api/src/lib.rs index 5369624..49d3c80 100644 --- a/entity_api/src/lib.rs +++ b/entity_api/src/lib.rs @@ -1,6 +1,7 @@ use chrono::{Days, Utc}; use password_auth::generate_hash; -use sea_orm::{ActiveModelTrait, DatabaseConnection, Set}; +use sea_orm::{ActiveModelTrait, DatabaseConnection, Set, Value}; +use std::collections::HashMap; use entity::{coaching_relationships, coaching_sessions, organizations, users, Id}; @@ -28,6 +29,87 @@ pub(crate) fn naive_date_parse_str(date_str: &str) -> Result>, +} + +impl QueryFilterMap { + pub fn new() -> Self { + Self { + map: HashMap::new(), + } + } + + pub fn get(&self, key: &str) -> Option { + // HashMap.get returns an Option and so we need to "flatten" this to a single Option + self.map + .get(key) + .and_then(|inner_option| inner_option.clone()) + } + + pub fn insert(&mut self, key: String, value: Option) { + self.map.insert(key, value); + } +} + +impl Default for QueryFilterMap { + fn default() -> Self { + Self::new() + } +} + +/// `IntoQueryFilterMap` is a trait that provides a method for converting a struct into a `QueryFilterMap`. +/// This is particularly useful for translating data between different layers of the application, +/// such as from web request parameters to database query filters. +/// +/// Implementing this trait for a struct allows you to define how the fields of the struct should be +/// mapped to the keys and values of the `QueryFilterMap`. This ensures that the data is passed +/// in a type-safe and organized manner. +/// +/// # Example +/// +/// ``` +/// use entity_api::QueryFilterMap; +/// use entity_api::IntoQueryFilterMap; +/// +/// #[derive(Debug)] +/// struct MyParams { +/// coaching_session_id: String, +/// } +/// +/// impl IntoQueryFilterMap for MyParams { +/// fn into_query_filter_map(self) -> QueryFilterMap { +/// let mut query_filter_map = QueryFilterMap::new(); +/// query_filter_map.insert( +/// "coaching_session_id".to_string(), +/// Some(sea_orm::Value::String(Some(Box::new(self.coaching_session_id)))), +/// ); +/// query_filter_map +/// } +/// } +/// ``` +pub trait IntoQueryFilterMap { + fn into_query_filter_map(self) -> QueryFilterMap; +} + pub async fn seed_database(db: &DatabaseConnection) { let now = Utc::now(); diff --git a/web/src/controller/agreement_controller.rs b/web/src/controller/agreement_controller.rs index d86061d..79f869b 100644 --- a/web/src/controller/agreement_controller.rs +++ b/web/src/controller/agreement_controller.rs @@ -2,16 +2,16 @@ use crate::controller::ApiResponse; use crate::extractors::{ authenticated_user::AuthenticatedUser, compare_api_version::CompareApiVersion, }; +use crate::params::agreement::IndexParams; use crate::{AppState, Error}; use axum::extract::{Path, Query, State}; use axum::http::StatusCode; use axum::response::IntoResponse; use axum::Json; +use domain::agreement as AgreementApi; use entity::{agreements::Model, Id}; -use entity_api::agreement as AgreementApi; use serde_json::json; use service::config::ApiVersion; -use std::collections::HashMap; use log::*; @@ -122,7 +122,7 @@ pub async fn update( path = "/agreements", params( ApiVersion, - ("coaching_session_id" = Option, Query, description = "Filter by coaching_session_id") + ("coaching_session_id" = Id, Query, description = "Filter by coaching_session_id") ), responses( (status = 200, description = "Successfully retrieved all Agreements", body = [entity::agreements::Model]), @@ -139,15 +139,11 @@ pub async fn index( // TODO: create a new Extractor to authorize the user to access // the data requested State(app_state): State, - Query(params): Query>, + Query(params): Query, ) -> Result { debug!("GET all Agreements"); - debug!("Filter Params: {:?}", params); - + info!("Params: {:?}", params); let agreements = AgreementApi::find_by(app_state.db_conn_ref(), params).await?; - - debug!("Found Agreements: {:?}", agreements); - Ok(Json(ApiResponse::new(StatusCode::OK.into(), agreements))) } diff --git a/web/src/error.rs b/web/src/error.rs index 97bac9b..8537fd3 100644 --- a/web/src/error.rs +++ b/web/src/error.rs @@ -15,7 +15,16 @@ use log::*; pub type Result = core::result::Result; #[derive(Debug)] -pub struct Error(DomainError); +pub enum Error { + Domain(DomainError), + Web(WebErrorKind), +} + +#[derive(Debug)] +pub enum WebErrorKind { + Input, + Other, +} impl StdError for Error {} @@ -28,41 +37,100 @@ impl std::fmt::Display for Error { // List of possible StatusCode variants https://docs.rs/http/latest/http/status/struct.StatusCode.html#associatedconstant.UNPROCESSABLE_ENTITY impl IntoResponse for Error { fn into_response(self) -> Response { - match &self.0.error_kind { - DomainErrorKind::Internal(internal_error_kind) => match internal_error_kind { - InternalErrorKind::Entity(entity_error_kind) => match entity_error_kind { - EntityErrorKind::NotFound => { - warn!( - "EntityErrorKind::NotFound: Responding with 404 Not Found. Error: {:?}", - self - ); - (StatusCode::NOT_FOUND, "NOT FOUND").into_response() - } - EntityErrorKind::Invalid => { - warn!("EntityErrorKind::Invalid: Responding with 422 Unprocessable Entity. Error: {:?}", self); - (StatusCode::UNPROCESSABLE_ENTITY, "UNPROCESSABLE ENTITY").into_response() - } - EntityErrorKind::Other => { - warn!("EntityErrorKind::Other: Responding with 500 Internal Server Error. Error: {:?}", self); - (StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL SERVER ERROR").into_response() - } - }, - InternalErrorKind::Other => { - warn!("InternalErrorKind::Other: Responding with 500 Internal Server Error. Error: {:?}", self); - (StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL SERVER ERROR").into_response() - } - }, - DomainErrorKind::External(external_error_kind) => { - match external_error_kind { - ExternalErrorKind::Network => { - warn!("ExternalErrorKind::Network: Responding with 502 Bad Gateway. Error: {:?}", self); - (StatusCode::BAD_GATEWAY, "BAD GATEWAY").into_response() - } - ExternalErrorKind::Other => { - warn!("ExternalErrorKind::Other: Responding with 500 Internal Server Error. Error: {:?}", self); - (StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL SERVER ERROR").into_response() - } - } + match self { + Error::Domain(ref domain_error) => self.handle_domain_error(domain_error), + Error::Web(ref web_error_kind) => self.handle_web_error(web_error_kind), + } + } +} + +impl Error { + fn handle_domain_error(&self, domain_error: &DomainError) -> Response { + match domain_error.error_kind { + DomainErrorKind::Internal(ref internal_error_kind) => { + self.handle_internal_error(internal_error_kind) + } + DomainErrorKind::External(ref external_error_kind) => { + self.handle_external_error(external_error_kind) + } + } + } + + fn handle_internal_error(&self, internal_error_kind: &InternalErrorKind) -> Response { + match internal_error_kind { + InternalErrorKind::Entity(ref entity_error_kind) => { + self.handle_entity_error(entity_error_kind) + } + InternalErrorKind::Other => { + warn!( + "InternalErrorKind::Other: Responding with 500 Internal Server Error. Error: {:?}", + self + ); + (StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL SERVER ERROR").into_response() + } + } + } + + fn handle_entity_error(&self, entity_error_kind: &EntityErrorKind) -> Response { + match entity_error_kind { + EntityErrorKind::NotFound => { + warn!( + "EntityErrorKind::NotFound: Responding with 404 Not Found. Error: {:?}", + self + ); + (StatusCode::NOT_FOUND, "NOT FOUND").into_response() + } + EntityErrorKind::Invalid => { + warn!( + "EntityErrorKind::Invalid: Responding with 422 Unprocessable Entity. Error: {:?}", + self + ); + (StatusCode::UNPROCESSABLE_ENTITY, "UNPROCESSABLE ENTITY").into_response() + } + EntityErrorKind::Other => { + warn!( + "EntityErrorKind::Other: Responding with 500 Internal Server Error. Error: {:?}", + self + ); + (StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL SERVER ERROR").into_response() + } + } + } + + fn handle_external_error(&self, external_error_kind: &ExternalErrorKind) -> Response { + match external_error_kind { + ExternalErrorKind::Network => { + warn!( + "ExternalErrorKind::Network: Responding with 502 Bad Gateway. Error: {:?}", + self + ); + (StatusCode::BAD_GATEWAY, "BAD GATEWAY").into_response() + } + ExternalErrorKind::Other => { + warn!( + "ExternalErrorKind::Other: Responding with 500 Internal Server Error. Error: {:?}", + self + ); + (StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL SERVER ERROR").into_response() + } + } + } + + fn handle_web_error(&self, web_error_kind: &WebErrorKind) -> Response { + match web_error_kind { + WebErrorKind::Input => { + warn!( + "WebErrorKind::Input: Responding with 400 Bad Request. Error: {:?}", + self + ); + (StatusCode::BAD_REQUEST, "BAD REQUEST").into_response() + } + WebErrorKind::Other => { + warn!( + "WebErrorKind::Other: Responding with 500 Internal Server Error. Error: {:?}", + self + ); + (StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL SERVER ERROR").into_response() } } } @@ -73,6 +141,6 @@ where E: Into, { fn from(err: E) -> Self { - Self(err.into()) + Error::Domain(err.into()) } } diff --git a/web/src/params/agreement.rs b/web/src/params/agreement.rs new file mode 100644 index 0000000..81ac04c --- /dev/null +++ b/web/src/params/agreement.rs @@ -0,0 +1,23 @@ +use entity::Id; +use sea_orm::Value; +use serde::Deserialize; +use utoipa::IntoParams; + +use domain::{IntoQueryFilterMap, QueryFilterMap}; + +#[derive(Debug, Deserialize, IntoParams)] +pub(crate) struct IndexParams { + pub(crate) coaching_session_id: Id, +} + +impl IntoQueryFilterMap for IndexParams { + fn into_query_filter_map(self) -> QueryFilterMap { + let mut query_filter_map = QueryFilterMap::new(); + query_filter_map.insert( + "coaching_session_id".to_string(), + Some(Value::Uuid(Some(Box::new(self.coaching_session_id)))), + ); + + query_filter_map + } +} diff --git a/web/src/params/mod.rs b/web/src/params/mod.rs index 2c6d7f0..f615806 100644 --- a/web/src/params/mod.rs +++ b/web/src/params/mod.rs @@ -11,4 +11,5 @@ // //! ``` +pub(crate) mod agreement; pub(crate) mod jwt; diff --git a/web/src/protect/agreements.rs b/web/src/protect/agreements.rs index 057d922..29483c6 100644 --- a/web/src/protect/agreements.rs +++ b/web/src/protect/agreements.rs @@ -1,3 +1,4 @@ +use crate::params::agreement::IndexParams; use crate::{extractors::authenticated_user::AuthenticatedUser, AppState}; use axum::{ extract::{Query, Request, State}, @@ -5,15 +6,8 @@ use axum::{ middleware::Next, response::IntoResponse, }; -use entity::Id; use entity_api::coaching_session; use log::*; -use serde::Deserialize; - -#[derive(Debug, Deserialize)] -pub(crate) struct QueryParams { - coaching_session_id: Id, -} /// Checks that coaching relationship record associated with the coaching session /// referenced by `coaching_session_id exists and that the authenticated user is associated with it. @@ -21,7 +15,7 @@ pub(crate) struct QueryParams { pub(crate) async fn index( State(app_state): State, AuthenticatedUser(user): AuthenticatedUser, - Query(params): Query, + Query(params): Query, request: Request, next: Next, ) -> impl IntoResponse {