From dd79cbe0bb1a3513cb6c50abd58a5539245d5067 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Mon, 29 Dec 2025 20:12:01 +0800 Subject: [PATCH] feat: add create_upstream handler for upstream creation --- .../routes/api/restricted/nginx/upstream.rs | 6 +- .../nginx/upstream/create_upstream.rs | 249 ++++++++++++++++++ 2 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/routes/api/restricted/nginx/upstream/create_upstream.rs diff --git a/apps/api/src/routes/api/restricted/nginx/upstream.rs b/apps/api/src/routes/api/restricted/nginx/upstream.rs index 408ca72..2481de0 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream.rs @@ -1,3 +1,4 @@ +pub mod create_upstream; pub mod get_upstream; pub mod get_upstream_target; pub mod info; @@ -10,7 +11,10 @@ use crate::routes::AppState; pub fn get_upstream_router(state: Arc) -> Router { Router::new() - .route("/upstreams", get(get_upstream::get_upstream_list)) + .route( + "/upstreams", + get(get_upstream::get_upstream_list).post(create_upstream::create_upstream), + ) .route("/upstreams/{upstream_id}", get(get_upstream::get_upstream)) .route( "/upstream_targets/{upstream_target_id}", diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream.rs b/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream.rs new file mode 100644 index 0000000..45b79ca --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream.rs @@ -0,0 +1,249 @@ +use std::sync::Arc; + +use axum::{Json, extract::State, response::Result as AxumResult}; + +use crate::{ + errors::api_error::ApiError, + middlewares::request_info::AuthenticatedRequestInfo, + routes::{AppState, api::restricted::nginx::upstream::info::response::UpstreamInfoResponse}, + services::nginx::info::upstream::UpstreamCreateInfo, +}; + +#[derive(serde::Deserialize, utoipa::ToSchema, serde::Serialize)] +pub struct UpstreamTargetInfo { + pub host: String, + pub port: i64, + pub weight: Option, + pub is_backup: Option, + pub enabled: Option, +} + +pub struct ConcreteUpstreamTargetInfo { + pub host: String, + pub port: i64, + pub weight: i64, + pub is_backup: bool, + pub enabled: bool, +} + +impl From for ConcreteUpstreamTargetInfo { + fn from(info: UpstreamTargetInfo) -> Self { + Self { + host: info.host, + port: info.port, + weight: info.weight.unwrap_or(1), + is_backup: info.is_backup.unwrap_or(false), + enabled: info.enabled.unwrap_or(true), + } + } +} + +#[derive(serde::Deserialize, utoipa::ToSchema, serde::Serialize)] +pub struct CreateUpstreamRequestBody { + pub name: String, + pub protocol: String, + pub algorithm: Option, + pub sticky_session: Option, + pub upstream_targets: Vec, +} + +struct ConcreteCreateUpstreamRequestBody { + pub name: String, + pub protocol: String, + pub algorithm: String, + pub sticky_session: bool, + pub upstream_targets: Vec, +} + +impl From for ConcreteCreateUpstreamRequestBody { + fn from(payload: CreateUpstreamRequestBody) -> Self { + Self { + name: payload.name, + protocol: payload.protocol, + algorithm: payload + .algorithm + .unwrap_or_else(|| "round_robin".to_string()), + sticky_session: payload.sticky_session.unwrap_or(false), + upstream_targets: payload + .upstream_targets + .into_iter() + .map(|target| target.into()) + .collect(), + } + } +} + +#[axum::debug_handler] +pub async fn create_upstream( + request_info: AuthenticatedRequestInfo, + State(state): State>, + Json(payload): Json, +) -> AxumResult, ApiError> { + let upstream_service = &state.service.nginx.get_upstream_service(); + let concrete_payload: ConcreteCreateUpstreamRequestBody = payload.into(); + + let create_info = UpstreamCreateInfo { + name: concrete_payload.name, + protocol: concrete_payload.protocol, + algorithm: concrete_payload.algorithm, + sticky_session: concrete_payload.sticky_session, + created_by: Some(request_info.user_id), + upstream_targets: concrete_payload + .upstream_targets + .into_iter() + .map( + |target| crate::services::nginx::info::upstream_target::UpstreamTargetCreateInfo { + target_host: target.host, + target_port: target.port, + weight: target.weight, + is_backup: target.is_backup, + enabled: target.enabled, + upstream_id: uuid::Uuid::nil(), // Placeholder, will be set in service + }, + ) + .collect(), + }; + + let upstream_info = upstream_service.create_upstream(create_info, None).await?; + + Ok(Json(upstream_info.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, upstream_target}; + + use crate::{ + configs::{FromConfig, ProgramSettings}, + middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER, + routes::api::restricted::nginx::upstream::{ + create_upstream::{CreateUpstreamRequestBody, UpstreamTargetInfo as ReqTarget}, + 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_create_upstream_succeeds_returns_created() { + let up_id = uuid::Uuid::new_v4(); + + let up_model = upstream::Model { + id: up_id, + name: "new_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 target_id = uuid::Uuid::new_v4(); + let target_model = upstream_target::Model { + id: target_id, + upstream_id: up_id, + 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(), + }; + + // service will likely perform an insert and then query to return created models + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![vec![up_model.clone()]]) + .append_query_results(vec![vec![target_model.clone()]]) + .into_connection(); + + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let payload = CreateUpstreamRequestBody { + name: "new_upstream".to_string(), + protocol: "http".to_string(), + algorithm: None, + sticky_session: None, + upstream_targets: vec![ReqTarget { + host: "127.0.0.1".to_string(), + port: 8080, + weight: None, + is_backup: None, + enabled: None, + }], + }; + + let res = server.post("/upstreams").json(&payload).await; + + res.assert_status_ok(); + let text = res.text(); + let body: crate::routes::api::restricted::nginx::upstream::info::response::UpstreamInfoResponse = + serde_json::from_str(&text).expect("failed to parse json"); + + assert_eq!(body.id, up_id); + assert_eq!(body.name, "new_upstream"); + assert_eq!(body.protocol, "http"); + assert_eq!(body.upstream_targets.len(), 1); + assert_eq!(body.upstream_targets[0].id, target_id); + } + + #[tokio::test] + async fn handler_create_upstream_invalid_payload_returns_bad_request() { + 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"); + + // missing required fields -> send empty object + let res = server.post("/upstreams").json(&serde_json::json!({})).await; + res.assert_status(StatusCode::UNPROCESSABLE_ENTITY); + } + + #[tokio::test] + async fn handler_create_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 = CreateUpstreamRequestBody { + name: "new_upstream".to_string(), + protocol: "http".to_string(), + algorithm: None, + sticky_session: None, + upstream_targets: vec![ReqTarget { + host: "127.0.0.1".to_string(), + port: 8080, + weight: None, + is_backup: None, + enabled: None, + }], + }; + + let res = server + .post("/upstreams") + .add_header(REQUEST_AUTH_USER_INVALID_HEADER, "true") + .json(&payload) + .await; + + res.assert_status(StatusCode::UNAUTHORIZED); + } +}