Skip to content

Commit bb28178

Browse files
authoredFeb 24, 2025··
Merge pull request #102 from refactor-group/refactor_parameter_typing
Refactor parameter typing
2 parents 8852758 + 2df1bd3 commit bb28178

File tree

9 files changed

+253
-77
lines changed

9 files changed

+253
-77
lines changed
 

‎domain/src/agreement.rs

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
use crate::error::Error;
2+
use entity::agreements::Model;
3+
pub use entity_api::agreement::{create, delete_by_id, find_by_id, update};
4+
use entity_api::{agreement, IntoQueryFilterMap};
5+
use sea_orm::DatabaseConnection;
6+
7+
pub async fn find_by(
8+
db: &DatabaseConnection,
9+
params: impl IntoQueryFilterMap,
10+
) -> Result<Vec<Model>, Error> {
11+
let agreements = agreement::find_by(db, params.into_query_filter_map()).await?;
12+
13+
Ok(agreements)
14+
}

‎domain/src/lib.rs

+9
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
//! This module re-exports `IntoQueryFilterMap` and `QueryFilterMap` from the `entity_api` crate.
2+
//!
3+
//! The purpose of this re-export is to ensure that consumers of the `domain` crate do not need to
4+
//! directly depend on the `entity_api` crate. By re-exporting these items, we provide a clear and
5+
//! consistent interface for working with query filters within the domain layer, while encapsulating
6+
//! the underlying implementation details remain in the `entity_api` crate.
7+
pub use entity_api::{IntoQueryFilterMap, QueryFilterMap};
8+
9+
pub mod agreement;
110
pub mod coaching_session;
211
pub mod error;
312
pub mod jwt;

‎entity_api/src/agreement.rs

+11-22
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
use super::error::{EntityApiErrorKind, Error};
2-
use crate::uuid_parse_str;
2+
use crate::QueryFilterMap;
33
use entity::agreements::{self, ActiveModel, Entity, Model};
44
use entity::Id;
55
use sea_orm::{
66
entity::prelude::*,
77
ActiveValue::{Set, Unchanged},
8-
DatabaseConnection, TryIntoModel,
8+
DatabaseConnection, Iterable, TryIntoModel,
99
};
10-
use std::collections::HashMap;
1110

1211
use log::*;
1312

@@ -109,23 +108,13 @@ pub async fn find_by_id(db: &DatabaseConnection, id: Id) -> Result<Option<Model>
109108

110109
pub async fn find_by(
111110
db: &DatabaseConnection,
112-
query_params: HashMap<String, String>,
111+
query_filter_map: QueryFilterMap,
113112
) -> Result<Vec<Model>, Error> {
114113
let mut query = Entity::find();
115114

116-
for (key, value) in query_params {
117-
match key.as_str() {
118-
"coaching_session_id" => {
119-
let coaching_session_id = uuid_parse_str(&value)?;
120-
121-
query = query.filter(agreements::Column::CoachingSessionId.eq(coaching_session_id));
122-
}
123-
_ => {
124-
return Err(Error {
125-
source: None,
126-
error_kind: EntityApiErrorKind::InvalidQueryTerm,
127-
});
128-
}
115+
for column in agreements::Column::iter() {
116+
if let Some(value) = query_filter_map.get(&column.to_string()) {
117+
query = query.filter(column.eq(value));
129118
}
130119
}
131120

@@ -140,7 +129,7 @@ pub async fn find_by(
140129
mod tests {
141130
use super::*;
142131
use entity::{agreements::Model, Id};
143-
use sea_orm::{DatabaseBackend, MockDatabase, Transaction};
132+
use sea_orm::{DatabaseBackend, MockDatabase, Transaction, Value};
144133

145134
#[tokio::test]
146135
async fn create_returns_a_new_agreement_model() -> Result<(), Error> {
@@ -216,15 +205,15 @@ mod tests {
216205
async fn find_by_returns_all_agreements_associated_with_coaching_session() -> Result<(), Error>
217206
{
218207
let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection();
219-
let mut query_params = HashMap::new();
208+
let mut query_filter_map = QueryFilterMap::new();
220209
let coaching_session_id = Id::new_v4();
221210

222-
query_params.insert(
211+
query_filter_map.insert(
223212
"coaching_session_id".to_owned(),
224-
coaching_session_id.to_string(),
213+
Some(Value::Uuid(Some(Box::new(coaching_session_id)))),
225214
);
226215

227-
let _ = find_by(&db, query_params).await;
216+
let _ = find_by(&db, query_filter_map).await;
228217

229218
assert_eq!(
230219
db.into_transaction_log(),

‎entity_api/src/lib.rs

+83-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use chrono::{Days, Utc};
22
use password_auth::generate_hash;
3-
use sea_orm::{ActiveModelTrait, DatabaseConnection, Set};
3+
use sea_orm::{ActiveModelTrait, DatabaseConnection, Set, Value};
4+
use std::collections::HashMap;
45

56
use entity::{coaching_relationships, coaching_sessions, organizations, users, Id};
67

@@ -28,6 +29,87 @@ pub(crate) fn naive_date_parse_str(date_str: &str) -> Result<chrono::NaiveDate,
2829
})
2930
}
3031

32+
/// `QueryFilterMap` is a data structure that serves as a bridge for translating filter parameters
33+
/// between different layers of the application. It is essentially a wrapper around a `HashMap`
34+
/// where the keys are filter parameter names (as `String`) and the values are optional `Value` types
35+
/// from `sea_orm`.
36+
///
37+
/// This structure is particularly useful in scenarios where you need to pass filter parameters
38+
/// from a web request down to the database query layer in a type-safe and organized manner.
39+
///
40+
/// # Example
41+
///
42+
/// ```
43+
/// use sea_orm::Value;
44+
/// use entity_api::QueryFilterMap;
45+
///
46+
/// let mut query_filter_map = QueryFilterMap::new();
47+
/// query_filter_map.insert("coaching_session_id".to_string(), Some(Value::String(Some(Box::new("a_coaching_session_id".to_string())))));
48+
/// let filter_value = query_filter_map.get("coaching_session_id");
49+
/// ```
50+
pub struct QueryFilterMap {
51+
map: HashMap<String, Option<Value>>,
52+
}
53+
54+
impl QueryFilterMap {
55+
pub fn new() -> Self {
56+
Self {
57+
map: HashMap::new(),
58+
}
59+
}
60+
61+
pub fn get(&self, key: &str) -> Option<Value> {
62+
// HashMap.get returns an Option and so we need to "flatten" this to a single Option
63+
self.map
64+
.get(key)
65+
.and_then(|inner_option| inner_option.clone())
66+
}
67+
68+
pub fn insert(&mut self, key: String, value: Option<Value>) {
69+
self.map.insert(key, value);
70+
}
71+
}
72+
73+
impl Default for QueryFilterMap {
74+
fn default() -> Self {
75+
Self::new()
76+
}
77+
}
78+
79+
/// `IntoQueryFilterMap` is a trait that provides a method for converting a struct into a `QueryFilterMap`.
80+
/// This is particularly useful for translating data between different layers of the application,
81+
/// such as from web request parameters to database query filters.
82+
///
83+
/// Implementing this trait for a struct allows you to define how the fields of the struct should be
84+
/// mapped to the keys and values of the `QueryFilterMap`. This ensures that the data is passed
85+
/// in a type-safe and organized manner.
86+
///
87+
/// # Example
88+
///
89+
/// ```
90+
/// use entity_api::QueryFilterMap;
91+
/// use entity_api::IntoQueryFilterMap;
92+
///
93+
/// #[derive(Debug)]
94+
/// struct MyParams {
95+
/// coaching_session_id: String,
96+
/// }
97+
///
98+
/// impl IntoQueryFilterMap for MyParams {
99+
/// fn into_query_filter_map(self) -> QueryFilterMap {
100+
/// let mut query_filter_map = QueryFilterMap::new();
101+
/// query_filter_map.insert(
102+
/// "coaching_session_id".to_string(),
103+
/// Some(sea_orm::Value::String(Some(Box::new(self.coaching_session_id)))),
104+
/// );
105+
/// query_filter_map
106+
/// }
107+
/// }
108+
/// ```
109+
pub trait IntoQueryFilterMap {
110+
fn into_query_filter_map(self) -> QueryFilterMap;
111+
}
112+
31113
pub async fn seed_database(db: &DatabaseConnection) {
32114
let now = Utc::now();
33115

‎web/src/controller/agreement_controller.rs

+5-9
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,16 @@ use crate::controller::ApiResponse;
22
use crate::extractors::{
33
authenticated_user::AuthenticatedUser, compare_api_version::CompareApiVersion,
44
};
5+
use crate::params::agreement::IndexParams;
56
use crate::{AppState, Error};
67
use axum::extract::{Path, Query, State};
78
use axum::http::StatusCode;
89
use axum::response::IntoResponse;
910
use axum::Json;
11+
use domain::agreement as AgreementApi;
1012
use entity::{agreements::Model, Id};
11-
use entity_api::agreement as AgreementApi;
1213
use serde_json::json;
1314
use service::config::ApiVersion;
14-
use std::collections::HashMap;
1515

1616
use log::*;
1717

@@ -122,7 +122,7 @@ pub async fn update(
122122
path = "/agreements",
123123
params(
124124
ApiVersion,
125-
("coaching_session_id" = Option<Id>, Query, description = "Filter by coaching_session_id")
125+
("coaching_session_id" = Id, Query, description = "Filter by coaching_session_id")
126126
),
127127
responses(
128128
(status = 200, description = "Successfully retrieved all Agreements", body = [entity::agreements::Model]),
@@ -139,15 +139,11 @@ pub async fn index(
139139
// TODO: create a new Extractor to authorize the user to access
140140
// the data requested
141141
State(app_state): State<AppState>,
142-
Query(params): Query<HashMap<String, String>>,
142+
Query(params): Query<IndexParams>,
143143
) -> Result<impl IntoResponse, Error> {
144144
debug!("GET all Agreements");
145-
debug!("Filter Params: {:?}", params);
146-
145+
info!("Params: {:?}", params);
147146
let agreements = AgreementApi::find_by(app_state.db_conn_ref(), params).await?;
148-
149-
debug!("Found Agreements: {:?}", agreements);
150-
151147
Ok(Json(ApiResponse::new(StatusCode::OK.into(), agreements)))
152148
}
153149

‎web/src/error.rs

+105-37
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,16 @@ use log::*;
1515
pub type Result<T> = core::result::Result<T, Error>;
1616

1717
#[derive(Debug)]
18-
pub struct Error(DomainError);
18+
pub enum Error {
19+
Domain(DomainError),
20+
Web(WebErrorKind),
21+
}
22+
23+
#[derive(Debug)]
24+
pub enum WebErrorKind {
25+
Input,
26+
Other,
27+
}
1928

2029
impl StdError for Error {}
2130

@@ -28,41 +37,100 @@ impl std::fmt::Display for Error {
2837
// List of possible StatusCode variants https://docs.rs/http/latest/http/status/struct.StatusCode.html#associatedconstant.UNPROCESSABLE_ENTITY
2938
impl IntoResponse for Error {
3039
fn into_response(self) -> Response {
31-
match &self.0.error_kind {
32-
DomainErrorKind::Internal(internal_error_kind) => match internal_error_kind {
33-
InternalErrorKind::Entity(entity_error_kind) => match entity_error_kind {
34-
EntityErrorKind::NotFound => {
35-
warn!(
36-
"EntityErrorKind::NotFound: Responding with 404 Not Found. Error: {:?}",
37-
self
38-
);
39-
(StatusCode::NOT_FOUND, "NOT FOUND").into_response()
40-
}
41-
EntityErrorKind::Invalid => {
42-
warn!("EntityErrorKind::Invalid: Responding with 422 Unprocessable Entity. Error: {:?}", self);
43-
(StatusCode::UNPROCESSABLE_ENTITY, "UNPROCESSABLE ENTITY").into_response()
44-
}
45-
EntityErrorKind::Other => {
46-
warn!("EntityErrorKind::Other: Responding with 500 Internal Server Error. Error: {:?}", self);
47-
(StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL SERVER ERROR").into_response()
48-
}
49-
},
50-
InternalErrorKind::Other => {
51-
warn!("InternalErrorKind::Other: Responding with 500 Internal Server Error. Error: {:?}", self);
52-
(StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL SERVER ERROR").into_response()
53-
}
54-
},
55-
DomainErrorKind::External(external_error_kind) => {
56-
match external_error_kind {
57-
ExternalErrorKind::Network => {
58-
warn!("ExternalErrorKind::Network: Responding with 502 Bad Gateway. Error: {:?}", self);
59-
(StatusCode::BAD_GATEWAY, "BAD GATEWAY").into_response()
60-
}
61-
ExternalErrorKind::Other => {
62-
warn!("ExternalErrorKind::Other: Responding with 500 Internal Server Error. Error: {:?}", self);
63-
(StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL SERVER ERROR").into_response()
64-
}
65-
}
40+
match self {
41+
Error::Domain(ref domain_error) => self.handle_domain_error(domain_error),
42+
Error::Web(ref web_error_kind) => self.handle_web_error(web_error_kind),
43+
}
44+
}
45+
}
46+
47+
impl Error {
48+
fn handle_domain_error(&self, domain_error: &DomainError) -> Response {
49+
match domain_error.error_kind {
50+
DomainErrorKind::Internal(ref internal_error_kind) => {
51+
self.handle_internal_error(internal_error_kind)
52+
}
53+
DomainErrorKind::External(ref external_error_kind) => {
54+
self.handle_external_error(external_error_kind)
55+
}
56+
}
57+
}
58+
59+
fn handle_internal_error(&self, internal_error_kind: &InternalErrorKind) -> Response {
60+
match internal_error_kind {
61+
InternalErrorKind::Entity(ref entity_error_kind) => {
62+
self.handle_entity_error(entity_error_kind)
63+
}
64+
InternalErrorKind::Other => {
65+
warn!(
66+
"InternalErrorKind::Other: Responding with 500 Internal Server Error. Error: {:?}",
67+
self
68+
);
69+
(StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL SERVER ERROR").into_response()
70+
}
71+
}
72+
}
73+
74+
fn handle_entity_error(&self, entity_error_kind: &EntityErrorKind) -> Response {
75+
match entity_error_kind {
76+
EntityErrorKind::NotFound => {
77+
warn!(
78+
"EntityErrorKind::NotFound: Responding with 404 Not Found. Error: {:?}",
79+
self
80+
);
81+
(StatusCode::NOT_FOUND, "NOT FOUND").into_response()
82+
}
83+
EntityErrorKind::Invalid => {
84+
warn!(
85+
"EntityErrorKind::Invalid: Responding with 422 Unprocessable Entity. Error: {:?}",
86+
self
87+
);
88+
(StatusCode::UNPROCESSABLE_ENTITY, "UNPROCESSABLE ENTITY").into_response()
89+
}
90+
EntityErrorKind::Other => {
91+
warn!(
92+
"EntityErrorKind::Other: Responding with 500 Internal Server Error. Error: {:?}",
93+
self
94+
);
95+
(StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL SERVER ERROR").into_response()
96+
}
97+
}
98+
}
99+
100+
fn handle_external_error(&self, external_error_kind: &ExternalErrorKind) -> Response {
101+
match external_error_kind {
102+
ExternalErrorKind::Network => {
103+
warn!(
104+
"ExternalErrorKind::Network: Responding with 502 Bad Gateway. Error: {:?}",
105+
self
106+
);
107+
(StatusCode::BAD_GATEWAY, "BAD GATEWAY").into_response()
108+
}
109+
ExternalErrorKind::Other => {
110+
warn!(
111+
"ExternalErrorKind::Other: Responding with 500 Internal Server Error. Error: {:?}",
112+
self
113+
);
114+
(StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL SERVER ERROR").into_response()
115+
}
116+
}
117+
}
118+
119+
fn handle_web_error(&self, web_error_kind: &WebErrorKind) -> Response {
120+
match web_error_kind {
121+
WebErrorKind::Input => {
122+
warn!(
123+
"WebErrorKind::Input: Responding with 400 Bad Request. Error: {:?}",
124+
self
125+
);
126+
(StatusCode::BAD_REQUEST, "BAD REQUEST").into_response()
127+
}
128+
WebErrorKind::Other => {
129+
warn!(
130+
"WebErrorKind::Other: Responding with 500 Internal Server Error. Error: {:?}",
131+
self
132+
);
133+
(StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL SERVER ERROR").into_response()
66134
}
67135
}
68136
}
@@ -73,6 +141,6 @@ where
73141
E: Into<DomainError>,
74142
{
75143
fn from(err: E) -> Self {
76-
Self(err.into())
144+
Error::Domain(err.into())
77145
}
78146
}

‎web/src/params/agreement.rs

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
use entity::Id;
2+
use sea_orm::Value;
3+
use serde::Deserialize;
4+
use utoipa::IntoParams;
5+
6+
use domain::{IntoQueryFilterMap, QueryFilterMap};
7+
8+
#[derive(Debug, Deserialize, IntoParams)]
9+
pub(crate) struct IndexParams {
10+
pub(crate) coaching_session_id: Id,
11+
}
12+
13+
impl IntoQueryFilterMap for IndexParams {
14+
fn into_query_filter_map(self) -> QueryFilterMap {
15+
let mut query_filter_map = QueryFilterMap::new();
16+
query_filter_map.insert(
17+
"coaching_session_id".to_string(),
18+
Some(Value::Uuid(Some(Box::new(self.coaching_session_id)))),
19+
);
20+
21+
query_filter_map
22+
}
23+
}

‎web/src/params/mod.rs

+1
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@
1111
//
1212
//! ```
1313
14+
pub(crate) mod agreement;
1415
pub(crate) mod jwt;

‎web/src/protect/agreements.rs

+2-8
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,21 @@
1+
use crate::params::agreement::IndexParams;
12
use crate::{extractors::authenticated_user::AuthenticatedUser, AppState};
23
use axum::{
34
extract::{Query, Request, State},
45
http::StatusCode,
56
middleware::Next,
67
response::IntoResponse,
78
};
8-
use entity::Id;
99
use entity_api::coaching_session;
1010
use log::*;
11-
use serde::Deserialize;
12-
13-
#[derive(Debug, Deserialize)]
14-
pub(crate) struct QueryParams {
15-
coaching_session_id: Id,
16-
}
1711

1812
/// Checks that coaching relationship record associated with the coaching session
1913
/// referenced by `coaching_session_id exists and that the authenticated user is associated with it.
2014
/// Intended to be given to axum::middleware::from_fn_with_state in the router
2115
pub(crate) async fn index(
2216
State(app_state): State<AppState>,
2317
AuthenticatedUser(user): AuthenticatedUser,
24-
Query(params): Query<QueryParams>,
18+
Query(params): Query<IndexParams>,
2519
request: Request,
2620
next: Next,
2721
) -> impl IntoResponse {

0 commit comments

Comments
 (0)
Please sign in to comment.