From f05544267c99a1935d4f3309d69172be9254b0a2 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Tue, 30 Dec 2025 18:02:46 +0800 Subject: [PATCH] feat: add remove upstream and remove upstream target handlers --- .../routes/api/restricted/nginx/upstream.rs | 9 +- .../nginx/upstream/remove_upstream.rs | 123 ++++++++++++++++++ .../nginx/upstream/remove_upstream_target.rs | 123 ++++++++++++++++++ apps/api/src/services/nginx/upstream.rs | 5 + 4 files changed, 258 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream.rs create mode 100644 apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream_target.rs diff --git a/apps/api/src/routes/api/restricted/nginx/upstream.rs b/apps/api/src/routes/api/restricted/nginx/upstream.rs index fc5fe99..00fd867 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 remove_upstream; +pub mod remove_upstream_target; pub mod update_upstream; pub mod update_upstream_target; @@ -23,7 +25,9 @@ pub fn get_upstream_router(state: Arc) -> Router { ) .route( "/upstreams/{upstream_id}", - get(get_upstream::get_upstream).patch(update_upstream::update_upstream), + get(get_upstream::get_upstream) + .patch(update_upstream::update_upstream) + .delete(remove_upstream::remove_upstream), ) .route( "/upstreams/{upstream_id}/targets", @@ -32,7 +36,8 @@ pub fn get_upstream_router(state: Arc) -> Router { .route( "/upstream_targets/{upstream_target_id}", get(get_upstream_target::get_upstream_target) - .patch(update_upstream_target::update_upstream_target), + .patch(update_upstream_target::update_upstream_target) + .delete(remove_upstream_target::remove_upstream_target), ) .with_state(state) } diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream.rs b/apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream.rs new file mode 100644 index 0000000..419d8f5 --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream.rs @@ -0,0 +1,123 @@ +use std::sync::Arc; + +use axum::{ + Json, + extract::{Path, State}, + response::Result as AxumResult, +}; +use uuid::Uuid; + +use crate::{ + errors::api_error::ApiError, middlewares::request_info::AuthenticatedRequestInfo, + routes::AppState, +}; + +pub async fn remove_upstream( + _request_info: AuthenticatedRequestInfo, + Path(upstream_id): Path, + State(state): State>, +) -> AxumResult, ApiError> { + let upstream_service = &state.service.nginx.get_upstream_service(); + + upstream_service.delete_upstream(upstream_id, None).await?; + + Ok(Json(())) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use axum::http::StatusCode; + use axum_test::TestServer; + use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, MockExecResult}; + + 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, + }; + + 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_remove_upstream_succeeds_returns_ok() { + let up_id = uuid::Uuid::new_v4(); + + let existing = upstream::Model { + id: up_id, + name: "todelete".to_string(), + protocol: "http".to_string(), + algorithm: "rr".to_string(), + sticky_session: false, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![vec![existing.clone()]]) + .append_exec_results(vec![ + MockExecResult { + rows_affected: 1, + last_insert_id: 0, + }, + MockExecResult { + rows_affected: 1, + last_insert_id: 0, + }, + ]) + .into_connection(); + + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let res = server.delete(&format!("/upstreams/{}", up_id)).await; + + res.assert_status_ok(); + } + + #[tokio::test] + async fn handler_remove_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 res = server + .delete(&format!("/upstreams/{}", uuid::Uuid::new_v4())) + .add_header(REQUEST_AUTH_USER_INVALID_HEADER, "true") + .await; + + res.assert_status(StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn handler_remove_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 res = server + .delete(&format!("/upstreams/{}", uuid::Uuid::new_v4())) + .await; + + res.assert_status(StatusCode::NOT_FOUND); + } +} diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream_target.rs b/apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream_target.rs new file mode 100644 index 0000000..3784352 --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream_target.rs @@ -0,0 +1,123 @@ +use std::sync::Arc; + +use axum::{ + Json, + extract::{Path, State}, + response::Result as AxumResult, +}; +use uuid::Uuid; + +use crate::{ + errors::api_error::ApiError, middlewares::request_info::AuthenticatedRequestInfo, + routes::AppState, +}; + +pub async fn remove_upstream_target( + _request_info: AuthenticatedRequestInfo, + Path(upstream_target_id): Path, + State(state): State>, +) -> AxumResult, ApiError> { + let upstream_service = &state.service.nginx.get_upstream_service(); + + upstream_service + .delete_upstream_target(upstream_target_id, None) + .await?; + + Ok(Json(())) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use axum::http::StatusCode; + use axum_test::TestServer; + use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, MockExecResult}; + + use database::generated::entities::upstream_target; + + 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, + }; + + 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_remove_upstream_target_succeeds_returns_ok() { + let ut_id = uuid::Uuid::new_v4(); + + let current_model = upstream_target::Model { + id: ut_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(), + }; + + // first find_by_id, then delete (delete typically doesn't return models) + let first: Vec> = vec![vec![current_model.clone()]]; + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(first) + .append_exec_results(vec![MockExecResult { + rows_affected: 1, + last_insert_id: 0, + }]) + .into_connection(); + + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let res = server.delete(&format!("/upstream_targets/{}", ut_id)).await; + + res.assert_status_ok(); + } + + #[tokio::test] + async fn handler_remove_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 res = server + .delete(&format!("/upstream_targets/{}", uuid::Uuid::new_v4())) + .add_header(REQUEST_AUTH_USER_INVALID_HEADER, "true") + .await; + + res.assert_status(StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn handler_remove_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 res = server + .delete(&format!("/upstream_targets/{}", uuid::Uuid::new_v4())) + .await; + + res.assert_status(StatusCode::NOT_FOUND); + } +} diff --git a/apps/api/src/services/nginx/upstream.rs b/apps/api/src/services/nginx/upstream.rs index 4022128..8833be0 100644 --- a/apps/api/src/services/nginx/upstream.rs +++ b/apps/api/src/services/nginx/upstream.rs @@ -208,6 +208,11 @@ impl UpstreamService { )))? }); with_conn!(&*self.connection, tx, conn, { + // delete all targets belonging to the upstream + upstream_target::Entity::delete_many() + .filter(upstream_target::Column::UpstreamId.eq(upstream_id)) + .exec(*conn) + .await?; model.delete(*conn).await?; Ok(()) })