Skip to content

Commit 41eb901

Browse files
committedMar 4, 2025··
define partial update endpoint for users
1 parent 6e0d3ac commit 41eb901

File tree

7 files changed

+180
-7
lines changed

7 files changed

+180
-7
lines changed
 

‎domain/src/lib.rs

+4-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44
//! directly depend on the `entity_api` crate. By re-exporting these items, we provide a clear and
55
//! consistent interface for working with query filters within the domain layer, while encapsulating
66
//! the underlying implementation details remain in the `entity_api` crate.
7-
pub use entity_api::query::{IntoQueryFilterMap, QueryFilterMap};
7+
pub use entity_api::{
8+
mutate::{IntoUpdateMap, UpdateMap},
9+
query::{IntoQueryFilterMap, QueryFilterMap},
10+
};
811

912
// Re-exports from `entity`
1013
pub use entity_api::user::{AuthSession, Backend, Credentials};

‎domain/src/user.rs

+21-1
Original file line numberDiff line numberDiff line change
@@ -1 +1,21 @@
1-
pub use entity_api::user::{create, find_by_email};
1+
use crate::{error::Error, users, Id};
2+
use entity_api::mutate;
3+
use sea_orm::DatabaseConnection;
4+
use sea_orm::IntoActiveModel;
5+
6+
pub use entity_api::user::{create, find_by_email, find_by_id};
7+
8+
pub async fn update(
9+
db: &DatabaseConnection,
10+
user_id: Id,
11+
params: impl mutate::IntoUpdateMap,
12+
) -> Result<users::Model, Error> {
13+
let existing_user = find_by_id(db, user_id).await?;
14+
let active_model = existing_user.into_active_model();
15+
Ok(mutate::update::<users::ActiveModel, users::Column>(
16+
db,
17+
active_model,
18+
params.into_update_map(),
19+
)
20+
.await?)
21+
}

‎entity_api/src/user.rs

+63-3
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@ use async_trait::async_trait;
33
use axum_login::{AuthnBackend, UserId};
44
use chrono::Utc;
55
use entity::users::{ActiveModel, Column, Entity, Model};
6+
use entity::Id;
67
use log::*;
78
use password_auth::{generate_hash, verify_password};
89
use sea_orm::{entity::prelude::*, DatabaseConnection, Set};
910
use serde::Deserialize;
1011
use std::sync::Arc;
11-
use utoipa::ToSchema;
12+
use utoipa::{IntoParams, ToSchema};
1213

1314
pub async fn create(db: &DatabaseConnection, user_model: Model) -> Result<Model, Error> {
1415
debug!(
@@ -36,7 +37,7 @@ pub async fn create(db: &DatabaseConnection, user_model: Model) -> Result<Model,
3637

3738
pub async fn find_by_email(db: &DatabaseConnection, email: &str) -> Result<Option<Model>, Error> {
3839
let user: Option<Model> = Entity::find()
39-
.filter(Column::Email.contains(email))
40+
.filter(Column::Email.eq(email))
4041
.one(db)
4142
.await?;
4243

@@ -45,6 +46,13 @@ pub async fn find_by_email(db: &DatabaseConnection, email: &str) -> Result<Optio
4546
Ok(user)
4647
}
4748

49+
pub async fn find_by_id(db: &DatabaseConnection, id: Id) -> Result<Model, Error> {
50+
Entity::find_by_id(id).one(db).await?.ok_or_else(|| Error {
51+
source: None,
52+
error_kind: EntityApiErrorKind::RecordNotFound,
53+
})
54+
}
55+
4856
async fn authenticate_user(creds: Credentials, user: Model) -> Result<Option<Model>, Error> {
4957
match verify_password(creds.password, &user.password) {
5058
Ok(_) => Ok(Some(user)),
@@ -60,7 +68,7 @@ pub struct Backend {
6068
db: Arc<DatabaseConnection>,
6169
}
6270

63-
#[derive(Debug, Clone, ToSchema, Deserialize)]
71+
#[derive(Debug, Clone, ToSchema, IntoParams, Deserialize)]
6472
#[schema(as = entity_api::user::Credentials)] // OpenAPI schema
6573
pub struct Credentials {
6674
pub email: String,
@@ -112,3 +120,55 @@ impl AuthnBackend for Backend {
112120
}
113121

114122
pub type AuthSession = axum_login::AuthSession<Backend>;
123+
124+
#[cfg(test)]
125+
// We need to gate seaORM's mock feature behind conditional compilation because
126+
// the feature removes the Clone trait implementation from seaORM's DatabaseConnection.
127+
// see https://github.com/SeaQL/sea-orm/issues/830
128+
#[cfg(feature = "mock")]
129+
mod test {
130+
use super::*;
131+
use entity::Id;
132+
use sea_orm::{DatabaseBackend, MockDatabase, Transaction};
133+
134+
#[tokio::test]
135+
async fn find_by_email_returns_a_single_record() -> Result<(), Error> {
136+
let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection();
137+
138+
let user_email = "test@test.com";
139+
let _ = find_by_email(&db, user_email).await;
140+
141+
assert_eq!(
142+
db.into_transaction_log(),
143+
[Transaction::from_sql_and_values(
144+
DatabaseBackend::Postgres,
145+
r#"SELECT "users"."id", "users"."email", "users"."first_name", "users"."last_name", "users"."display_name", "users"."password", "users"."github_username", "users"."github_profile_url", "users"."created_at", "users"."updated_at" FROM "refactor_platform"."users" WHERE "users"."email" = $1 LIMIT $2"#,
146+
[user_email.into(), sea_orm::Value::BigUnsigned(Some(1))]
147+
)]
148+
);
149+
150+
Ok(())
151+
}
152+
153+
#[tokio::test]
154+
async fn find_by_id_returns_a_single_record() -> Result<(), Error> {
155+
let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection();
156+
157+
let coaching_session_id = Id::new_v4();
158+
let _ = find_by_id(&db, coaching_session_id).await;
159+
160+
assert_eq!(
161+
db.into_transaction_log(),
162+
[Transaction::from_sql_and_values(
163+
DatabaseBackend::Postgres,
164+
r#"SELECT "users"."id", "users"."email", "users"."first_name", "users"."last_name", "users"."display_name", "users"."password", "users"."github_username", "users"."github_profile_url", "users"."created_at", "users"."updated_at" FROM "refactor_platform"."users" WHERE "users"."id" = $1 LIMIT $2"#,
165+
[
166+
coaching_session_id.into(),
167+
sea_orm::Value::BigUnsigned(Some(1))
168+
]
169+
)]
170+
);
171+
172+
Ok(())
173+
}
174+
}

‎web/src/controller/user_controller.rs

+35-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
use crate::{controller::ApiResponse, extractors::compare_api_version::CompareApiVersion};
1+
use crate::extractors::{
2+
authenticated_user::AuthenticatedUser, compare_api_version::CompareApiVersion,
3+
};
4+
use crate::{controller::ApiResponse, params::user::*};
25
use crate::{AppState, Error};
36
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
47
use domain::{user as UserApi, users};
@@ -36,3 +39,34 @@ pub async fn create(
3639

3740
Ok(Json(ApiResponse::new(StatusCode::CREATED.into(), user)))
3841
}
42+
43+
/// UPDATE a User
44+
/// NOTE: that this is for updating the current user and as such uses the user
45+
/// from the AuthenticatedUser extractor. If we decide to allow a user to update
46+
/// another user, we may want to consider something like a PUT /myself endpoint for
47+
/// the current user updating their own data.
48+
#[utoipa::path(
49+
put,
50+
path = "/users",
51+
params(
52+
ApiVersion,
53+
UpdateUserParams
54+
),
55+
request_body = UpdateUserParams,
56+
responses(
57+
(status = 200, description = "Successfully updated a User", body = [users::Model]),
58+
(status = 401, description = "Unauthorized"),
59+
),
60+
security(
61+
("cookie_auth" = [])
62+
)
63+
)]
64+
pub async fn update(
65+
CompareApiVersion(_v): CompareApiVersion,
66+
AuthenticatedUser(user): AuthenticatedUser,
67+
State(app_state): State<AppState>,
68+
Json(params): Json<UpdateUserParams>,
69+
) -> Result<impl IntoResponse, Error> {
70+
let updated_user = UserApi::update(app_state.db_conn_ref(), user.id, params).await?;
71+
Ok(Json(ApiResponse::new(StatusCode::OK.into(), updated_user)))
72+
}

‎web/src/params/mod.rs

+1
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,4 @@ pub(crate) mod agreement;
1616
pub(crate) mod coaching_session;
1717
pub(crate) mod jwt;
1818
pub(crate) mod overarching_goal;
19+
pub(crate) mod user;

‎web/src/params/user.rs

+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
use sea_orm::Value;
2+
use serde::Deserialize;
3+
use utoipa::{IntoParams, ToSchema};
4+
5+
use domain::{IntoUpdateMap, UpdateMap};
6+
7+
#[derive(Debug, Deserialize, IntoParams, ToSchema)]
8+
pub struct UpdateUserParams {
9+
pub email: Option<String>,
10+
pub first_name: Option<String>,
11+
pub last_name: Option<String>,
12+
pub display_name: Option<String>,
13+
pub github_profile_url: Option<String>,
14+
}
15+
16+
impl IntoUpdateMap for UpdateUserParams {
17+
fn into_update_map(self) -> UpdateMap {
18+
let mut update_map = UpdateMap::new();
19+
if let Some(email) = self.email {
20+
update_map.insert(
21+
"email".to_string(),
22+
Some(Value::String(Some(Box::new(email)))),
23+
);
24+
}
25+
if let Some(first_name) = self.first_name {
26+
update_map.insert(
27+
"first_name".to_string(),
28+
Some(Value::String(Some(Box::new(first_name)))),
29+
);
30+
}
31+
if let Some(last_name) = self.last_name {
32+
update_map.insert(
33+
"last_name".to_string(),
34+
Some(Value::String(Some(Box::new(last_name)))),
35+
);
36+
}
37+
if let Some(display_name) = self.display_name {
38+
update_map.insert(
39+
"display_name".to_string(),
40+
Some(Value::String(Some(Box::new(display_name)))),
41+
);
42+
}
43+
if let Some(github_profile_url) = self.github_profile_url {
44+
update_map.insert(
45+
"github_profile_url".to_string(),
46+
Some(Value::String(Some(Box::new(github_profile_url)))),
47+
);
48+
}
49+
update_map
50+
}
51+
}

‎web/src/router.rs

+5-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use crate::{protect, AppState};
1+
use crate::{params, protect, AppState};
22
use axum::{
33
middleware::from_fn_with_state,
44
routing::{delete, get, post, put},
@@ -61,6 +61,7 @@ use self::organization::coaching_relationship_controller;
6161
overarching_goal_controller::read,
6262
overarching_goal_controller::update_status,
6363
user_controller::create,
64+
user_controller::update,
6465
user_session_controller::login,
6566
user_session_controller::logout,
6667
jwt_controller::generate_collab_token,
@@ -76,6 +77,8 @@ use self::organization::coaching_relationship_controller;
7677
domain::overarching_goals::Model,
7778
domain::users::Model,
7879
domain::Credentials,
80+
params::user::UpdateUserParams,
81+
7982
)
8083
),
8184
modifiers(&SecurityAddon),
@@ -278,6 +281,7 @@ pub fn overarching_goal_routes(app_state: AppState) -> Router {
278281
pub fn user_routes(app_state: AppState) -> Router {
279282
Router::new()
280283
.route("/users", post(user_controller::create))
284+
.route("/users", put(user_controller::update))
281285
.route_layer(login_required!(Backend, login_url = "/login"))
282286
.with_state(app_state)
283287
}

0 commit comments

Comments
 (0)
Please sign in to comment.