diff --git a/apps/api/src/routes/api/restricted/nginx/upstream.rs b/apps/api/src/routes/api/restricted/nginx/upstream.rs index 2481de0..717a043 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream.rs @@ -1,11 +1,15 @@ pub mod create_upstream; +pub mod create_upstream_target; pub mod get_upstream; pub mod get_upstream_target; pub mod info; use std::sync::Arc; -use axum::{Router, routing::get}; +use axum::{ + Router, + routing::{get, post}, +}; use crate::routes::AppState; @@ -16,6 +20,10 @@ pub fn get_upstream_router(state: Arc) -> Router { get(get_upstream::get_upstream_list).post(create_upstream::create_upstream), ) .route("/upstreams/{upstream_id}", get(get_upstream::get_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), diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream_target.rs b/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream_target.rs new file mode 100644 index 0000000..86cfd6b --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream_target.rs @@ -0,0 +1,189 @@ +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::UpstreamTargetInfoResponse, + }, + services::nginx::info::upstream_target::UpstreamTargetCreateInfo, +}; + +#[derive(serde::Deserialize, utoipa::ToSchema, serde::Serialize)] +pub struct CreateUpstreamTargetInfo { + pub upstream_id: uuid::Uuid, + pub host: String, + pub port: i64, + pub weight: Option, + pub is_backup: Option, + pub enabled: Option, +} + +pub struct ConcreteCreateUpstreamTargetInfo { + pub upstream_id: uuid::Uuid, + pub host: String, + pub port: i64, + pub weight: i64, + pub is_backup: bool, + pub enabled: bool, +} + +impl From for ConcreteCreateUpstreamTargetInfo { + fn from(info: CreateUpstreamTargetInfo) -> Self { + Self { + upstream_id: info.upstream_id, + 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), + } + } +} + +#[axum::debug_handler] +pub async fn add_upstream_target( + _request_info: AuthenticatedRequestInfo, + State(state): State>, + Json(payload): Json, +) -> AxumResult, ApiError> { + let upstream_service = &state.service.nginx.get_upstream_service(); + let concrete_payload: ConcreteCreateUpstreamTargetInfo = payload.into(); + + let create_info = UpstreamTargetCreateInfo { + weight: concrete_payload.weight, + is_backup: concrete_payload.is_backup, + enabled: concrete_payload.enabled, + target_host: concrete_payload.host, + target_port: concrete_payload.port, + upstream_id: concrete_payload.upstream_id, + }; + + let upstream_info = upstream_service + .create_upstream_target(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_target; + + use crate::{ + configs::{FromConfig, ProgramSettings}, + middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER, + routes::api::restricted::nginx::upstream::{ + create_upstream_target::CreateUpstreamTargetInfo, 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_add_upstream_target_succeeds_returns_created() { + let up_id = uuid::Uuid::new_v4(); + + 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(), + }; + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .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 = CreateUpstreamTargetInfo { + upstream_id: up_id, + host: "127.0.0.1".to_string(), + port: 8080, + weight: None, + is_backup: None, + enabled: None, + }; + + let res = server + .post(&format!("/upstreams/{}/targets", up_id)) + .json(&payload) + .await; + + res.assert_status_ok(); + let text = res.text(); + let body: crate::routes::api::restricted::nginx::upstream::info::response::UpstreamTargetInfoResponse = + serde_json::from_str(&text).expect("failed to parse json"); + + assert_eq!(body.id, target_id); + assert_eq!(body.host, "127.0.0.1"); + assert_eq!(body.port, 8080); + assert_eq!(body.upstream_id, up_id); + } + + #[tokio::test] + async fn handler_add_upstream_target_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"); + + let res = server + .post(&format!("/upstreams/{}/targets", uuid::Uuid::new_v4())) + .json(&serde_json::json!({})) + .await; + + res.assert_status(StatusCode::UNPROCESSABLE_ENTITY); + } + + #[tokio::test] + async fn handler_add_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 = CreateUpstreamTargetInfo { + upstream_id: uuid::Uuid::new_v4(), + host: "127.0.0.1".to_string(), + port: 8080, + weight: None, + is_backup: None, + enabled: None, + }; + + let res = server + .post(&format!("/upstreams/{}/targets", payload.upstream_id)) + .add_header(REQUEST_AUTH_USER_INVALID_HEADER, "true") + .json(&payload) + .await; + + res.assert_status(StatusCode::UNAUTHORIZED); + } +} diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream_target.rs b/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream_target.rs index fdc5d47..07a4ac5 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream_target.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream_target.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::{ - errors::{api_error::ApiError, service_error::ServiceError}, + errors::api_error::ApiError, routes::{AppState, api::restricted::nginx::upstream::info::response::UpstreamTargetInfo}, }; 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 58d744d..efdc428 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 @@ -127,3 +127,38 @@ pub struct UpstreamListResponse { pub items: Vec, pub pagination: PaginationInfo, } + +#[derive(Serialize, Deserialize, utoipa::ToSchema)] +pub struct UpstreamTargetInfoResponse { + 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 UpstreamTargetInfoResponse +{ + 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, + } + } +}