diff --git a/apps/api/src/routes/api/restricted/nginx/upstream.rs b/apps/api/src/routes/api/restricted/nginx/upstream.rs index 717a043..fc5fe99 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream.rs @@ -3,6 +3,8 @@ pub mod create_upstream_target; pub mod get_upstream; pub mod get_upstream_target; pub mod info; +pub mod update_upstream; +pub mod update_upstream_target; use std::sync::Arc; @@ -19,14 +21,18 @@ pub fn get_upstream_router(state: Arc) -> Router { "/upstreams", get(get_upstream::get_upstream_list).post(create_upstream::create_upstream), ) - .route("/upstreams/{upstream_id}", get(get_upstream::get_upstream)) + .route( + "/upstreams/{upstream_id}", + get(get_upstream::get_upstream).patch(update_upstream::update_upstream), + ) .route( "/upstreams/{upstream_id}/targets", post(create_upstream_target::add_upstream_target), ) .route( "/upstream_targets/{upstream_target_id}", - get(get_upstream_target::get_upstream_target), + get(get_upstream_target::get_upstream_target) + .patch(update_upstream_target::update_upstream_target), ) .with_state(state) } diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/info/response.rs b/apps/api/src/routes/api/restricted/nginx/upstream/info/response.rs index efdc428..0a8d717 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream/info/response.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream/info/response.rs @@ -162,3 +162,71 @@ impl From } } } + +#[derive(Serialize, Deserialize, utoipa::ToSchema)] +pub struct UpdateUpstreamInfoResponse { + pub id: uuid::Uuid, + pub name: String, + pub protocol: String, + pub algorithm: String, + pub sticky_session: bool, + pub created_by: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + // + pub upstream_targets: Vec, +} + +impl From for UpdateUpstreamInfoResponse { + fn from(info: crate::services::nginx::info::upstream::UpstreamInfo) -> Self { + Self { + id: info.id, + name: info.name, + protocol: info.protocol, + algorithm: info.algorithm, + sticky_session: info.sticky_session, + created_by: info.created_by, + created_at: info.created_at, + updated_at: info.updated_at, + upstream_targets: info + .upstream_targets + .into_iter() + .map(|t| t.into()) + .collect(), + } + } +} + +#[derive(Serialize, Deserialize, utoipa::ToSchema)] +pub struct UpdateUpstreamTargetInfoResponse { + pub id: uuid::Uuid, + pub host: String, + pub port: i64, + pub enabled: bool, + pub is_backup: bool, + pub weight: i32, + // + pub created_at: DateTime, + pub updated_at: DateTime, + // + pub upstream_id: Uuid, +} + +impl From + for UpdateUpstreamTargetInfoResponse +{ + fn from(info: crate::services::nginx::info::upstream_target::UpstreamTargetInfo) -> Self { + Self { + id: info.id, + host: info.target_host, + port: info.target_port, + enabled: info.enabled, + is_backup: info.is_backup, + weight: info.weight as i32, + // + created_at: info.created_at, + updated_at: info.updated_at, + upstream_id: info.upstream_id, + } + } +} diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/update_upstream.rs b/apps/api/src/routes/api/restricted/nginx/upstream/update_upstream.rs new file mode 100644 index 0000000..a287671 --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/upstream/update_upstream.rs @@ -0,0 +1,203 @@ +use std::sync::Arc; + +use axum::{ + Json, + extract::{Path, State}, + response::Result as AxumResult, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + errors::api_error::ApiError, + middlewares::request_info::AuthenticatedRequestInfo, + routes::{ + AppState, api::restricted::nginx::upstream::info::response::UpdateUpstreamInfoResponse, + }, + services::nginx::info::upstream::UpdateUpstreamInfo, +}; + +#[derive(Deserialize, utoipa::ToSchema, Serialize)] +pub struct UpstreamTargetBasicUpdateInfo { + pub id: i64, + pub enabled: bool, +} + +#[derive(Deserialize, utoipa::ToSchema, Serialize)] +pub struct UpdateUpstreamRequestBody { + pub name: Option, + pub protocol: Option, + pub algorithm: Option, + pub sticky_session: Option, + // only updates upstream targets' enabled status for now + pub upstream_targets: Option>, +} + +impl From for UpdateUpstreamInfo { + fn from(val: UpdateUpstreamRequestBody) -> Self { + Self { + name: val.name, + protocol: val.protocol, + algorithm: val.algorithm, + sticky_session: val.sticky_session, + // + upstream_targets: None, + } + } +} + +pub async fn update_upstream( + _request_info: AuthenticatedRequestInfo, + Path(upstream_id): Path, + State(state): State>, + Json(payload): Json, +) -> AxumResult, ApiError> { + let upstream_service = &state.service.nginx.get_upstream_service(); + let update_info: UpdateUpstreamInfo = payload.into(); + + let r = upstream_service + .update_upstream(upstream_id, update_info, None) + .await?; + + Ok(Json(r.into())) +} + + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use axum::http::StatusCode; + use axum_test::TestServer; + use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase}; + + use database::generated::entities::upstream; + + use crate::{ + configs::{FromConfig, ProgramSettings}, + middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER, + routes::api::restricted::nginx::upstream::get_upstream_router, + services::get_app_service, + }; + use super::UpdateUpstreamRequestBody; + + fn get_router_with_state(db: DatabaseConnection) -> axum::Router { + let program_settings = ProgramSettings::mock(); + let app_service = get_app_service(&Arc::new(db.clone()), &program_settings); + let state = Arc::new(crate::routes::AppState { + database_connection: Arc::new(db), + service: Arc::new(app_service), + config: Arc::new(program_settings), + }); + get_upstream_router(state).layer(axum::middleware::from_fn( + crate::middlewares::require_auth::mock::mock_require_auth, + )) + } + + #[tokio::test] + async fn handler_update_upstream_succeeds_returns_ok() { + let up_id = uuid::Uuid::new_v4(); + + let current_model = upstream::Model { + id: up_id, + name: "old_upstream".to_string(), + protocol: "http".to_string(), + algorithm: "round_robin".to_string(), + sticky_session: false, + created_by: Some(uuid::Uuid::new_v4()), + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let updated_model = upstream::Model { + id: up_id, + name: "updated_upstream".to_string(), + protocol: "http".to_string(), + algorithm: "round_robin".to_string(), + sticky_session: false, + created_by: Some(uuid::Uuid::new_v4()), + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + // first find_by_id, then update returns updated model + let first: Vec> = vec![vec![current_model.clone()]]; + let second: Vec> = vec![vec![updated_model.clone()]]; + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(first) + .append_query_results(second) + .into_connection(); + + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let payload = UpdateUpstreamRequestBody { + name: Some("updated_upstream".to_string()), + protocol: None, + algorithm: None, + sticky_session: None, + upstream_targets: None, + }; + + let res = server + .patch(&format!("/upstreams/{}", up_id)) + .json(&payload) + .await; + + res.assert_status_ok(); + let text = res.text(); + let body: crate::routes::api::restricted::nginx::upstream::info::response::UpdateUpstreamInfoResponse = + serde_json::from_str(&text).expect("failed to parse json"); + + assert_eq!(body.id, up_id); + assert_eq!(body.name, "updated_upstream"); + } + + #[tokio::test] + async fn handler_update_upstream_unauthenticated_returns_unauthorized() { + let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection(); + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let payload = UpdateUpstreamRequestBody { + name: Some("updated_upstream".to_string()), + protocol: None, + algorithm: None, + sticky_session: None, + upstream_targets: None, + }; + + let res = server + .patch(&format!("/upstreams/{}", uuid::Uuid::new_v4())) + .add_header(REQUEST_AUTH_USER_INVALID_HEADER, "true") + .json(&payload) + .await; + + res.assert_status(StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn handler_update_upstream_not_found_returns_not_found() { + let empty_results: Vec> = vec![Vec::::new()]; + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(empty_results) + .into_connection(); + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let payload = UpdateUpstreamRequestBody { + name: Some("updated_upstream".to_string()), + protocol: None, + algorithm: None, + sticky_session: None, + upstream_targets: None, + }; + + let res = server + .patch(&format!("/upstreams/{}", uuid::Uuid::new_v4())) + .json(&payload) + .await; + + res.assert_status(StatusCode::NOT_FOUND); + } +} diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/update_upstream_target.rs b/apps/api/src/routes/api/restricted/nginx/upstream/update_upstream_target.rs new file mode 100644 index 0000000..814ce69 --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/upstream/update_upstream_target.rs @@ -0,0 +1,206 @@ +use std::sync::Arc; + +use axum::{ + Json, + extract::{Path, State}, + response::Result as AxumResult, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + errors::api_error::ApiError, + middlewares::request_info::AuthenticatedRequestInfo, + routes::{ + AppState, + api::restricted::nginx::upstream::info::response::UpdateUpstreamTargetInfoResponse, + }, + services::nginx::info::upstream_target::UpdateUpstreamTargetInfo, +}; + +#[derive(Deserialize, utoipa::ToSchema, Serialize)] +pub struct UpdateUpstreamTargetRequestBody { + pub host: Option, + pub port: Option, + pub enabled: Option, + pub is_backup: Option, + pub weight: Option, +} + +impl From for UpdateUpstreamTargetInfo { + fn from(val: UpdateUpstreamTargetRequestBody) -> Self { + Self { + target_host: val.host, + target_port: val.port, + enabled: val.enabled, + is_backup: val.is_backup, + weight: val.weight.map(|w| w as i64), + } + } +} + +pub async fn update_upstream_target( + _request_info: AuthenticatedRequestInfo, + Path(upstream_target_id): Path, + State(state): State>, + Json(payload): Json, +) -> AxumResult, ApiError> { + let upstream_service = &state.service.nginx.get_upstream_service(); + let update_info: UpdateUpstreamTargetInfo = payload.into(); + + let r = upstream_service + .update_upstream_target(upstream_target_id, update_info, None) + .await?; + + Ok(Json(r.into())) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use axum::http::StatusCode; + use axum::routing::patch; + use axum_test::TestServer; + use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase}; + + use database::generated::entities::upstream_target; + + use super::UpdateUpstreamTargetRequestBody; + use crate::{ + configs::{FromConfig, ProgramSettings}, + middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER, + services::get_app_service, + }; + + fn get_router_with_state(db: DatabaseConnection) -> axum::Router { + let program_settings = ProgramSettings::mock(); + let app_service = get_app_service(&Arc::new(db.clone()), &program_settings); + let state = Arc::new(crate::routes::AppState { + database_connection: Arc::new(db), + service: Arc::new(app_service), + config: Arc::new(program_settings), + }); + + axum::Router::new() + .route( + "/upstream_targets/{upstream_target_id}", + patch(crate::routes::api::restricted::nginx::upstream::update_upstream_target::update_upstream_target), + ) + .with_state(state) + .layer(axum::middleware::from_fn( + crate::middlewares::require_auth::mock::mock_require_auth, + )) + } + + #[tokio::test] + async fn handler_update_upstream_target_succeeds_returns_ok() { + let target_id = uuid::Uuid::new_v4(); + + let current_model = upstream_target::Model { + id: target_id, + upstream_id: uuid::Uuid::new_v4(), + target_host: "127.0.0.1".to_string(), + target_port: 8080, + weight: 1, + is_backup: false, + enabled: true, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let updated_model = upstream_target::Model { + id: target_id, + upstream_id: current_model.upstream_id, + target_host: "127.0.0.1".to_string(), + target_port: 8081, + weight: 2, + is_backup: false, + enabled: false, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let first: Vec> = vec![vec![current_model.clone()]]; + let second: Vec> = vec![vec![updated_model.clone()]]; + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(first) + .append_query_results(second) + .into_connection(); + + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let payload = UpdateUpstreamTargetRequestBody { + host: None, + port: Some(8081), + enabled: Some(false), + is_backup: None, + weight: Some(2), + }; + + let res = server + .patch(&format!("/upstream_targets/{}", target_id)) + .json(&payload) + .await; + + res.assert_status_ok(); + let text = res.text(); + let body: crate::routes::api::restricted::nginx::upstream::info::response::UpdateUpstreamTargetInfoResponse = + serde_json::from_str(&text).expect("failed to parse json"); + + assert_eq!(body.id, target_id); + assert_eq!(body.port, 8081); + assert!(!body.enabled); + } + + #[tokio::test] + async fn handler_update_upstream_target_unauthenticated_returns_unauthorized() { + let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection(); + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let payload = UpdateUpstreamTargetRequestBody { + host: None, + port: Some(8081), + enabled: Some(false), + is_backup: None, + weight: Some(2), + }; + + let res = server + .patch(&format!("/upstream_targets/{}", uuid::Uuid::new_v4())) + .add_header(REQUEST_AUTH_USER_INVALID_HEADER, "true") + .json(&payload) + .await; + + res.assert_status(StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn handler_update_upstream_target_not_found_returns_not_found() { + let empty_results: Vec> = + vec![Vec::::new()]; + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(empty_results) + .into_connection(); + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let payload = UpdateUpstreamTargetRequestBody { + host: None, + port: Some(8081), + enabled: Some(false), + is_backup: None, + weight: Some(2), + }; + + let res = server + .patch(&format!("/upstream_targets/{}", uuid::Uuid::new_v4())) + .json(&payload) + .await; + + res.assert_status(StatusCode::NOT_FOUND); + } +} diff --git a/apps/api/src/services/nginx/info/upstream.rs b/apps/api/src/services/nginx/info/upstream.rs index 8389109..0353d0f 100644 --- a/apps/api/src/services/nginx/info/upstream.rs +++ b/apps/api/src/services/nginx/info/upstream.rs @@ -2,6 +2,7 @@ use chrono::{DateTime, Utc}; use optfield::optfield; use database::generated::entities::{upstream, upstream_target}; +use sea_orm::ActiveValue::{Set, Unchanged}; use uuid::Uuid; use crate::{ @@ -13,15 +14,14 @@ use crate::{ set_if_some, }; -#[optfield(pub UpdateUpstreamInfo)] #[derive(Clone)] pub struct UpstreamInfo { - pub id: uuid::Uuid, + pub id: Uuid, pub name: String, pub protocol: String, pub algorithm: String, pub sticky_session: bool, - pub created_by: Option, + pub created_by: Option, pub created_at: DateTime, pub updated_at: DateTime, // @@ -33,11 +33,21 @@ pub struct UpstreamCreateInfo { pub protocol: String, pub algorithm: String, pub sticky_session: bool, - pub created_by: Option, + pub created_by: Option, // pub upstream_targets: Vec, } +#[derive(Clone)] +pub struct UpdateUpstreamInfo { + pub name: Option, + pub protocol: Option, + pub algorithm: Option, + pub sticky_session: Option, + // + pub upstream_targets: Option>, +} + impl NginxConfigProvider for UpstreamInfo { fn to_nginx_config(&self, indent: Option) -> String { let targets_config: Vec = self @@ -142,18 +152,14 @@ impl From for (upstream::ActiveModel, Vec upstream::ActiveModel { upstream::ActiveModel { - id: sea_orm::ActiveValue::Unchanged(current_model.id), + id: Unchanged(current_model.id), name: set_if_some!(self.name), protocol: set_if_some!(self.protocol), algorithm: set_if_some!(self.algorithm), sticky_session: set_if_some!(self.sticky_session), - created_by: set_if_some!(if self.created_by.is_some() { - Some(self.created_by) - } else { - None - }), - created_at: set_if_some!(self.created_at), - updated_at: set_if_some!(self.updated_at), + created_by: Unchanged(current_model.created_by), + created_at: Unchanged(current_model.created_at), + updated_at: Set(chrono::Utc::now()), } } } diff --git a/apps/api/src/services/nginx/info/upstream_target.rs b/apps/api/src/services/nginx/info/upstream_target.rs index 3331098..129bae3 100644 --- a/apps/api/src/services/nginx/info/upstream_target.rs +++ b/apps/api/src/services/nginx/info/upstream_target.rs @@ -1,5 +1,4 @@ use chrono::{DateTime, Utc}; -use optfield::optfield; use sea_orm::ActiveValue::{Set, Unchanged}; use uuid::Uuid; @@ -11,7 +10,6 @@ use crate::{ set_if_some, }; -#[optfield(pub UpdateUpstreamTargetInfo)] #[derive(Clone)] pub struct UpstreamTargetInfo { pub id: uuid::Uuid, @@ -27,6 +25,15 @@ pub struct UpstreamTargetInfo { pub upstream: Option, } +#[derive(Clone)] +pub struct UpdateUpstreamTargetInfo { + pub target_host: Option, + pub target_port: Option, + pub weight: Option, + pub is_backup: Option, + pub enabled: Option, +} + #[derive(Clone)] pub struct UpstreamBasicInfo { pub id: uuid::Uuid, @@ -146,9 +153,9 @@ impl UpdateUpstreamTargetInfo { weight: set_if_some!(self.weight), is_backup: set_if_some!(self.is_backup), enabled: set_if_some!(self.enabled), - created_at: set_if_some!(self.created_at), - updated_at: set_if_some!(self.updated_at), - upstream_id: set_if_some!(self.upstream_id), + created_at: Unchanged(current_model.created_at), + updated_at: Set(chrono::Utc::now()), + upstream_id: Unchanged(current_model.upstream_id), } } } diff --git a/apps/api/src/services/nginx/upstream.rs b/apps/api/src/services/nginx/upstream.rs index f9fd054..4022128 100644 --- a/apps/api/src/services/nginx/upstream.rs +++ b/apps/api/src/services/nginx/upstream.rs @@ -142,7 +142,16 @@ impl UpstreamService { upstream: UpdateUpstreamInfo, tx: Option<&mut DatabaseTransaction>, ) -> Result { - let current_model = with_conn!(&*self.connection, tx, conn, { + // If a transaction was provided use it, otherwise create and own one here. + let mut maybe_owned_tx: Option = None; + let tx_ref: Option<&mut DatabaseTransaction> = if let Some(tx) = tx { + Some(tx) + } else { + maybe_owned_tx = Some(self.connection.begin().await?); + maybe_owned_tx.as_mut() + }; + + let current_model = with_conn!(&*self.connection, tx_ref, conn, { upstream::Entity::find_by_id(id) .one(*conn) .await? @@ -151,9 +160,36 @@ impl UpstreamService { id )))? }); - let active_model = upstream.apply_to_model(current_model); + let upstream_active_model = upstream.clone().apply_to_model(current_model); - let r = active_model.update(&*self.connection).await?; + let r = with_conn!(&*self.connection, tx_ref, conn, { + let updated_upstream_model = upstream_active_model.update(*conn).await?; + + // update upstream targets if any + if let Some(targets) = upstream.upstream_targets { + for (target_id, enabled) in targets.into_iter() { + let target_model = upstream_target::Entity::find_by_id(target_id) + .one(*conn) + .await? + .ok_or(ServiceError::NotFound(format!( + "Upstream target with id {} not found", + target_id + )))?; + let mut target_active_model: upstream_target::ActiveModel = target_model.into(); + target_active_model.enabled = sea_orm::ActiveValue::Set(enabled); + + target_active_model.update(*conn).await?; + Ok::<(), ServiceError>(())?; + } + } + + updated_upstream_model + }); + + // Commit + if let Some(t) = maybe_owned_tx.take() { + t.commit().await?; + } Ok(r.into()) } @@ -494,14 +530,10 @@ mod tests { let svc = UpstreamService::new(Arc::new(db)); let update_info = crate::services::nginx::info::upstream::UpdateUpstreamInfo { - id: None, name: None, protocol: None, algorithm: None, sticky_session: None, - created_by: None, - created_at: None, - updated_at: None, upstream_targets: None, }; let res = svc.update_upstream(id, update_info, None).await; @@ -522,14 +554,11 @@ mod tests { .update_upstream( uuid::Uuid::new_v4(), crate::services::nginx::info::upstream::UpdateUpstreamInfo { - id: None, name: None, protocol: None, algorithm: None, sticky_session: None, - created_by: None, - created_at: None, - updated_at: None, + upstream_targets: None, }, None, @@ -650,16 +679,11 @@ mod tests { let svc = UpstreamService::new(Arc::new(db)); let update_info = crate::services::nginx::info::upstream_target::UpdateUpstreamTargetInfo { - id: None, target_host: None, target_port: None, weight: None, is_backup: None, enabled: None, - created_at: None, - updated_at: None, - upstream_id: None, - upstream: None, }; let res = svc.update_upstream_target(id, update_info, None).await; assert!(res.is_ok());