diff --git a/Cargo.lock b/Cargo.lock index 5259b53..f583c2a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7,7 +7,7 @@ name = "agent_client" version = "0.1.0" dependencies = [ "async-trait", - "mockall", + "mockall 0.13.1", "reqwest", "serde", "serde_json", @@ -292,6 +292,35 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "axum-test" +version = "18.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3290e73c56c5cc4701cdd7d46b9ced1b4bd61c7e9f9c769a9e9e87ff617d75d2" +dependencies = [ + "anyhow", + "axum", + "bytes", + "bytesize", + "cookie", + "expect-json", + "http", + "http-body-util", + "hyper", + "hyper-util", + "mime", + "pretty_assertions", + "reserve-port", + "rust-multipart-rfc7578_2", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "tokio", + "tower", + "url", +] + [[package]] name = "base16ct" version = "0.2.0" @@ -509,6 +538,12 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +[[package]] +name = "bytesize" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3" + [[package]] name = "cc" version = "1.2.45" @@ -994,6 +1029,12 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.10.7" @@ -1123,6 +1164,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1191,6 +1241,35 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "expect-json" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "422e7906e79941e5ac58c64dfd2da03e6ae3de62227f87606fbbe125d91080f9" +dependencies = [ + "chrono", + "email_address", + "expect-json-macros", + "num", + "regex", + "serde", + "serde_json", + "thiserror", + "typetag", + "uuid", +] + +[[package]] +name = "expect-json-macros" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6b515b7f10f1e61bfd938522e9884509b82060af2016153f5b3d6f44d6da89c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -1925,6 +2004,15 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "inventory" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e" +dependencies = [ + "rustversion", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -2159,7 +2247,21 @@ dependencies = [ "cfg-if", "downcast", "fragile", - "mockall_derive", + "mockall_derive 0.13.1", + "predicates", + "predicates-tree", +] + +[[package]] +name = "mockall" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58d964098a5f9c6b63d0798e5372fd04708193510a7af313c22e9f29b7b620b" +dependencies = [ + "cfg-if", + "downcast", + "fragile", + "mockall_derive 0.14.0", "predicates", "predicates-tree", ] @@ -2176,6 +2278,18 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "mockall_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca41ce716dda6a9be188b385aa78ee5260fc25cd3802cb2a8afdc6afbe6b6dbf" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "native-tls" version = "0.2.14" @@ -2741,6 +2855,16 @@ dependencies = [ "termtree", ] +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -3115,6 +3239,15 @@ dependencies = [ "webpki-roots 1.0.4", ] +[[package]] +name = "reserve-port" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21918d6644020c6f6ef1993242989bf6d4952d2e025617744f184c02df51c356" +dependencies = [ + "thiserror", +] + [[package]] name = "rfc6979" version = "0.4.0" @@ -3212,6 +3345,21 @@ dependencies = [ "ordered-multimap", ] +[[package]] +name = "rust-multipart-rfc7578_2" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c839d037155ebc06a571e305af66ff9fd9063a6e662447051737e1ac75beea41" +dependencies = [ + "bytes", + "futures-core", + "futures-util", + "http", + "mime", + "rand 0.9.2", + "thiserror", +] + [[package]] name = "rust_decimal" version = "1.39.0" @@ -4684,6 +4832,30 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "typetag" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be2212c8a9b9bcfca32024de14998494cf9a5dfa59ea1b829de98bac374b86bf" +dependencies = [ + "erased-serde", + "inventory", + "once_cell", + "serde", + "typetag-impl", +] + +[[package]] +name = "typetag-impl" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27a7a9b72ba121f6f1f6c3632b85604cac41aedb5ddc70accbebb6cac83de846" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "ucd-trie" version = "0.1.7" @@ -5433,6 +5605,7 @@ dependencies = [ "async-trait", "axum", "axum-extra", + "axum-test", "chrono", "clap", "config", @@ -5441,11 +5614,13 @@ dependencies = [ "jsonwebtoken", "migration", "mime_guess", + "mockall 0.14.0", "once_cell", "reqwest", "sea-orm", "serde", "serde_json", + "serde_urlencoded", "tempfile", "tokio", "tower", diff --git a/apps/api/Cargo.toml b/apps/api/Cargo.toml index 81a05be..2563501 100644 --- a/apps/api/Cargo.toml +++ b/apps/api/Cargo.toml @@ -30,9 +30,13 @@ jsonwebtoken = { version = "10.2.0", features = ["rust_crypto"] } uuid = { version = "1.19.0", features = ["v4", "serde", "fast-rng"] } tower-http = { version = "0.6.8", features = ["cors"] } reqwest = { version = "^0.12", features = ["json", "multipart", "stream"] } +serde_urlencoded = { version = "0.7.1" } [dev-dependencies] tempfile = "3" +axum-test = "18.4.1" +agent_client = { path = "../../public/agent-client", features = ["mockall"] } +mockall = { version = "0.14.0", features = [] } [lints.clippy] -unwrap_used = "deny" \ No newline at end of file +unwrap_used = "deny" diff --git a/apps/api/src/errors.rs b/apps/api/src/errors.rs index ae13070..d261f2c 100644 --- a/apps/api/src/errors.rs +++ b/apps/api/src/errors.rs @@ -1 +1,2 @@ +pub mod api_error; pub mod service_error; diff --git a/apps/api/src/errors/api_error.rs b/apps/api/src/errors/api_error.rs new file mode 100644 index 0000000..674d04c --- /dev/null +++ b/apps/api/src/errors/api_error.rs @@ -0,0 +1,32 @@ +use axum::response::IntoResponse; +use sea_orm::DbErr; +use tracing::error; + +use crate::errors::service_error::ServiceError; + +#[derive(Debug)] +pub enum ApiError { + ServiceError(ServiceError), +} + +impl From for ApiError { + fn from(err: ServiceError) -> Self { + error!("Service error occurred: {:?}", err); + ApiError::ServiceError(err) + } +} + +impl From for ApiError { + fn from(err: DbErr) -> Self { + ServiceError::from(err).into() + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> axum::response::Response { + error!("API error occurred: {:?}", self); + match self { + ApiError::ServiceError(service_error) => service_error.into_response(), + } + } +} diff --git a/apps/api/src/errors/service_error.rs b/apps/api/src/errors/service_error.rs index bd99ad8..f04ea40 100644 --- a/apps/api/src/errors/service_error.rs +++ b/apps/api/src/errors/service_error.rs @@ -1,3 +1,4 @@ +use axum::response::IntoResponse; use sea_orm::DbErr; #[derive(Debug)] @@ -37,3 +38,23 @@ impl From for ServiceError { } } } + +impl IntoResponse for ServiceError { + fn into_response(self) -> axum::response::Response { + let (status, message) = match &self { + ServiceError::NotFound(msg) => (axum::http::StatusCode::NOT_FOUND, msg.clone()), + ServiceError::DatabaseError(msg) => { + (axum::http::StatusCode::INTERNAL_SERVER_ERROR, msg.clone()) + } + ServiceError::Unauthorized(msg) => (axum::http::StatusCode::UNAUTHORIZED, msg.clone()), + ServiceError::InternalError(msg) => { + (axum::http::StatusCode::INTERNAL_SERVER_ERROR, msg.clone()) + } + ServiceError::BadRequest(msg) => (axum::http::StatusCode::BAD_REQUEST, msg.clone()), + }; + let body = axum::Json(serde_json::json!({ + "error": message, + })); + (status, body).into_response() + } +} diff --git a/apps/api/src/helpers.rs b/apps/api/src/helpers.rs index 6fbb533..4cf5a18 100644 --- a/apps/api/src/helpers.rs +++ b/apps/api/src/helpers.rs @@ -1,2 +1,3 @@ pub mod constants; pub mod database; +pub mod macros; diff --git a/apps/api/src/helpers/database.rs b/apps/api/src/helpers/database.rs index da8f12c..b4b42ed 100644 --- a/apps/api/src/helpers/database.rs +++ b/apps/api/src/helpers/database.rs @@ -11,3 +11,16 @@ macro_rules! with_conn { } }}; } + +pub struct PaginationFilter { + pub page: u64, + pub per_page: u64, +} + +impl PaginationFilter { + pub fn get_offset_limit(&self) -> (u64, u64) { + let offset = (self.page - 1) * self.per_page; + let limit = self.per_page; + (offset, limit) + } +} diff --git a/apps/api/src/helpers/macros.rs b/apps/api/src/helpers/macros.rs new file mode 100644 index 0000000..22bf75c --- /dev/null +++ b/apps/api/src/helpers/macros.rs @@ -0,0 +1,9 @@ +#[macro_export] +macro_rules! set_if_some { + ($field:expr) => { + match $field { + Some(value) => sea_orm::ActiveValue::Set(value), + None => sea_orm::ActiveValue::NotSet, + } + }; +} diff --git a/apps/api/src/middlewares.rs b/apps/api/src/middlewares.rs index 8799c1d..5d8d634 100644 --- a/apps/api/src/middlewares.rs +++ b/apps/api/src/middlewares.rs @@ -9,7 +9,7 @@ use axum::{ http::{HeaderValue, Method, StatusCode, Uri}, }; use tower::{ServiceBuilder, timeout::TimeoutLayer}; -use tower_http::cors::{AllowHeaders, AllowOrigin, CorsLayer}; +use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer}; use tracing::warn; use crate::{configs::server::CORSSettings, routes::AppState}; @@ -34,6 +34,7 @@ pub fn apply_root_middleware( pub fn get_cors_layer(cors_settings: Arc) -> CorsLayer { let mut cors_layer = CorsLayer::new() .allow_credentials(true) + .allow_methods(AllowMethods::mirror_request()) .allow_headers(AllowHeaders::mirror_request()); let allowed_origins = &cors_settings.allowed_origins; diff --git a/apps/api/src/middlewares/request_info.rs b/apps/api/src/middlewares/request_info.rs index fb44b20..65b35e0 100644 --- a/apps/api/src/middlewares/request_info.rs +++ b/apps/api/src/middlewares/request_info.rs @@ -1,6 +1,34 @@ +use axum::{extract::FromRequestParts, http::StatusCode}; use uuid::Uuid; #[derive(Clone, Debug)] pub struct RequestInfo { pub user_id: Option, } + +pub struct AuthenticatedRequestInfo { + pub user_id: Uuid, +} + +impl FromRequestParts for AuthenticatedRequestInfo +where + S: Send + Sync, +{ + type Rejection = StatusCode; + + async fn from_request_parts( + parts: &mut axum::http::request::Parts, + _state: &S, + ) -> Result { + let request_info = parts + .extensions + .get::() + .ok_or(StatusCode::UNAUTHORIZED)?; + + if let Some(user_id) = request_info.user_id { + Ok(AuthenticatedRequestInfo { user_id }) + } else { + Err(StatusCode::UNAUTHORIZED) + } + } +} diff --git a/apps/api/src/middlewares/require_auth.rs b/apps/api/src/middlewares/require_auth.rs index 5cf3a6f..3c5db09 100644 --- a/apps/api/src/middlewares/require_auth.rs +++ b/apps/api/src/middlewares/require_auth.rs @@ -68,3 +68,42 @@ async fn handle_unauthenticated() -> Result { // TODO: log unauthenticated access attempts Err(StatusCode::UNAUTHORIZED) } + +#[cfg(test)] +pub mod mock { + + use super::*; + + pub const REQUEST_AUTH_USER_ID_HEADER: &str = "x-mock-authenticated-user-id"; + pub const REQUEST_AUTH_USER_INVALID_HEADER: &str = "x-mock-authenticated-invalid"; + + pub async fn mock_require_auth( + req: Request, + next: Next, + ) -> Result { + let mut req = req; + let invalid_present = req + .headers() + .get(REQUEST_AUTH_USER_INVALID_HEADER) + .is_some(); + let user_id_header = req.headers().get(REQUEST_AUTH_USER_ID_HEADER).cloned(); + + if invalid_present { + return handle_unauthenticated().await; + } + + let user = req + .extensions_mut() + .get_or_insert_with(|| RequestInfo { user_id: None }); + user.user_id = Some(if let Some(user_id_header) = user_id_header { + let user_id_str = user_id_header + .to_str() + .map_err(|_| StatusCode::UNAUTHORIZED)?; + Uuid::parse_str(user_id_str).map_err(|_| StatusCode::UNAUTHORIZED)? + } else { + Uuid::new_v4() + }); + + Ok(next.run(req).await) + } +} diff --git a/apps/api/src/routes/api.rs b/apps/api/src/routes/api.rs index 3546a2b..451722e 100644 --- a/apps/api/src/routes/api.rs +++ b/apps/api/src/routes/api.rs @@ -1,5 +1,6 @@ mod auth; mod health; +mod helper; mod openapi; mod restricted; diff --git a/apps/api/src/routes/api/health/info.rs b/apps/api/src/routes/api/health/info.rs index f5d2ecf..c6d7aaa 100644 --- a/apps/api/src/routes/api/health/info.rs +++ b/apps/api/src/routes/api/health/info.rs @@ -78,24 +78,12 @@ pub async fn get_health_info( #[cfg(test)] mod test { - use crate::configs::FromConfig; - use crate::services::agent_client::AgentService; - use crate::{ - routes::{AppState, api::health::state::HealthState}, - services::{ - auth::{ - authentication::{ - AuthenticationServiceImpl, strategies::password::PasswordStrategy, - }, - user::UserServiceImpl, - }, - server_state::ServerStateService, - settings::SettingsService, - }, - }; - use super::*; - use agent_client::apis::configuration::Configuration; + + use crate::configs::FromConfig; + use crate::routes::{AppState, api::health::state::HealthState}; + use crate::services::get_app_service; + use axum::body::to_bytes; use axum::{ Router, @@ -116,18 +104,10 @@ mod test { let app_state = Arc::new(AppState { database_connection: db.clone(), config: Arc::new(crate::configs::ProgramSettings::mock()), - service: Arc::new(crate::routes::AppService { - settings: Arc::new(SettingsService::new(db.clone())), - auth_state: crate::routes::AuthState { - strategy: crate::routes::AuthStrategy { - password: Arc::new(PasswordStrategy::new(db.clone())), - }, - authentication: Arc::new(AuthenticationServiceImpl::new(None)), - }, - user: Arc::new(UserServiceImpl::new(db.clone())), - server_state: Arc::new(ServerStateService::new(db.clone())), - agent_client: Arc::new(AgentService::new(Configuration::default())), - }), + service: Arc::new(get_app_service( + &db.clone(), + &crate::configs::ProgramSettings::mock(), + )), }); let app = Router::new() diff --git a/apps/api/src/routes/api/helper.rs b/apps/api/src/routes/api/helper.rs new file mode 100644 index 0000000..bc8665b --- /dev/null +++ b/apps/api/src/routes/api/helper.rs @@ -0,0 +1 @@ +pub mod pagination; diff --git a/apps/api/src/routes/api/helper/pagination.rs b/apps/api/src/routes/api/helper/pagination.rs new file mode 100644 index 0000000..8fca585 --- /dev/null +++ b/apps/api/src/routes/api/helper/pagination.rs @@ -0,0 +1,76 @@ +use axum::{ + extract::FromRequestParts, + http::{StatusCode, request::Parts}, +}; +use serde::{Deserialize, Serialize}; + +use crate::helpers::database::PaginationFilter; + +#[derive(Serialize, Deserialize, utoipa::ToSchema, Clone)] +/// Pagination parameters for API requests +pub struct Pagination { + /// Page number (1-based) + pub page: u32, + /// Items per page + pub per_page: u32, +} + +impl Default for Pagination { + fn default() -> Self { + Self { + page: 1, + per_page: 20, + } + } +} + +impl From for PaginationFilter { + fn from(pagination: Pagination) -> Self { + Self { + page: pagination.page as u64, + per_page: pagination.per_page as u64, + } + } +} + +#[derive(Serialize, Deserialize, utoipa::ToSchema)] +/// Pagination information included in API responses +pub struct PaginationInfo { + /// Total number of items + pub total_items: u64, + /// Total number of pages + pub total_pages: u32, + /// Current page number + pub current_page: u32, + /// Items per page + pub per_page: u32, +} + +/// Extractor for pagination parameters from query string +pub struct ExtractPagination(pub Pagination); + +impl FromRequestParts for ExtractPagination +where + S: Send + Sync, +{ + type Rejection = (StatusCode, &'static str); + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + let query = parts.uri.query().unwrap_or(""); + let pagination: Pagination = serde_urlencoded::from_str(query).unwrap_or_default(); + + // validation + if pagination.page == 0 { + return Err((StatusCode::BAD_REQUEST, "page must be greater than 0")); + } + + if pagination.per_page < 1 || pagination.per_page > 100 { + return Err(( + StatusCode::BAD_REQUEST, + "per_page must be between 1 and 100", + )); + } + + Ok(ExtractPagination(pagination)) + } +} diff --git a/apps/api/src/routes/api/openapi.rs b/apps/api/src/routes/api/openapi.rs index f37db25..cc855e3 100644 --- a/apps/api/src/routes/api/openapi.rs +++ b/apps/api/src/routes/api/openapi.rs @@ -3,6 +3,7 @@ pub mod tag { pub const HEALTH_TAG: &str = "Health"; pub const AUTH_TAG: &str = "Authentication"; pub const USER_TAG: &str = "User"; + pub const NGINX_TAG: &str = "Nginx"; } #[derive(utoipa::OpenApi)] @@ -14,6 +15,16 @@ pub mod tag { crate::routes::api::auth::init_admin::init_admin, // User management paths crate::routes::api::restricted::user::me::get_user_info, + // Nginx upstream management + crate::routes::api::restricted::nginx::upstream::create_upstream::create_upstream, + crate::routes::api::restricted::nginx::upstream::create_upstream_target::add_upstream_target, + crate::routes::api::restricted::nginx::upstream::get_upstream::get_upstream_list, + crate::routes::api::restricted::nginx::upstream::get_upstream::get_upstream, + crate::routes::api::restricted::nginx::upstream::get_upstream_target::get_upstream_target, + crate::routes::api::restricted::nginx::upstream::update_upstream::update_upstream, + crate::routes::api::restricted::nginx::upstream::update_upstream_target::update_upstream_target, + crate::routes::api::restricted::nginx::upstream::remove_upstream::remove_upstream, + crate::routes::api::restricted::nginx::upstream::remove_upstream_target::remove_upstream_target, ), components( schemas(crate::routes::api::health::info::HealthInfo), @@ -22,11 +33,25 @@ pub mod tag { schemas(crate::routes::api::auth::init_admin::AdminInitRequest), // User management schemas schemas(crate::routes::api::restricted::user::me::UserInfo), + // Nginx upstream schemas + schemas(crate::routes::api::restricted::nginx::upstream::create_upstream::CreateUpstreamRequestBody), + schemas(crate::routes::api::restricted::nginx::upstream::create_upstream_target::CreateUpstreamTargetInfo), + schemas(crate::routes::api::restricted::nginx::upstream::get_upstream::GetUpstreamParams), + schemas(crate::routes::api::restricted::nginx::upstream::get_upstream_target::GetUpstreamTargetsParams), + schemas(crate::routes::api::restricted::nginx::upstream::info::response::UpstreamTargetInfo), + schemas(crate::routes::api::restricted::nginx::upstream::info::response::UpstreamInfoResponse), + schemas(crate::routes::api::restricted::nginx::upstream::info::response::UpstreamListResponse), + schemas(crate::routes::api::restricted::nginx::upstream::info::response::UpstreamTargetInfoResponse), + schemas(crate::routes::api::restricted::nginx::upstream::update_upstream::UpdateUpstreamRequestBody), + schemas(crate::routes::api::restricted::nginx::upstream::update_upstream_target::UpdateUpstreamTargetRequestBody), + schemas(crate::routes::api::restricted::nginx::upstream::info::response::UpdateUpstreamInfoResponse), + schemas(crate::routes::api::restricted::nginx::upstream::info::response::UpdateUpstreamTargetInfoResponse), ), tags( (name = tag::HEALTH_TAG, description = "Health information API"), (name = tag::AUTH_TAG, description = "Authentication API"), - (name = tag::USER_TAG, description = "User management API") + (name = tag::USER_TAG, description = "User management API"), + (name = tag::NGINX_TAG, description = "Nginx management API") ) )] pub struct ApiDoc; diff --git a/apps/api/src/routes/api/restricted.rs b/apps/api/src/routes/api/restricted.rs index 17be841..023d8f3 100644 --- a/apps/api/src/routes/api/restricted.rs +++ b/apps/api/src/routes/api/restricted.rs @@ -1,3 +1,4 @@ +pub mod nginx; pub mod user; use std::sync::Arc; @@ -9,6 +10,7 @@ use crate::{middlewares::require_auth::require_auth, routes::AppState}; pub fn get_restricted_router(state: Arc) -> Router { Router::new() .nest("/user", user::get_user_router(state.clone())) + .nest("/nginx", nginx::get_nginx_router(state.clone())) .layer(axum::middleware::from_fn_with_state( state.clone(), require_auth, diff --git a/apps/api/src/routes/api/restricted/nginx.rs b/apps/api/src/routes/api/restricted/nginx.rs new file mode 100644 index 0000000..50803cf --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx.rs @@ -0,0 +1,11 @@ +pub mod upstream; + +use std::sync::Arc; + +use axum::Router; + +use crate::routes::AppState; + +pub fn get_nginx_router(state: Arc) -> Router { + Router::new().merge(upstream::get_upstream_router(state.clone())) +} diff --git a/apps/api/src/routes/api/restricted/nginx/upstream.rs b/apps/api/src/routes/api/restricted/nginx/upstream.rs new file mode 100644 index 0000000..00fd867 --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/upstream.rs @@ -0,0 +1,43 @@ +pub mod create_upstream; +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; + +use std::sync::Arc; + +use axum::{ + Router, + routing::{get, post}, +}; + +use crate::routes::AppState; + +pub fn get_upstream_router(state: Arc) -> Router { + Router::new() + .route( + "/upstreams", + get(get_upstream::get_upstream_list).post(create_upstream::create_upstream), + ) + .route( + "/upstreams/{upstream_id}", + get(get_upstream::get_upstream) + .patch(update_upstream::update_upstream) + .delete(remove_upstream::remove_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) + .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/create_upstream.rs b/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream.rs new file mode 100644 index 0000000..67cb4e7 --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream.rs @@ -0,0 +1,367 @@ +use std::sync::Arc; + +use axum::{Json, extract::State, response::Result as AxumResult}; +use sea_orm::TransactionTrait; + +use crate::{ + errors::api_error::ApiError, + middlewares::request_info::AuthenticatedRequestInfo, + routes::{ + AppState, + api::{ + openapi::tag::NGINX_TAG, + 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] +#[utoipa::path( + post, + path = "/api/nginx/upstreams", + request_body = CreateUpstreamRequestBody, + responses( + (status = 200, description = "Upstream created successfully", body = UpstreamInfoResponse), + (status = 401, description = "Unauthorized"), + (status = 422, description = "Invalid request"), + (status = 500, description = "Internal server error"), + ), + tag = NGINX_TAG, +)] +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 mut tx = state.database_connection.begin().await?; + let upstream_info = upstream_service + .create_upstream(create_info, Some(&mut tx)) + .await?; + + state + .service + .nginx + .regenerate_and_apply_config(state.service.agent_client.clone(), Some(&mut tx)) + .await?; + + tx.commit().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::{agent_client::MockAgentService, get_mock_app_service}, + }; + + fn get_router_with_state(db: DatabaseConnection) -> axum::Router { + let program_settings = ProgramSettings::mock(); + let mut mock = MockAgentService::new(); + mock.expect_validate().returning(|_cfg| Ok(())); + mock.expect_apply().returning(|_cfg| Ok(())); + let mock_agent = Arc::new(mock); + let app_service = + get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent); + 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()]]) + // additional query result for regenerate_and_apply_config -> generate_config + // `find_with_related` returns rows of `(upstream, Option)` which + // the mock DB expects as `(Model, Option)` per row. + .append_query_results(vec![vec![(up_model.clone(), Some(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_agent_error_returns_internal() { + 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(), + }; + + // configure mock agent to error on apply + let mut mock = MockAgentService::new(); + mock.expect_validate().returning(|_cfg| Ok(())); + mock.expect_apply().returning(|_cfg| { + Err( + crate::services::agent_client::AgentError::ApplicationFailed( + "internal".to_string(), + "Failed to communicate with the agent.".to_string(), + ), + ) + }); + let mock_agent = Arc::new(mock); + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![vec![up_model.clone()]]) + .append_query_results(vec![vec![target_model.clone()]]) + .append_query_results(vec![vec![(up_model.clone(), Some(target_model.clone()))]]) + .into_connection(); + + let program_settings = ProgramSettings::mock(); + let app_service = + get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent); + let state = Arc::new(crate::routes::AppState { + database_connection: Arc::new(db), + service: Arc::new(app_service), + config: Arc::new(program_settings), + }); + + let router = get_upstream_router(state).layer(axum::middleware::from_fn( + crate::middlewares::require_auth::mock::mock_require_auth, + )); + 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(axum::http::StatusCode::INTERNAL_SERVER_ERROR); + } + + #[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); + } +} 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..1584871 --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream_target.rs @@ -0,0 +1,310 @@ +use std::sync::Arc; + +use axum::{Json, extract::State, response::Result as AxumResult}; +use sea_orm::TransactionTrait; + +use crate::{ + errors::api_error::ApiError, + middlewares::request_info::AuthenticatedRequestInfo, + routes::{ + AppState, + api::{ + openapi::tag::NGINX_TAG, + 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] +#[utoipa::path( + post, + path = "/api/nginx/upstreams/{upstream_id}/targets", + request_body = CreateUpstreamTargetInfo, + responses( + (status = 200, description = "Upstream target created successfully", body = UpstreamTargetInfoResponse), + (status = 401, description = "Unauthorized"), + (status = 422, description = "Invalid request"), + (status = 500, description = "Internal server error"), + ), + tag = NGINX_TAG, +)] +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 mut tx = state.database_connection.begin().await?; + let upstream_info = upstream_service + .create_upstream_target(create_info, Some(&mut tx)) + .await?; + + state + .service + .nginx + .regenerate_and_apply_config(state.service.agent_client.clone(), Some(&mut tx)) + .await?; + + tx.commit().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_target::CreateUpstreamTargetInfo, get_upstream_router, + }, + services::{agent_client::MockAgentService, get_mock_app_service}, + }; + + fn get_router_with_state(db: DatabaseConnection) -> axum::Router { + let program_settings = ProgramSettings::mock(); + let mut mock = MockAgentService::new(); + mock.expect_validate().returning(|_cfg| Ok(())); + mock.expect_apply().returning(|_cfg| Ok(())); + let mock_agent = Arc::new(mock); + let app_service = + get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent); + 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_agent_error_returns_internal() { + 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 up_model = upstream::Model { + id: up_id, + name: "test_upstream".to_string(), + protocol: "http".to_string(), + algorithm: "round_robin".to_string(), + sticky_session: false, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + // configure mock agent to return an error on apply + let mut mock = MockAgentService::new(); + mock.expect_validate().returning(|_cfg| Ok(())); + mock.expect_apply().returning(|_cfg| { + Err( + crate::services::agent_client::AgentError::ApplicationFailed( + "internal".to_string(), + "Failed to communicate with the agent.".to_string(), + ), + ) + }); + let mock_agent = Arc::new(mock); + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![vec![target_model.clone()]]) + .append_query_results(vec![vec![(up_model.clone(), Some(target_model.clone()))]]) + .into_connection(); + + let program_settings = ProgramSettings::mock(); + let app_service = + get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent); + let state = Arc::new(crate::routes::AppState { + database_connection: Arc::new(db), + service: Arc::new(app_service), + config: Arc::new(program_settings), + }); + + let router = get_upstream_router(state).layer(axum::middleware::from_fn( + crate::middlewares::require_auth::mock::mock_require_auth, + )); + 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(axum::http::StatusCode::INTERNAL_SERVER_ERROR); + } + + #[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 up_model = upstream::Model { + id: up_id, + name: "test_upstream".to_string(), + protocol: "http".to_string(), + algorithm: "round_robin".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![target_model.clone()]]) + // additional query result for regenerate_and_apply_config -> generate_config + .append_query_results(vec![vec![(up_model.clone(), Some(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.rs b/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream.rs new file mode 100644 index 0000000..827f282 --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream.rs @@ -0,0 +1,343 @@ +use std::sync::Arc; + +use axum::{ + Json, + extract::{Path, Query, State}, + response::Result as AxumResult, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + errors::{api_error::ApiError, service_error::ServiceError}, + routes::{ + AppState, + api::{ + helper::pagination::{ExtractPagination, PaginationInfo}, + openapi::tag::NGINX_TAG, + restricted::nginx::upstream::info::response::{ + UpstreamInfoResponse, UpstreamListResponse, + }, + }, + }, + services::nginx::upstream::GetUpstreamOptions, +}; + +#[derive(Serialize, Deserialize, utoipa::ToSchema)] +pub struct GetUpstreamParams { + pub include_targets: Option, +} + +pub struct ConcreteGetUpstreamParams { + pub include_targets: bool, +} + +impl From for ConcreteGetUpstreamParams { + fn from(params: GetUpstreamParams) -> Self { + Self { + include_targets: params.include_targets.unwrap_or(false), + } + } +} + +#[utoipa::path( + get, + path = "/api/nginx/upstreams", + responses( + (status = 200, description = "List upstreams", body = UpstreamListResponse), + (status = 500, description = "Internal server error"), + ), + tag = NGINX_TAG, +)] +pub async fn get_upstream_list( + ExtractPagination(pagination): ExtractPagination, + State(state): State>, +) -> AxumResult, ServiceError> { + let upstream_service = &state.service.nginx.get_upstream_service(); + + let (upstreams_res, upstream_count_res) = tokio::join!( + upstream_service.get_upstreams( + Some(pagination.clone().into()), + Some(GetUpstreamOptions { + include_targets: true, + filter_by_enabled: false, + }), + None, + ), + upstream_service.get_total_upstreams(None, None), + ); + + let upstreams = upstreams_res?; + let upstream_count = upstream_count_res?; + + // + Ok(Json(UpstreamListResponse { + items: upstreams.into_iter().map(|u| u.into()).collect(), + pagination: PaginationInfo { + total_items: upstream_count, + total_pages: if upstream_count == 0 { + 0 + } else { + (upstream_count as f32 / pagination.per_page as f32).ceil() as u32 + }, + current_page: pagination.page, + per_page: pagination.per_page, + }, + })) +} + +#[utoipa::path( + get, + path = "/api/nginx/upstreams/{upstream_id}", + responses( + (status = 200, description = "Get upstream info", body = UpstreamInfoResponse), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + tag = NGINX_TAG, +)] +pub async fn get_upstream( + Path(upstream_id): Path, + Query(params): Query, + State(_state): State>, +) -> AxumResult, ApiError> { + let concrete_params: ConcreteGetUpstreamParams = params.into(); + let upstream_service = &_state.service.nginx.get_upstream_service(); + let upstream_info = if concrete_params.include_targets { + upstream_service + .get_upstream( + upstream_id, + Some(GetUpstreamOptions { + include_targets: true, + filter_by_enabled: false, + }), + None, + ) + .await? + } else { + upstream_service + .get_upstream(upstream_id, None, None) + .await? + }; + + // + Ok(Json(upstream_info.into())) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{collections::BTreeMap, sync::Arc}; + + use axum::http::StatusCode; + use axum_test::TestServer; + use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, Value}; + + use database::generated::entities::{upstream, upstream_target}; + + use crate::{ + configs::{FromConfig, ProgramSettings}, + routes::api::restricted::nginx::upstream::{ + get_upstream_router, info::response::UpstreamInfoResponse, + }, + 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) + } + + #[tokio::test] + async fn handler_get_upstream_list_returns_list() { + let u1 = upstream::Model { + id: uuid::Uuid::new_v4(), + name: "u1".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 u2 = upstream::Model { + id: uuid::Uuid::new_v4(), + name: "u2".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![ + (u1.clone(), None::), + (u2.clone(), None::), + ]]) + .append_query_results(vec![vec![BTreeMap::from([( + "count".to_string(), + Value::BigInt(Some(2)), + )])]]) + .into_connection(); + + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let res = server.get("/upstreams").await; + res.assert_status_ok(); + let body = res.json::(); + assert_eq!(body.items.len(), 2); + assert_eq!(body.pagination.current_page, 1u32); + assert_eq!(body.pagination.total_pages, 1u32); + } + + #[tokio::test] + async fn handler_get_upstream_with_targets_returns_targets() { + let up_id = uuid::Uuid::new_v4(); + + let up_model = upstream::Model { + id: up_id, + name: "with_targets".to_string(), + protocol: "http".to_string(), + algorithm: "least_conn".to_string(), + sticky_session: true, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let target_model = upstream_target::Model { + id: uuid::Uuid::new_v4(), + 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) + // find_by_id -> returns upstream model + .append_query_results(vec![vec![up_model.clone()]]) + // find targets -> returns the target(s) + .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 url = format!("/upstreams/{}?include_targets=true", up_id); + let res = server.get(&url).await; + res.assert_status_ok(); + let body = res.json::(); + assert_eq!(body.id, up_id); + assert_eq!(body.upstream_targets.len(), 1); + assert_eq!(body.upstream_targets[0].target_host, "127.0.0.1"); + } + + #[tokio::test] + async fn extractor_pagination_validation_rejects_bad_values() { + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![Vec::::new()]) + .append_query_results(vec![vec![BTreeMap::from([( + "count".to_string(), + Value::BigInt(Some(0)), + )])]]) + .into_connection(); + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + // page = 0 should be rejected + let res = server.get("/upstreams?page=0&per_page=10").await; + res.assert_status(StatusCode::BAD_REQUEST); + + // per_page out of range should be rejected + let res = server.get("/upstreams?page=1&per_page=0").await; + res.assert_status(StatusCode::BAD_REQUEST); + + // valid values accepted + let res = server.get("/upstreams?page=2&per_page=5").await; + res.assert_status_ok(); + let body = res.json::(); + assert_eq!(body.pagination.current_page, 2u32); + assert_eq!(body.pagination.per_page, 5u32); + } + + #[tokio::test] + async fn handler_get_upstream_not_found_returns_service_error() { + let up_id = uuid::Uuid::new_v4(); + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![Vec::::new()]) + .into_connection(); + + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let url = format!("/upstreams/{}?include_targets=false", up_id); + let res = server.get(&url).await; + res.assert_status(StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn handler_get_upstream_without_targets_returns_info() { + let up_id = uuid::Uuid::new_v4(); + + let up_model = upstream::Model { + id: up_id, + name: "simple_up".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) + // find_by_id -> returns upstream model + .append_query_results(vec![vec![up_model.clone()]]) + .into_connection(); + + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + // include_targets omitted -> should not include targets + let url = format!("/upstreams/{}", up_id); + let res = server.get(&url).await; + res.assert_status_ok(); + let body = res.json::(); + assert_eq!(body.id, up_id); + assert!(body.upstream_targets.is_empty()); + } + + #[tokio::test] + async fn handler_get_upstream_list_empty_returns_empty_items() { + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![Vec::::new()]) + .append_query_results(vec![vec![BTreeMap::from([( + "count".to_string(), + Value::BigInt(Some(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.get("/upstreams?page=3&per_page=10").await; + res.assert_status_ok(); + let body = res.json::(); + assert_eq!(body.items.len(), 0); + assert_eq!(body.pagination.current_page, 3u32); + assert_eq!(body.pagination.per_page, 10u32); + } +} 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 new file mode 100644 index 0000000..6c012ce --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream_target.rs @@ -0,0 +1,193 @@ +use std::sync::Arc; + +use axum::{ + Json, + extract::{Path, Query, State}, + response::Result as AxumResult, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + errors::api_error::ApiError, + routes::{ + AppState, + api::{ + openapi::tag::NGINX_TAG, + restricted::nginx::upstream::info::response::UpstreamTargetInfo, + }, + }, +}; + +#[derive(Serialize, Deserialize, utoipa::ToSchema)] +pub struct GetUpstreamTargetsParams { + pub include_upstream: Option, +} + +pub struct ConcreteGetUpstreamTargetsParams { + pub include_upstream: bool, +} + +impl From for ConcreteGetUpstreamTargetsParams { + fn from(params: GetUpstreamTargetsParams) -> Self { + Self { + include_upstream: params.include_upstream.unwrap_or(false), + } + } +} + +#[utoipa::path( + get, + path = "/api/nginx/upstream_targets/{upstream_target_id}", + responses( + (status = 200, description = "Get upstream target info", body = UpstreamTargetInfo), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + tag = NGINX_TAG, +)] +pub async fn get_upstream_target( + Path(upstream_target_id): Path, + Query(params): Query, + State(_state): State>, +) -> AxumResult, ApiError> { + let concrete_params: ConcreteGetUpstreamTargetsParams = params.into(); + let upstream_service = &_state.service.nginx.get_upstream_service(); + let upstream_target_info = upstream_service + .get_upstream_target( + upstream_target_id, + if concrete_params.include_upstream { + Some(crate::services::nginx::upstream::GetUpstreamTargetOptions { + include_upstream: true, + }) + } else { + None + }, + None, + ) + .await?; + Ok(Json(upstream_target_info.into())) +} + +#[cfg(test)] +mod tests { + use super::*; + 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}; + + use crate::routes::api::restricted::nginx::upstream::get_upstream_router; + use crate::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) + } + + #[tokio::test] + async fn handler_get_upstream_target_with_upstream_returns_upstream() { + let up_id = uuid::Uuid::new_v4(); + + let up_model = upstream::Model { + id: up_id, + name: "with_targets".to_string(), + protocol: "http".to_string(), + algorithm: "least_conn".to_string(), + sticky_session: true, + created_by: None, + 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(), + }; + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + // query returns joined (upstream_target, upstream) + .append_query_results(vec![vec![(target_model.clone(), Some(up_model.clone()))]]) + .into_connection(); + + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let url = format!("/upstream_targets/{}?include_upstream=true", target_id); + let res = server.get(&url).await; + res.assert_status_ok(); + let text = res.text(); + let body: UpstreamTargetInfo = serde_json::from_str(&text).expect("failed to parse json"); + assert_eq!(body.upstream_id, up_id); + assert!(body.upstream.is_some()); + let upstream = body.upstream.expect("upstream to be present"); + assert_eq!(upstream.id, up_id); + assert_eq!(upstream.name, "with_targets"); + } + + #[tokio::test] + async fn handler_get_upstream_target_without_upstream_returns_info() { + let target_id = uuid::Uuid::new_v4(); + + let target_model = upstream_target::Model { + id: target_id, + upstream_id: uuid::Uuid::new_v4(), + target_host: "10.0.0.1".to_string(), + target_port: 9090, + weight: 5, + 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 url = format!("/upstream_targets/{}", target_id); + let res = server.get(&url).await; + res.assert_status_ok(); + let text = res.text(); + let body: UpstreamTargetInfo = serde_json::from_str(&text).expect("failed to parse json"); + assert_eq!(body.id, target_id); + assert!(body.upstream.is_none()); + } + + #[tokio::test] + async fn handler_get_upstream_target_not_found_returns_service_error() { + let target_id = uuid::Uuid::new_v4(); + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![Vec::::new()]) + .into_connection(); + + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let url = format!("/upstream_targets/{}?include_upstream=false", target_id); + let res = server.get(&url).await; + res.assert_status(StatusCode::NOT_FOUND); + } +} diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/info.rs b/apps/api/src/routes/api/restricted/nginx/upstream/info.rs new file mode 100644 index 0000000..4c6f2cd --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/upstream/info.rs @@ -0,0 +1 @@ +pub mod response; 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 new file mode 100644 index 0000000..0a8d717 --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/upstream/info/response.rs @@ -0,0 +1,232 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::routes::api::helper::pagination::PaginationInfo; + +#[derive(Serialize, Deserialize, utoipa::ToSchema)] +pub struct UpstreamTargetInfo { + pub id: uuid::Uuid, + pub target_host: String, + pub target_port: i64, + pub enabled: bool, + pub is_backup: bool, + pub weight: i32, + // + pub created_at: DateTime, + pub updated_at: DateTime, + // + pub upstream_id: Uuid, + pub upstream: Option, +} + +#[derive(Serialize, Deserialize, utoipa::ToSchema)] +pub struct UpstreamBasicInfo { + pub id: Uuid, + pub name: String, + pub protocol: String, + // + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl From + for UpstreamTargetInfo +{ + fn from(info: crate::services::nginx::info::upstream_target::UpstreamTargetInfo) -> Self { + Self { + id: info.id, + target_host: info.target_host, + target_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, + upstream: info.upstream.map(|u| UpstreamBasicInfo { + id: u.id, + name: u.name, + protocol: u.protocol, + created_at: u.created_at, + updated_at: u.updated_at, + }), + } + } +} + +#[derive(Serialize, Deserialize, utoipa::ToSchema)] +pub struct UpstreamTargetBasicInfo { + pub id: uuid::Uuid, + pub target_host: String, + pub target_port: i64, + pub enabled: bool, + pub is_backup: bool, + pub weight: i32, + // + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl From + for UpstreamTargetBasicInfo +{ + fn from(info: crate::services::nginx::info::upstream_target::UpstreamTargetInfo) -> Self { + Self { + id: info.id, + target_host: info.target_host, + target_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, + } + } +} + +#[derive(Serialize, Deserialize, utoipa::ToSchema)] +pub struct UpstreamInfoResponse { + 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 UpstreamInfoResponse { + 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 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, + } + } +} + +#[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/remove_upstream.rs b/apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream.rs new file mode 100644 index 0000000..9b8ea83 --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream.rs @@ -0,0 +1,238 @@ +use std::sync::Arc; + +use axum::{ + Json, + extract::{Path, State}, + response::Result as AxumResult, +}; +use sea_orm::TransactionTrait; +use uuid::Uuid; + +use crate::{ + errors::api_error::ApiError, + middlewares::request_info::AuthenticatedRequestInfo, + routes::{AppState, api::openapi::tag::NGINX_TAG}, +}; + +#[utoipa::path( + delete, + path = "/api/nginx/upstreams/{upstream_id}", + responses( + (status = 200, description = "Upstream removed successfully", body = ()), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + tag = NGINX_TAG, +)] +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(); + + let mut tx = state.database_connection.begin().await?; + upstream_service + .delete_upstream(upstream_id, Some(&mut tx)) + .await?; + + state + .service + .nginx + .regenerate_and_apply_config(state.service.agent_client.clone(), Some(&mut tx)) + .await?; + + tx.commit().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, 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::{agent_client::MockAgentService, get_mock_app_service}, + }; + + fn get_router_with_state(db: DatabaseConnection) -> axum::Router { + let program_settings = ProgramSettings::mock(); + let mut mock = MockAgentService::new(); + mock.expect_validate().returning(|_cfg| Ok(())); + mock.expect_apply().returning(|_cfg| Ok(())); + let mock_agent = Arc::new(mock); + let app_service = + get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent); + 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 target_model = upstream_target::Model { + id: uuid::Uuid::new_v4(), + 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![existing.clone()]]) + .append_exec_results(vec![ + MockExecResult { + rows_affected: 1, + last_insert_id: 0, + }, + MockExecResult { + rows_affected: 1, + last_insert_id: 0, + }, + ]) + // additional query result for regenerate_and_apply_config -> generate_config + .append_query_results(vec![vec![(existing.clone(), Some(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 res = server.delete(&format!("/upstreams/{}", up_id)).await; + + res.assert_status_ok(); + } + + #[tokio::test] + async fn handler_remove_upstream_agent_error_returns_internal() { + 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 target_model = upstream_target::Model { + id: uuid::Uuid::new_v4(), + 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 mut mock = MockAgentService::new(); + mock.expect_validate().returning(|_cfg| Ok(())); + mock.expect_apply().returning(|_cfg| { + Err( + crate::services::agent_client::AgentError::ApplicationFailed( + "internal".to_string(), + "Failed to communicate with the agent.".to_string(), + ), + ) + }); + let mock_agent = Arc::new(mock); + + 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, + }, + ]) + .append_query_results(vec![vec![(existing.clone(), Some(target_model.clone()))]]) + .into_connection(); + + let program_settings = ProgramSettings::mock(); + let app_service = + get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent); + let state = Arc::new(crate::routes::AppState { + database_connection: Arc::new(db), + service: Arc::new(app_service), + config: Arc::new(program_settings), + }); + + let router = get_upstream_router(state).layer(axum::middleware::from_fn( + crate::middlewares::require_auth::mock::mock_require_auth, + )); + let server = TestServer::new(router).expect("failed to create test server"); + + let res = server.delete(&format!("/upstreams/{}", up_id)).await; + res.assert_status(axum::http::StatusCode::INTERNAL_SERVER_ERROR); + } + + #[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..e4c858e --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream_target.rs @@ -0,0 +1,230 @@ +use std::sync::Arc; + +use axum::{ + Json, + extract::{Path, State}, + response::Result as AxumResult, +}; +use sea_orm::TransactionTrait; +use uuid::Uuid; + +use crate::{ + errors::api_error::ApiError, + middlewares::request_info::AuthenticatedRequestInfo, + routes::{AppState, api::openapi::tag::NGINX_TAG}, +}; + +#[utoipa::path( + delete, + path = "/api/nginx/upstream_targets/{upstream_target_id}", + responses( + (status = 200, description = "Upstream target removed successfully", body = ()), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + tag = NGINX_TAG, +)] +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(); + + let mut tx = state.database_connection.begin().await?; + upstream_service + .delete_upstream_target(upstream_target_id, Some(&mut tx)) + .await?; + + state + .service + .nginx + .regenerate_and_apply_config(state.service.agent_client.clone(), Some(&mut tx)) + .await?; + + tx.commit().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, 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::{agent_client::MockAgentService, get_mock_app_service}, + }; + + fn get_router_with_state(db: DatabaseConnection) -> axum::Router { + let program_settings = ProgramSettings::mock(); + let mut mock = MockAgentService::new(); + mock.expect_validate().returning(|_cfg| Ok(())); + mock.expect_apply().returning(|_cfg| Ok(())); + let mock_agent = Arc::new(mock); + let app_service = + get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent); + 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 up_model = upstream::Model { + id: current_model.upstream_id, + name: "test_upstream".to_string(), + protocol: "http".to_string(), + algorithm: "round_robin".to_string(), + sticky_session: false, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + 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, + }]) + // additional query result for regenerate_and_apply_config -> generate_config + .append_query_results(vec![vec![(up_model.clone(), Some(current_model.clone()))]]) + .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_agent_error_returns_internal() { + 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(), + }; + + let up_model = upstream::Model { + id: current_model.upstream_id, + name: "test_upstream".to_string(), + protocol: "http".to_string(), + algorithm: "round_robin".to_string(), + sticky_session: false, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let mut mock = MockAgentService::new(); + mock.expect_validate().returning(|_cfg| Ok(())); + mock.expect_apply().returning(|_cfg| { + Err( + crate::services::agent_client::AgentError::ApplicationFailed( + "internal".to_string(), + "Failed to communicate with the agent.".to_string(), + ), + ) + }); + let mock_agent = Arc::new(mock); + + 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, + }]) + .append_query_results(vec![vec![(up_model.clone(), Some(current_model.clone()))]]) + .into_connection(); + + let program_settings = ProgramSettings::mock(); + let app_service = + get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent); + let state = Arc::new(crate::routes::AppState { + database_connection: Arc::new(db), + service: Arc::new(app_service), + config: Arc::new(program_settings), + }); + + let router = get_upstream_router(state).layer(axum::middleware::from_fn( + crate::middlewares::require_auth::mock::mock_require_auth, + )); + let server = TestServer::new(router).expect("failed to create test server"); + + let res = server.delete(&format!("/upstream_targets/{}", ut_id)).await; + res.assert_status(axum::http::StatusCode::INTERNAL_SERVER_ERROR); + } + + #[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/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..86da499 --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/upstream/update_upstream.rs @@ -0,0 +1,318 @@ +use std::sync::Arc; + +use axum::{ + Json, + extract::{Path, State}, + response::Result as AxumResult, +}; +use sea_orm::TransactionTrait; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + errors::api_error::ApiError, + middlewares::request_info::AuthenticatedRequestInfo, + routes::{ + AppState, api::openapi::tag::NGINX_TAG, + 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, + } + } +} + +#[utoipa::path( + patch, + path = "/api/nginx/upstreams/{upstream_id}", + request_body = UpdateUpstreamRequestBody, + responses( + (status = 200, description = "Upstream updated successfully", body = UpdateUpstreamInfoResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + (status = 422, description = "Invalid request"), + (status = 500, description = "Internal server error"), + ), + tag = NGINX_TAG, +)] +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 mut tx = state.database_connection.begin().await?; + let r = upstream_service + .update_upstream(upstream_id, update_info, Some(&mut tx)) + .await?; + + state + .service + .nginx + .regenerate_and_apply_config(state.service.agent_client.clone(), Some(&mut tx)) + .await?; + + tx.commit().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, upstream_target}; + + use super::UpdateUpstreamRequestBody; + use crate::{ + configs::{FromConfig, ProgramSettings}, + middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER, + routes::api::restricted::nginx::upstream::get_upstream_router, + services::{agent_client::MockAgentService, get_mock_app_service}, + }; + + fn get_router_with_state(db: DatabaseConnection) -> axum::Router { + let program_settings = ProgramSettings::mock(); + let mut mock = MockAgentService::new(); + mock.expect_validate().returning(|_cfg| Ok(())); + mock.expect_apply().returning(|_cfg| Ok(())); + let mock_agent = Arc::new(mock); + let app_service = + get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent); + 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 up_model = current_model.clone(); + 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) + // additional query result for regenerate_and_apply_config -> generate_config + .append_query_results(vec![vec![( + up_model.clone(), + Option::::None, + )]]) + .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_agent_error_returns_internal() { + 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(), + }; + + let up_model = current_model.clone(); + + let mut mock = MockAgentService::new(); + mock.expect_validate().returning(|_cfg| Ok(())); + mock.expect_apply().returning(|_cfg| { + Err( + crate::services::agent_client::AgentError::ApplicationFailed( + "internal".to_string(), + "Failed to communicate with the agent.".to_string(), + ), + ) + }); + let mock_agent = Arc::new(mock); + + 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) + .append_query_results(vec![vec![( + up_model.clone(), + Option::::None, + )]]) + .into_connection(); + + let program_settings = ProgramSettings::mock(); + let app_service = + get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent); + let state = Arc::new(crate::routes::AppState { + database_connection: Arc::new(db), + service: Arc::new(app_service), + config: Arc::new(program_settings), + }); + + let router = get_upstream_router(state).layer(axum::middleware::from_fn( + crate::middlewares::require_auth::mock::mock_require_auth, + )); + 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(axum::http::StatusCode::INTERNAL_SERVER_ERROR); + } + + #[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..ef8ac57 --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/upstream/update_upstream_target.rs @@ -0,0 +1,348 @@ +use std::sync::Arc; + +use axum::{ + Json, + extract::{Path, State}, + response::Result as AxumResult, +}; +use sea_orm::TransactionTrait; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + errors::api_error::ApiError, + middlewares::request_info::AuthenticatedRequestInfo, + routes::{ + AppState, api::openapi::tag::NGINX_TAG, + 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), + } + } +} + +#[utoipa::path( + patch, + path = "/api/nginx/upstream_targets/{upstream_target_id}", + request_body = UpdateUpstreamTargetRequestBody, + responses( + (status = 200, description = "Upstream target updated successfully", body = UpdateUpstreamTargetInfoResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + (status = 422, description = "Invalid request"), + (status = 500, description = "Internal server error"), + ), + tag = NGINX_TAG, +)] +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 mut tx = state.database_connection.begin().await?; + let r = upstream_service + .update_upstream_target(upstream_target_id, update_info, Some(&mut tx)) + .await?; + + state + .service + .nginx + .regenerate_and_apply_config(state.service.agent_client.clone(), Some(&mut tx)) + .await?; + + tx.commit().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, upstream_target}; + + use super::UpdateUpstreamTargetRequestBody; + use crate::{ + configs::{FromConfig, ProgramSettings}, + middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER, + services::{agent_client::MockAgentService, get_mock_app_service}, + }; + + fn get_router_with_state(db: DatabaseConnection) -> axum::Router { + let program_settings = ProgramSettings::mock(); + let mut mock = MockAgentService::new(); + mock.expect_validate().returning(|_cfg| Ok(())); + mock.expect_apply().returning(|_cfg| Ok(())); + let mock_agent = Arc::new(mock); + let app_service = + get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent); + 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 up_model = upstream::Model { + id: current_model.upstream_id, + name: "test_upstream".to_string(), + protocol: "http".to_string(), + algorithm: "round_robin".to_string(), + sticky_session: false, + created_by: None, + 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()]]; + // additional query result for regenerate_and_apply_config -> generate_config + let third: Vec)>> = + vec![vec![(up_model.clone(), Some(updated_model.clone()))]]; + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(first) + .append_query_results(second) + .append_query_results(third) + .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); + } + + #[tokio::test] + async fn handler_update_upstream_target_agent_error_returns_internal() { + 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 up_model = upstream::Model { + id: current_model.upstream_id, + name: "test_upstream".to_string(), + protocol: "http".to_string(), + algorithm: "round_robin".to_string(), + sticky_session: false, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let mut mock = MockAgentService::new(); + mock.expect_validate().returning(|_cfg| Ok(())); + mock.expect_apply().returning(|_cfg| { + Err( + crate::services::agent_client::AgentError::ApplicationFailed( + "internal".to_string(), + "Failed to communicate with the agent.".to_string(), + ), + ) + }); + let mock_agent = Arc::new(mock); + + let first: Vec> = vec![vec![current_model.clone()]]; + let second: Vec> = vec![vec![updated_model.clone()]]; + let third: Vec)>> = + vec![vec![(up_model.clone(), Some(updated_model.clone()))]]; + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(first) + .append_query_results(second) + .append_query_results(third) + .into_connection(); + + let program_settings = ProgramSettings::mock(); + let app_service = + get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent); + let state = Arc::new(crate::routes::AppState { + database_connection: Arc::new(db), + service: Arc::new(app_service), + config: Arc::new(program_settings), + }); + + let router = axum::Router::new() + .route( + "/upstream_targets/{upstream_target_id}", + axum::routing::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, + )); + + 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(axum::http::StatusCode::INTERNAL_SERVER_ERROR); + } +} diff --git a/apps/api/src/routes/api/restricted/user/me.rs b/apps/api/src/routes/api/restricted/user/me.rs index a95d5de..a794c9a 100644 --- a/apps/api/src/routes/api/restricted/user/me.rs +++ b/apps/api/src/routes/api/restricted/user/me.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use axum::{ - Extension, Json, + Json, extract::State, http::StatusCode, response::{IntoResponse, Response}, @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use tracing::error; use crate::{ - middlewares::request_info::RequestInfo, + middlewares::request_info::AuthenticatedRequestInfo, routes::{AppState, api::openapi::tag::USER_TAG}, }; @@ -38,15 +38,9 @@ pub struct UserInfo { )] pub async fn get_user_info( State(app_state): State>, - request_info: Extension>, + request_info: AuthenticatedRequestInfo, ) -> Response { - let user_id = match request_info.user_id { - Some(id) => id, - None => { - error!("User ID not found in request info"); - return (StatusCode::UNAUTHORIZED).into_response(); - } - }; + let user_id = request_info.user_id; match app_state.service.user.get_user_by_id(user_id, None).await { Ok(user) => { diff --git a/apps/api/src/services.rs b/apps/api/src/services.rs index 8960c58..66fb373 100644 --- a/apps/api/src/services.rs +++ b/apps/api/src/services.rs @@ -1,5 +1,6 @@ pub mod agent_client; pub mod auth; +pub mod nginx; pub mod server_state; pub mod settings; @@ -7,14 +8,18 @@ use std::sync::Arc; use ::agent_client::apis::configuration::Configuration; +#[cfg(test)] +use crate::services::agent_client::MockAgentService; use crate::{ configs::ProgramSettings, routes::{self, AuthState}, services::{ + agent_client::{AgentService, AgentServiceImpl}, auth::{ authentication::{AuthenticationServiceImpl, strategies::password::PasswordStrategy}, user::{UserService, UserServiceImpl}, }, + nginx::NginxService, server_state::{ServerStateService, ServerStateStore}, settings::{SettingsService, SettingsStore}, }, @@ -28,7 +33,9 @@ pub struct AppService { pub user: ServiceState, pub server_state: ServiceState, #[allow(dead_code)] - pub agent_client: ServiceState, + pub nginx: ServiceState, + #[allow(dead_code)] + pub agent_client: ServiceState, } pub fn get_app_service( @@ -47,8 +54,32 @@ pub fn get_app_service( )), }, user: Arc::new(UserServiceImpl::new(db_connection.clone())), - agent_client: Arc::new(agent_client::AgentService::new(Configuration::from( + nginx: Arc::new(NginxService::new(db_connection.clone())), + agent_client: Arc::new(AgentServiceImpl::new(Configuration::from( settings.agent.clone(), ))), } } + +#[cfg(test)] +pub fn get_mock_app_service( + db_connection: &Arc, + settings: &ProgramSettings, + mock_agent: Arc, +) -> AppService { + AppService { + server_state: Arc::new(ServerStateService::new(db_connection.clone())), + settings: Arc::new(SettingsService::new(db_connection.clone())), + auth_state: routes::AuthState { + strategy: routes::AuthStrategy { + password: Arc::new(PasswordStrategy::new(db_connection.clone())), + }, + authentication: Arc::new(AuthenticationServiceImpl::new( + settings.auth.jwt_secret.clone(), + )), + }, + user: Arc::new(UserServiceImpl::new(db_connection.clone())), + nginx: Arc::new(NginxService::new(db_connection.clone())), + agent_client: mock_agent, + } +} diff --git a/apps/api/src/services/agent_client.rs b/apps/api/src/services/agent_client.rs index 8aa7d95..efb5ee4 100644 --- a/apps/api/src/services/agent_client.rs +++ b/apps/api/src/services/agent_client.rs @@ -1,21 +1,114 @@ -use std::sync::Arc; +use std::{os::unix::fs::FileTypeExt, sync::Arc}; -use agent_client::apis::{ApiClient, configuration::Configuration}; -use tracing::warn; +use agent_client::{ + apis::{ + Api, ApiClient, Error as ApiError, ResponseContent, + configuration::Configuration, + nginx_agent_api::{ValidateAndReloadParams, ValidateParams, WriteConfigParams}, + }, + models::{ValidateAndReloadBody, ValidateBody, WriteConfigBody}, +}; +use tracing::{error, warn}; -use crate::configs::agent::AgentSettings; +use crate::{configs::agent::AgentSettings, errors::service_error::ServiceError}; -pub struct AgentService { +#[derive(Debug)] +pub enum AgentError { + // (internal messages, user-facing messages) + #[allow(dead_code)] + ValidationFailed(String, String), + // (internal messages, user-facing messages) + ApplicationFailed(String, String), +} + +impl From for ServiceError { + fn from(err: AgentError) -> Self { + error!("Agent error occurred: {:?}", err); + match err { + AgentError::ValidationFailed(_internal, user) => ServiceError::InternalError(user), + AgentError::ApplicationFailed(_internal, user) => ServiceError::InternalError(user), + } + } +} + +impl From> for AgentError { + fn from(err: ResponseContent) -> Self { + let ResponseContent { + status, + content, + entity, + } = err; + { + let entity_str = entity + .map(|e| format!("{:?}", e)) + .unwrap_or_else(|| "".to_string()); + AgentError::ApplicationFailed( + format!( + "Agent responded with error status {}: {}, entity: {}", + status, content, entity_str + ), + "Agent reported an error during operation.".to_string(), + ) + } + } +} + +impl From> for AgentError { + fn from(err: ApiError) -> Self { + match err { + ApiError::ResponseError(resp) => AgentError::from(resp), + ApiError::Io(err) => AgentError::ApplicationFailed( + format!("IO error during agent communication: {}", err), + "Failed to communicate with the agent.".to_string(), + ), + ApiError::Reqwest(err) => AgentError::ApplicationFailed( + format!("Reqwest error during agent communication: {}", err), + "Failed to communicate with the agent.".to_string(), + ), + ApiError::Serde(err) => AgentError::ApplicationFailed( + format!("Serialization error during agent communication: {}", err), + "Failed to communicate with the agent.".to_string(), + ), + } + } +} + +#[cfg_attr(test, mockall::automock)] +#[async_trait::async_trait] +pub trait AgentService: Send + Sync { + #[allow(dead_code)] + fn get_client(&self) -> Arc; + + // TODO: improve error handling and reporting, error reasons + // validate configurations that has been created/updated before the given timestamp + #[allow(dead_code)] + async fn validate(&self, config: &str) -> Result<(), AgentError>; + // validate and apply configurations that has been created/updated before the given timestamp + async fn apply(&self, config: &str) -> Result<(), AgentError>; +} + +pub struct AgentServiceImpl { client: Arc, } +impl AgentServiceImpl { + pub fn new(config: impl Into>) -> Self { + let client = ApiClient::new(config.into()); + AgentServiceImpl { + client: Arc::new(client), + } + } +} + impl From for Configuration { fn from(settings: AgentSettings) -> Self { let mut config = Configuration::default(); let mut builder = reqwest::Client::builder(); let url = settings.socket_path; - if url.starts_with("unix://") { + // check if the url is a unix socket path + let is_socket = std::fs::metadata(&url).is_ok_and(|m| m.file_type().is_socket()); + if is_socket || url.starts_with("unix://") { builder = builder.unix_socket(url.to_string()); config.client = builder.build().expect("Failed to build reqwest client"); } else { @@ -27,17 +120,73 @@ impl From for Configuration { } } -impl AgentService { - pub fn new(config: impl Into>) -> Self { - let client = ApiClient::new(config.into()); - AgentService { - client: Arc::new(client), - } +#[async_trait::async_trait] +impl AgentService for AgentServiceImpl { + fn get_client(&self) -> Arc { + Arc::clone(&self.client) } - #[allow(dead_code)] - pub fn get_client(&self) -> Arc { - Arc::clone(&self.client) + async fn validate(&self, config: &str) -> Result<(), AgentError> { + let timestamp = chrono::Utc::now().timestamp_millis(); + let name = Self::get_config_name(true); + self._validate(&name, timestamp, config).await + } + + async fn apply(&self, config: &str) -> Result<(), AgentError> { + let timestamp = chrono::Utc::now().timestamp_millis(); + let name = Self::get_config_name(false); + self._validate(&name, timestamp, config).await?; + self._apply(&name, timestamp).await + } +} + +impl AgentServiceImpl { + fn get_config_name(is_validate_only: bool) -> String { + format!( + "nginx_config_{}{}", + if is_validate_only { + "validation_" + } else { + "application_" + }, + uuid::Uuid::new_v4() + ) + } + + async fn _validate(&self, name: &str, timestamp: i64, config: &str) -> Result<(), AgentError> { + let api = self.client.nginx_agent_api(); + + api.write_config(WriteConfigParams { + write_config_body: WriteConfigBody { + config_name: name.to_string(), + content: config.to_string(), + timestamp, + }, + }) + .await?; + + api.validate(ValidateParams { + validate_body: ValidateBody { + config_name: name.to_string(), + timestamp, + }, + }) + .await?; + + Ok(()) + } + + async fn _apply(&self, name: &str, timestamp: i64) -> Result<(), AgentError> { + let api = self.client.nginx_agent_api(); + api.validate_and_reload(ValidateAndReloadParams { + validate_and_reload_body: ValidateAndReloadBody { + config_name: name.to_string(), + timestamp, + }, + }) + .await?; + + Ok(()) } } @@ -56,7 +205,7 @@ mod tests { #[test] fn test_agent_service_creation() { let config = Configuration::default(); - let service = AgentService::new(config); + let service = AgentServiceImpl::new(config); let client = service.get_client(); assert!(Arc::ptr_eq(&client, &service.client)); } diff --git a/apps/api/src/services/nginx.rs b/apps/api/src/services/nginx.rs new file mode 100644 index 0000000..fd56b07 --- /dev/null +++ b/apps/api/src/services/nginx.rs @@ -0,0 +1,86 @@ +pub mod builder; +pub mod info; +pub mod traits; + +pub mod upstream; + +use std::sync::Arc; + +use sea_orm::{DatabaseConnection, DatabaseTransaction}; + +use crate::{ + errors::service_error::ServiceError, + services::{ + agent_client::AgentService, + nginx::{ + builder::{NginxConfigBuilder, NginxConfigProvider}, + upstream::{UpstreamService, UpstreamServiceImpl}, + }, + }, +}; + +pub struct NginxService { + #[allow(dead_code)] + connection: Arc, + // + upstream_service: Arc, +} + +impl NginxService { + pub fn new(connection: Arc) -> Self { + Self { + connection: connection.clone(), + // + upstream_service: Arc::new(UpstreamServiceImpl::new(connection.clone())), + } + } + + pub fn get_upstream_service(&self) -> Arc { + self.upstream_service.clone() + } + + #[allow(dead_code)] + pub async fn validate_config( + &self, + agent: Arc, + config: &str, + ) -> Result<(), ServiceError> { + agent.validate(config).await?; + + Ok(()) + } + + pub async fn apply_changes( + &self, + agent: Arc, + config: &str, + ) -> Result<(), ServiceError> { + agent.apply(config).await?; + + Ok(()) + } + + pub async fn generate_config( + &self, + tx: Option<&mut DatabaseTransaction>, + ) -> Result { + let mut builder = NginxConfigBuilder::default(); + self.upstream_service + .generate_config(&mut builder, tx) + .await?; + + Ok(builder.to_nginx_config(None)) + } + + pub async fn regenerate_and_apply_config( + &self, + agent: Arc, + tx: Option<&mut DatabaseTransaction>, + ) -> Result<(), ServiceError> { + let config = self.generate_config(tx).await?; + + self.apply_changes(agent, &config).await?; + + Ok(()) + } +} diff --git a/apps/api/src/services/nginx/builder.rs b/apps/api/src/services/nginx/builder.rs new file mode 100644 index 0000000..4a55df2 --- /dev/null +++ b/apps/api/src/services/nginx/builder.rs @@ -0,0 +1,43 @@ +use crate::services::nginx::info::upstream::UpstreamInfo; + +pub const INDENT_SIZE: usize = 2; + +pub trait NginxConfigProvider { + fn to_nginx_config(&self, indent: Option) -> String; +} + +#[derive(Default)] +pub struct NginxConfigBuilder { + upstreams: Vec, +} + +impl NginxConfigBuilder { + pub fn add_upstream(&mut self, upstream: UpstreamInfo) { + self.upstreams.push(upstream); + } + + pub fn add_upstreams(&mut self, upstreams: Vec) { + for upstream in upstreams { + self.add_upstream(upstream); + } + } +} + +impl NginxConfigProvider for NginxConfigBuilder { + fn to_nginx_config(&self, indent: Option) -> String { + let mut config = format!( + "# Nginx Config Generated by YANPM at {}", + chrono::Utc::now() + ); + + for upstream in &self.upstreams { + config.push('\n'); + config.push_str(&upstream.to_nginx_config(indent)); + } + + // TODO: Add other sections like servers, locations, etc. + // trailing newline for file ending + config.push('\n'); + config + } +} diff --git a/apps/api/src/services/nginx/info.rs b/apps/api/src/services/nginx/info.rs new file mode 100644 index 0000000..b74b8b1 --- /dev/null +++ b/apps/api/src/services/nginx/info.rs @@ -0,0 +1,2 @@ +pub mod upstream; +pub mod upstream_target; diff --git a/apps/api/src/services/nginx/info/upstream.rs b/apps/api/src/services/nginx/info/upstream.rs new file mode 100644 index 0000000..4c38b61 --- /dev/null +++ b/apps/api/src/services/nginx/info/upstream.rs @@ -0,0 +1,219 @@ +use chrono::{DateTime, Utc}; + +use database::generated::entities::{upstream, upstream_target}; +use sea_orm::ActiveValue::{Set, Unchanged}; +use tracing::warn; +use uuid::Uuid; + +use crate::{ + services::nginx::{ + builder::{INDENT_SIZE, NginxConfigProvider}, + info::upstream_target as upstream_target_info, + traits::indentable::Indentable, + }, + set_if_some, +}; + +const PLACEHOLDER_TARGET: &str = "server 127.0.0.1:65535 down; # placeholder target"; + +#[derive(Clone)] +pub struct UpstreamInfo { + pub id: 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, +} + +pub struct UpstreamCreateInfo { + pub name: String, + pub protocol: String, + pub algorithm: String, + pub sticky_session: bool, + 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 + .upstream_targets + .iter() + .map(|target| target.to_nginx_config(Some(indent.unwrap_or(0) + INDENT_SIZE))) + .collect(); + + let mut targets_config_str = { + let config_str = match self.algorithm.as_str() { + "least-conn" => "least_conn", + "ip-hash" => "ip_hash", + "round-robin" => "", + v => { + // TODO: allow arbitrary algorithms via config extensions/plugins + warn!( + "Unknown upstream algorithm '{}', defaulting to 'round-robin'", + v + ); + "" + } + } + .to_string(); + // TODO: add support for sticky session / checking for nginx sticky module existence + // if self.sticky_session { + // config_str.push_str("sticky") + // } + if config_str.trim().is_empty() { + String::new() + } else { + config_str + ";" + } + } + .indent(indent.unwrap_or(0) + INDENT_SIZE * 2); + targets_config_str.push('\n'); + + targets_config_str.push_str( + &(if targets_config.is_empty() { + // add placeholder if no targets + PLACEHOLDER_TARGET.to_string() + } else { + // normal targets + targets_config.join("\n") + } + .indent(indent.unwrap_or(0) + INDENT_SIZE)), + ); + + // add placeholder if all targets are backup + if self.upstream_targets.iter().all(|v| v.is_backup) { + targets_config_str.push('\n'); + targets_config_str + .push_str(&PLACEHOLDER_TARGET.indent(indent.unwrap_or(0) + INDENT_SIZE)); + } + + format!("upstream {} {{\n{}\n}}", self.name, targets_config_str).indent(indent.unwrap_or(0)) + } +} + +impl From for (upstream::ActiveModel, Vec) { + fn from(val: UpstreamCreateInfo) -> (upstream::ActiveModel, Vec) { + let upstream_uuid = Uuid::new_v4(); + let upstream = upstream::ActiveModel { + id: sea_orm::ActiveValue::Set(upstream_uuid), + name: sea_orm::ActiveValue::Set(val.name), + protocol: sea_orm::ActiveValue::Set(val.protocol), + algorithm: sea_orm::ActiveValue::Set(val.algorithm), + sticky_session: sea_orm::ActiveValue::Set(val.sticky_session), + created_by: sea_orm::ActiveValue::Set(val.created_by), + created_at: sea_orm::ActiveValue::Set(chrono::Utc::now()), + updated_at: sea_orm::ActiveValue::Set(chrono::Utc::now()), + }; + let upstream_targets = val + .upstream_targets + .into_iter() + .map(|target| { + let mut active_model: upstream_target::ActiveModel = target.into(); + active_model.upstream_id = sea_orm::ActiveValue::Set(upstream_uuid); + active_model + }) + .collect(); + (upstream, upstream_targets) + } +} + +impl From for UpstreamInfo { + fn from(model: upstream::Model) -> Self { + Self { + id: model.id, + name: model.name, + protocol: model.protocol, + algorithm: model.algorithm, + sticky_session: model.sticky_session, + created_by: model.created_by, + created_at: model.created_at, + updated_at: model.updated_at, + upstream_targets: Vec::new(), + } + } +} + +impl From<(upstream::Model, Option>)> for UpstreamInfo { + fn from(data: (upstream::Model, Option>)) -> Self { + let (upstream_model, upstream_target_models) = data; + if let Some(targets) = upstream_target_models { + UpstreamInfo::from((upstream_model, targets)) + } else { + UpstreamInfo::from(upstream_model) + } + } +} + +impl From<(upstream::Model, Vec)> for UpstreamInfo { + fn from(data: (upstream::Model, Vec)) -> Self { + let (upstream_model, upstream_target_models) = data; + + Self { + id: upstream_model.id, + name: upstream_model.name, + protocol: upstream_model.protocol, + algorithm: upstream_model.algorithm, + sticky_session: upstream_model.sticky_session, + created_by: upstream_model.created_by, + created_at: upstream_model.created_at, + updated_at: upstream_model.updated_at, + upstream_targets: upstream_target_models + .into_iter() + .map(upstream_target_info::UpstreamTargetInfo::from) + .collect(), + } + } +} + +impl From for (upstream::ActiveModel, Vec) { + fn from(val: UpstreamInfo) -> Self { + ( + upstream::ActiveModel { + id: sea_orm::ActiveValue::Set(val.id), + name: sea_orm::ActiveValue::Set(val.name), + protocol: sea_orm::ActiveValue::Set(val.protocol), + algorithm: sea_orm::ActiveValue::Set(val.algorithm), + sticky_session: sea_orm::ActiveValue::Set(val.sticky_session), + created_by: sea_orm::ActiveValue::Set(val.created_by), + created_at: sea_orm::ActiveValue::Set(val.created_at), + updated_at: sea_orm::ActiveValue::Set(val.updated_at), + }, + val.upstream_targets + .into_iter() + .map(|target| target.into()) + .collect(), + ) + } +} + +impl UpdateUpstreamInfo { + pub fn apply_to_model(self, current_model: upstream::Model) -> upstream::ActiveModel { + upstream::ActiveModel { + 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: 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 new file mode 100644 index 0000000..b558566 --- /dev/null +++ b/apps/api/src/services/nginx/info/upstream_target.rs @@ -0,0 +1,161 @@ +use chrono::{DateTime, Utc}; + +use sea_orm::ActiveValue::{Set, Unchanged}; +use uuid::Uuid; + +use database::generated::entities::{upstream, upstream_target}; + +use crate::{ + services::nginx::{builder::NginxConfigProvider, traits::indentable::Indentable}, + set_if_some, +}; + +#[derive(Clone)] +pub struct UpstreamTargetInfo { + pub id: uuid::Uuid, + pub target_host: String, + pub target_port: i64, + pub weight: i64, + pub is_backup: bool, + pub enabled: bool, + pub created_at: DateTime, + pub updated_at: DateTime, + // + pub upstream_id: uuid::Uuid, + 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, + pub name: String, + pub protocol: String, + // + pub created_at: DateTime, + pub updated_at: DateTime, +} + +pub struct UpstreamTargetCreateInfo { + pub target_host: String, + pub target_port: i64, + pub weight: i64, + pub is_backup: bool, + pub enabled: bool, + // + pub upstream_id: uuid::Uuid, +} + +impl From for UpstreamTargetInfo { + fn from(model: upstream_target::Model) -> Self { + Self { + id: model.id, + target_host: model.target_host, + target_port: model.target_port, + weight: model.weight, + is_backup: model.is_backup, + enabled: model.enabled, + created_at: model.created_at, + updated_at: model.updated_at, + upstream_id: model.upstream_id, + upstream: None, + } + } +} + +impl From<(upstream_target::Model, upstream::Model)> for UpstreamTargetInfo { + fn from(data: (upstream_target::Model, upstream::Model)) -> Self { + let (target_model, up_model) = data; + Self { + id: target_model.id, + target_host: target_model.target_host, + target_port: target_model.target_port, + weight: target_model.weight, + is_backup: target_model.is_backup, + enabled: target_model.enabled, + created_at: target_model.created_at, + updated_at: target_model.updated_at, + upstream_id: target_model.upstream_id, + upstream: Some(UpstreamBasicInfo { + id: up_model.id, + name: up_model.name, + protocol: up_model.protocol, + created_at: up_model.created_at, + updated_at: up_model.updated_at, + }), + } + } +} + +impl From for upstream_target::ActiveModel { + fn from(val: UpstreamTargetInfo) -> Self { + upstream_target::ActiveModel { + id: Set(val.id), + target_host: Set(val.target_host), + target_port: Set(val.target_port), + weight: Set(val.weight), + is_backup: Set(val.is_backup), + enabled: Set(val.enabled), + created_at: Set(val.created_at), + updated_at: Set(val.updated_at), + upstream_id: Set(val.upstream_id), + } + } +} + +impl From for upstream_target::ActiveModel { + fn from(val: UpstreamTargetCreateInfo) -> Self { + upstream_target::ActiveModel { + id: Set(Uuid::new_v4()), + target_host: Set(val.target_host), + target_port: Set(val.target_port), + weight: Set(val.weight), + is_backup: Set(val.is_backup), + enabled: Set(val.enabled), + created_at: Set(chrono::Utc::now()), + updated_at: Set(chrono::Utc::now()), + upstream_id: Set(val.upstream_id), + } + } +} + +impl NginxConfigProvider for UpstreamTargetInfo { + fn to_nginx_config(&self, indent: Option) -> String { + format!( + "server {}:{} weight={}{}{};", + self.target_host, + self.target_port, + self.weight, + if self.is_backup { " backup" } else { "" }, + if !self.enabled { " down" } else { "" }, + ) + .indent(indent.unwrap_or(0)) + } +} + +impl UpdateUpstreamTargetInfo { + pub fn apply_to_model( + self, + current_model: upstream_target::Model, + ) -> upstream_target::ActiveModel { + upstream_target::ActiveModel { + id: Unchanged(current_model.id), + target_host: set_if_some!(self.target_host), + target_port: set_if_some!(self.target_port), + weight: set_if_some!(self.weight), + is_backup: set_if_some!(self.is_backup), + enabled: set_if_some!(self.enabled), + 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/traits.rs b/apps/api/src/services/nginx/traits.rs new file mode 100644 index 0000000..66ccbfd --- /dev/null +++ b/apps/api/src/services/nginx/traits.rs @@ -0,0 +1 @@ +pub mod indentable; diff --git a/apps/api/src/services/nginx/traits/indentable.rs b/apps/api/src/services/nginx/traits/indentable.rs new file mode 100644 index 0000000..d8113b5 --- /dev/null +++ b/apps/api/src/services/nginx/traits/indentable.rs @@ -0,0 +1,31 @@ +pub trait Indentable { + fn indent(&self, spaces: T) -> String; +} + +impl Indentable for &str { + fn indent(&self, spaces: usize) -> String { + let indent_str = " ".repeat(spaces); + self.lines() + .map(|line| format!("{}{}", indent_str, line)) + .collect::>() + .join("\n") + } +} + +impl Indentable> for String { + fn indent(&self, spaces: Option) -> String { + self.as_str().indent(spaces.unwrap_or(0)) + } +} + +impl Indentable for String { + fn indent(&self, spaces: usize) -> String { + self.as_str().indent(spaces) + } +} + +impl Indentable> for &str { + fn indent(&self, spaces: Option) -> String { + self.indent(spaces.unwrap_or(0)) + } +} diff --git a/apps/api/src/services/nginx/upstream.rs b/apps/api/src/services/nginx/upstream.rs new file mode 100644 index 0000000..9678989 --- /dev/null +++ b/apps/api/src/services/nginx/upstream.rs @@ -0,0 +1,881 @@ +use std::sync::Arc; + +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DatabaseConnection, DatabaseTransaction, EntityTrait, ExprTrait, + FromQueryResult, ModelTrait, QueryFilter, QuerySelect, QueryTrait, TransactionTrait, +}; + +use database::generated::entities::{upstream, upstream_target}; + +use crate::{ + errors::service_error::ServiceError, + helpers::database::PaginationFilter, + services::nginx::{ + builder::NginxConfigBuilder, + info::{ + upstream::{UpdateUpstreamInfo, UpstreamCreateInfo, UpstreamInfo}, + upstream_target::{ + UpdateUpstreamTargetInfo, UpstreamTargetCreateInfo, UpstreamTargetInfo, + }, + }, + }, + with_conn, +}; + +#[async_trait::async_trait] +pub trait UpstreamService: Send + Sync { + async fn create_upstream( + &self, + create_info: UpstreamCreateInfo, + tx: Option<&mut DatabaseTransaction>, + ) -> Result; + async fn get_total_upstreams( + &self, + options: Option, + tx: Option<&mut DatabaseTransaction>, + ) -> Result; + async fn get_upstream( + &self, + upstream_id: uuid::Uuid, + options: Option, + tx: Option<&mut DatabaseTransaction>, + ) -> Result; + async fn get_upstreams( + &self, + pagination: Option, + options: Option, + tx: Option<&mut DatabaseTransaction>, + ) -> Result, ServiceError>; + async fn update_upstream( + &self, + id: uuid::Uuid, + upstream: UpdateUpstreamInfo, + tx: Option<&mut DatabaseTransaction>, + ) -> Result; + async fn delete_upstream( + &self, + upstream_id: uuid::Uuid, + tx: Option<&mut DatabaseTransaction>, + ) -> Result<(), ServiceError>; + async fn create_upstream_target( + &self, + create_info: UpstreamTargetCreateInfo, + tx: Option<&mut DatabaseTransaction>, + ) -> Result; + async fn get_upstream_target( + &self, + target_id: uuid::Uuid, + options: Option, + tx: Option<&mut DatabaseTransaction>, + ) -> Result; + #[allow(dead_code)] + async fn get_upstream_targets_by_upstream( + &self, + upstream_id: uuid::Uuid, + tx: Option<&mut DatabaseTransaction>, + ) -> Result, ServiceError>; + async fn update_upstream_target( + &self, + id: uuid::Uuid, + target: UpdateUpstreamTargetInfo, + tx: Option<&mut DatabaseTransaction>, + ) -> Result; + async fn delete_upstream_target( + &self, + target_id: uuid::Uuid, + tx: Option<&mut DatabaseTransaction>, + ) -> Result<(), ServiceError>; + async fn generate_config( + &self, + builder: &mut NginxConfigBuilder, + tx: Option<&mut DatabaseTransaction>, + ) -> Result<(), ServiceError>; +} + +pub struct UpstreamServiceImpl { + connection: Arc, +} + +#[derive(Default)] +pub struct GetUpstreamOptions { + pub include_targets: bool, + pub filter_by_enabled: bool, +} + +#[allow(dead_code)] +pub struct UpstreamTotalCountOptions {} + +#[derive(Default)] +pub struct GetUpstreamTargetOptions { + pub include_upstream: bool, +} + +impl UpstreamServiceImpl { + pub fn new(connection: Arc) -> Self { + Self { connection } + } +} + +#[async_trait::async_trait] +impl UpstreamService for UpstreamServiceImpl { + async fn create_upstream( + &self, + create_info: UpstreamCreateInfo, + tx: Option<&mut DatabaseTransaction>, + ) -> Result { + let (upstream_model, upstream_target_models): ( + upstream::ActiveModel, + Vec, + ) = create_info.into(); + + // 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 r = with_conn!(&*self.connection, tx_ref, conn, { + let created_upstream = upstream_model.insert(*conn).await?; + let created_targets = upstream_target::Entity::insert_many( + upstream_target_models + .into_iter() + .map(|mut model| { + model.upstream_id = sea_orm::ActiveValue::Set(created_upstream.id); + model + }) + .collect::>(), + ) + .exec_with_returning(*conn) + .await?; + (created_upstream, created_targets) + }); + + // Commit only if we created the transaction here (we own it). + if let Some(t) = maybe_owned_tx.take() { + t.commit().await?; + } + Ok(r.into()) + } + + async fn get_total_upstreams( + &self, + _options: Option, + tx: Option<&mut DatabaseTransaction>, + ) -> Result { + #[derive(Debug, FromQueryResult)] + struct CountResult { + // The field name must match the column alias in the query + count: i64, + } + let count_info = with_conn!(&*self.connection, tx, conn, { + upstream::Entity::find() + .select_only() + .column_as(upstream::Column::Id, "count") + .into_model::() + .one(*conn) + .await? + }); + Ok(count_info.map_or(0, |c| c.count) as u64) + } + + async fn get_upstream( + &self, + upstream_id: uuid::Uuid, + options: Option, + tx: Option<&mut DatabaseTransaction>, + ) -> Result { + let concrete_options = options.unwrap_or_default(); + let info: UpstreamInfo = if concrete_options.include_targets { + let (up_model, targets) = with_conn!(&*self.connection, tx, conn, { + let up = upstream::Entity::find_by_id(upstream_id) + .one(*conn) + .await? + .ok_or(ServiceError::NotFound(format!( + "Upstream with id {} not found", + upstream_id + )))?; + let targets = upstream_target::Entity::find() + .filter(upstream_target::Column::UpstreamId.eq(upstream_id)) + .apply_if(Some(concrete_options.filter_by_enabled), |query, _v| { + query.filter(upstream_target::Column::Enabled.eq(true)) + }) + .all(*conn) + .await?; + (up, targets) + }); + (up_model, targets).into() + } else { + with_conn!(&*self.connection, tx, conn, { + upstream::Entity::find_by_id(upstream_id) + .one(*conn) + .await? + .ok_or(ServiceError::NotFound(format!( + "Upstream with id {} not found", + upstream_id + )))? + }) + .into() + }; + Ok(info) + } + + async fn get_upstreams( + &self, + pagination: Option, + options: Option, + tx: Option<&mut DatabaseTransaction>, + ) -> Result, ServiceError> { + let r = with_conn!(&*self.connection, tx, conn, { + let find_query = upstream::Entity::find(); + let find_query = if let Some(pagination) = pagination { + let (offset, limit) = pagination.get_offset_limit(); + find_query.offset(offset).limit(limit) + } else { + find_query + }; + let find_query = match options { + Some(opts) => { + if opts.include_targets && opts.filter_by_enabled { + find_query.filter( + upstream_target::Column::Enabled + .eq(true) + .or(upstream_target::Column::Id.is_null()), + ) + } else { + find_query + } + } + _ => find_query, + }; + find_query + .find_with_related(upstream_target::Entity) + .all(*conn) + .await? + }); + + Ok(r.into_iter().map(|m| m.into()).collect()) + } + + async fn update_upstream( + &self, + id: uuid::Uuid, + upstream: UpdateUpstreamInfo, + tx: Option<&mut DatabaseTransaction>, + ) -> Result { + // 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? + .ok_or(ServiceError::NotFound(format!( + "Upstream with id {} not found", + id + )))? + }); + let upstream_active_model = upstream.clone().apply_to_model(current_model); + + 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()) + } + + async fn delete_upstream( + &self, + upstream_id: uuid::Uuid, + tx: Option<&mut DatabaseTransaction>, + ) -> Result<(), ServiceError> { + let model = with_conn!(&*self.connection, tx, conn, { + upstream::Entity::find_by_id(upstream_id) + .one(*conn) + .await? + .ok_or(ServiceError::NotFound(format!( + "Upstream with id {} not found", + upstream_id + )))? + }); + 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(()) + }) + } + + // + // + async fn create_upstream_target( + &self, + create_info: UpstreamTargetCreateInfo, + tx: Option<&mut DatabaseTransaction>, + ) -> Result { + let model: upstream_target::ActiveModel = create_info.into(); + let r = with_conn!(&*self.connection, tx, conn, { model.insert(*conn).await? }); + Ok(r.into()) + } + + async fn get_upstream_target( + &self, + target_id: uuid::Uuid, + options: Option, + tx: Option<&mut DatabaseTransaction>, + ) -> Result { + let concrete_options = options.unwrap_or_default(); + let info: UpstreamTargetInfo = if concrete_options.include_upstream { + match with_conn!(&*self.connection, tx, conn, { + upstream_target::Entity::find_by_id(target_id) + .find_also_related(upstream::Entity) + .one(*conn) + .await? + }) { + Some((target_model, Some(upstream_model))) => (target_model, upstream_model).into(), + Some((_target_model, None)) => { + return Err(ServiceError::InternalError(format!( + "Inconsistent data: Upstream target with id {} has no associated upstream", + target_id + ))); + } + None => { + return Err(ServiceError::NotFound(format!( + "Upstream target with id {} not found", + target_id + ))); + } + } + } else { + with_conn!(&*self.connection, tx, conn, { + upstream_target::Entity::find_by_id(target_id) + .one(*conn) + .await? + .ok_or(ServiceError::NotFound(format!( + "Upstream target with id {} not found", + target_id + )))? + }) + .into() + }; + Ok(info) + } + + async fn get_upstream_targets_by_upstream( + &self, + upstream_id: uuid::Uuid, + tx: Option<&mut DatabaseTransaction>, + ) -> Result, ServiceError> { + let r = with_conn!(&*self.connection, tx, conn, { + upstream_target::Entity::find() + .filter(upstream_target::Column::UpstreamId.eq(upstream_id)) + .all(*conn) + .await? + }); + Ok(r.into_iter().map(|m| m.into()).collect()) + } + + async fn update_upstream_target( + &self, + id: uuid::Uuid, + target: UpdateUpstreamTargetInfo, + tx: Option<&mut DatabaseTransaction>, + ) -> Result { + let current_model = with_conn!(&*self.connection, tx, conn, { + upstream_target::Entity::find_by_id(id) + .one(*conn) + .await? + .ok_or(ServiceError::NotFound(format!( + "Upstream target with id {} not found", + id + )))? + }); + let active_model = target.apply_to_model(current_model); + + let r = with_conn!(&*self.connection, tx, conn, { + active_model.update(*conn).await? + }); + Ok(r.into()) + } + + async fn delete_upstream_target( + &self, + target_id: uuid::Uuid, + tx: Option<&mut DatabaseTransaction>, + ) -> Result<(), ServiceError> { + let model = with_conn!(&*self.connection, tx, conn, { + upstream_target::Entity::find_by_id(target_id) + .one(*conn) + .await? + .ok_or(ServiceError::NotFound(format!( + "Upstream target with id {} not found", + target_id + )))? + }); + with_conn!(&*self.connection, tx, conn, { + model.delete(*conn).await?; + Ok(()) + }) + } + + async fn generate_config( + &self, + builder: &mut NginxConfigBuilder, + tx: Option<&mut DatabaseTransaction>, + ) -> Result<(), ServiceError> { + // get all upstreams and their targets + let upstreams = with_conn!(&*self.connection, tx, conn, { + upstream::Entity::find() + .find_with_related(upstream_target::Entity) + .all(*conn) + .await? + }); + let upstreams_info = upstreams + .into_iter() + .map(|(up_model, target_models)| (up_model, target_models).into()) + .collect::>(); + builder.add_upstreams(upstreams_info); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + use sea_orm::MockExecResult; + use sea_orm::{DatabaseBackend, MockDatabase}; + + use database::generated::entities::{upstream, upstream_target}; + + #[tokio::test] + async fn create_upstream_returns_info() { + let up_model = upstream::Model { + id: uuid::Uuid::new_v4(), + name: "test_upstream".to_string(), + protocol: "http".to_string(), + algorithm: "round_robin".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![up_model.clone()]]) + .into_connection(); + + let svc = UpstreamServiceImpl::new(Arc::new(db)); + + let create_info = crate::services::nginx::info::upstream::UpstreamCreateInfo { + name: "test_upstream".to_string(), + protocol: "http".to_string(), + algorithm: "round_robin".to_string(), + sticky_session: false, + created_by: None, + upstream_targets: Vec::new(), + }; + + let res = svc.create_upstream(create_info, None).await; + assert!(res.is_ok()); + let info = res.expect("Failed to create upstream"); + assert_eq!(info.name, "test_upstream"); + } + + #[tokio::test] + async fn get_upstream_with_targets_returns_targets() { + let up_id = uuid::Uuid::new_v4(); + + let up_model = upstream::Model { + id: up_id, + name: "with_targets".to_string(), + protocol: "http".to_string(), + algorithm: "least_conn".to_string(), + sticky_session: true, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let target_model = upstream_target::Model { + id: uuid::Uuid::new_v4(), + 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) + // find_by_id -> returns upstream model + .append_query_results(vec![vec![up_model.clone()]]) + // find targets -> returns the target(s) + .append_query_results(vec![vec![target_model.clone()]]) + .into_connection(); + + let svc = UpstreamServiceImpl::new(Arc::new(db)); + + let res = svc + .get_upstream( + up_id, + Some(GetUpstreamOptions { + include_targets: true, + filter_by_enabled: false, + }), + None, + ) + .await; + + assert!(res.is_ok()); + let info = res.expect("Failed to get upstream with targets"); + assert_eq!(info.id, up_id); + assert_eq!(info.upstream_targets.len(), 1); + assert_eq!(info.upstream_targets[0].target_host, "127.0.0.1"); + } + + #[tokio::test] + async fn get_upstream_not_found_returns_not_found() { + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![Vec::::new()]) + .into_connection(); + + let svc = UpstreamServiceImpl::new(Arc::new(db)); + + let res = svc.get_upstream(uuid::Uuid::new_v4(), None, None).await; + + assert!(matches!(res, Err(ServiceError::NotFound(_)))); + } + + #[tokio::test] + async fn get_upstreams_returns_list() { + let u1 = upstream::Model { + id: uuid::Uuid::new_v4(), + name: "u1".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 u2 = upstream::Model { + id: uuid::Uuid::new_v4(), + name: "u2".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![ + (u1.clone(), None::), + (u2.clone(), None::), + ]]) + .into_connection(); + + let svc = UpstreamServiceImpl::new(Arc::new(db)); + + let res = svc.get_upstreams(None, None, None).await; + assert!(res.is_ok()); + let list = res.expect("Failed to get upstreams"); + assert_eq!(list.len(), 2); + } + + #[tokio::test] + async fn get_upstream_targets_by_upstream_returns_targets() { + let up_id = uuid::Uuid::new_v4(); + + let t = upstream_target::Model { + id: uuid::Uuid::new_v4(), + upstream_id: up_id, + target_host: "10.0.0.1".to_string(), + target_port: 80, + weight: 10, + 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![t.clone()]]) + .into_connection(); + + let svc = UpstreamServiceImpl::new(Arc::new(db)); + + let res = svc.get_upstream_targets_by_upstream(up_id, None).await; + assert!(res.is_ok()); + let targets = res.expect("Failed to get upstream targets"); + assert_eq!(targets.len(), 1); + assert_eq!(targets[0].target_host, "10.0.0.1"); + } + + #[tokio::test] + async fn update_upstream_success() { + let id = uuid::Uuid::new_v4(); + let existing = upstream::Model { + id, + name: "old".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 updated = upstream::Model { + id, + name: "new".to_string(), + protocol: "http".to_string(), + algorithm: "rr".to_string(), + sticky_session: false, + created_by: None, + created_at: existing.created_at, + updated_at: chrono::Utc::now(), + }; + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![vec![existing.clone()]]) // find_by_id + .append_query_results(vec![vec![updated.clone()]]) // update result + .into_connection(); + + let svc = UpstreamServiceImpl::new(Arc::new(db)); + + let update_info = crate::services::nginx::info::upstream::UpdateUpstreamInfo { + name: None, + protocol: None, + algorithm: None, + sticky_session: None, + upstream_targets: None, + }; + let res = svc.update_upstream(id, update_info, None).await; + assert!(res.is_ok()); + let got = res.expect("Failed to update upstream"); + assert_eq!(got.name, "new"); + } + + #[tokio::test] + async fn update_upstream_not_found() { + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![Vec::::new()]) + .into_connection(); + + let svc = UpstreamServiceImpl::new(Arc::new(db)); + + let res = svc + .update_upstream( + uuid::Uuid::new_v4(), + crate::services::nginx::info::upstream::UpdateUpstreamInfo { + name: None, + protocol: None, + algorithm: None, + sticky_session: None, + + upstream_targets: None, + }, + None, + ) + .await; + + assert!(matches!(res, Err(ServiceError::NotFound(_)))); + } + + #[tokio::test] + async fn delete_upstream_success() { + let id = uuid::Uuid::new_v4(); + let existing = upstream::Model { + 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 svc = UpstreamServiceImpl::new(Arc::new(db)); + + let res = svc.delete_upstream(id, None).await; + assert!(res.is_ok()); + } + + #[tokio::test] + async fn delete_upstream_not_found() { + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![Vec::::new()]) + .into_connection(); + + let svc = UpstreamServiceImpl::new(Arc::new(db)); + + let res = svc.delete_upstream(uuid::Uuid::new_v4(), None).await; + assert!(matches!(res, Err(ServiceError::NotFound(_)))); + } + + #[tokio::test] + async fn create_upstream_target_success() { + let id = uuid::Uuid::new_v4(); + let upstream_id = uuid::Uuid::new_v4(); + let created = upstream_target::Model { + id, + upstream_id, + target_host: "1.2.3.4".to_string(), + target_port: 8080, + weight: 5, + 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![created.clone()]]) + .into_connection(); + + let svc = UpstreamServiceImpl::new(Arc::new(db)); + + let create_info = crate::services::nginx::info::upstream_target::UpstreamTargetCreateInfo { + target_host: "1.2.3.4".to_string(), + target_port: 8080, + weight: 5, + is_backup: false, + enabled: true, + upstream_id, + }; + + let res = svc.create_upstream_target(create_info, None).await; + assert!(res.is_ok()); + let t = res.expect("Failed to create target"); + assert_eq!(t.target_host, "1.2.3.4"); + } + + #[tokio::test] + async fn update_upstream_target_success() { + let id = uuid::Uuid::new_v4(); + let existing = upstream_target::Model { + id, + upstream_id: uuid::Uuid::new_v4(), + target_host: "old".to_string(), + target_port: 80, + weight: 1, + is_backup: false, + enabled: true, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + let updated = upstream_target::Model { + id, + upstream_id: existing.upstream_id, + target_host: "new".to_string(), + target_port: 80, + weight: 1, + is_backup: false, + enabled: true, + created_at: existing.created_at, + updated_at: chrono::Utc::now(), + }; + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![vec![existing.clone()]]) + .append_query_results(vec![vec![updated.clone()]]) + .into_connection(); + + let svc = UpstreamServiceImpl::new(Arc::new(db)); + + let update_info = crate::services::nginx::info::upstream_target::UpdateUpstreamTargetInfo { + target_host: None, + target_port: None, + weight: None, + is_backup: None, + enabled: None, + }; + let res = svc.update_upstream_target(id, update_info, None).await; + assert!(res.is_ok()); + let got = res.expect("Failed to update target"); + assert_eq!(got.target_host, "new"); + } + + #[tokio::test] + async fn delete_upstream_target_success() { + let id = uuid::Uuid::new_v4(); + let existing = upstream_target::Model { + id, + upstream_id: uuid::Uuid::new_v4(), + target_host: "del".to_string(), + target_port: 80, + 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![existing.clone()]]) + .append_exec_results(vec![MockExecResult { + rows_affected: 1, + last_insert_id: 0, + }]) + .into_connection(); + + let svc = UpstreamServiceImpl::new(Arc::new(db)); + let res = svc.delete_upstream_target(id, None).await; + assert!(res.is_ok()); + } +} diff --git a/apps/api/swagger.json b/apps/api/swagger.json index cf7fddc..ae006db 100644 --- a/apps/api/swagger.json +++ b/apps/api/swagger.json @@ -106,6 +106,357 @@ } } }, + "/api/nginx/upstream_targets/{upstream_target_id}": { + "get": { + "tags": [ + "Nginx" + ], + "operationId": "get_upstream_target", + "parameters": [ + { + "name": "upstream_target_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Get upstream target info", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpstreamTargetInfo" + } + } + } + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + } + }, + "delete": { + "tags": [ + "Nginx" + ], + "operationId": "remove_upstream_target", + "parameters": [ + { + "name": "upstream_target_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Upstream target removed successfully", + "content": { + "application/json": { + "schema": { + "default": null + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + } + }, + "patch": { + "tags": [ + "Nginx" + ], + "operationId": "update_upstream_target", + "parameters": [ + { + "name": "upstream_target_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateUpstreamTargetRequestBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Upstream target updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateUpstreamTargetInfoResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Invalid request" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/api/nginx/upstreams": { + "get": { + "tags": [ + "Nginx" + ], + "operationId": "get_upstream_list", + "responses": { + "200": { + "description": "List upstreams", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpstreamListResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + }, + "post": { + "tags": [ + "Nginx" + ], + "operationId": "create_upstream", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateUpstreamRequestBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Upstream created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpstreamInfoResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "422": { + "description": "Invalid request" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/api/nginx/upstreams/{upstream_id}": { + "get": { + "tags": [ + "Nginx" + ], + "operationId": "get_upstream", + "parameters": [ + { + "name": "upstream_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Get upstream info", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpstreamInfoResponse" + } + } + } + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + } + }, + "delete": { + "tags": [ + "Nginx" + ], + "operationId": "remove_upstream", + "parameters": [ + { + "name": "upstream_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Upstream removed successfully", + "content": { + "application/json": { + "schema": { + "default": null + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + } + }, + "patch": { + "tags": [ + "Nginx" + ], + "operationId": "update_upstream", + "parameters": [ + { + "name": "upstream_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateUpstreamRequestBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Upstream updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateUpstreamInfoResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Invalid request" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/api/nginx/upstreams/{upstream_id}/targets": { + "post": { + "tags": [ + "Nginx" + ], + "operationId": "add_upstream_target", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateUpstreamTargetInfo" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Upstream target created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpstreamTargetInfoResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "422": { + "description": "Invalid request" + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/api/user/me": { "get": { "tags": [ @@ -157,6 +508,102 @@ } } }, + "CreateUpstreamRequestBody": { + "type": "object", + "required": [ + "name", + "protocol", + "upstream_targets" + ], + "properties": { + "algorithm": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "protocol": { + "type": "string" + }, + "sticky_session": { + "type": [ + "boolean", + "null" + ] + }, + "upstream_targets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UpstreamTargetInfo" + } + } + } + }, + "CreateUpstreamTargetInfo": { + "type": "object", + "required": [ + "upstream_id", + "host", + "port" + ], + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + }, + "host": { + "type": "string" + }, + "is_backup": { + "type": [ + "boolean", + "null" + ] + }, + "port": { + "type": "integer", + "format": "int64" + }, + "upstream_id": { + "type": "string", + "format": "uuid" + }, + "weight": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + } + }, + "GetUpstreamParams": { + "type": "object", + "properties": { + "include_targets": { + "type": [ + "boolean", + "null" + ] + } + } + }, + "GetUpstreamTargetsParams": { + "type": "object", + "properties": { + "include_upstream": { + "type": [ + "boolean", + "null" + ] + } + } + }, "HealthInfo": { "type": "object", "description": "System health information", @@ -212,6 +659,486 @@ } } }, + "PaginationInfo": { + "type": "object", + "description": "Pagination information included in API responses", + "required": [ + "total_items", + "total_pages", + "current_page", + "per_page" + ], + "properties": { + "current_page": { + "type": "integer", + "format": "int32", + "description": "Current page number", + "minimum": 0 + }, + "per_page": { + "type": "integer", + "format": "int32", + "description": "Items per page", + "minimum": 0 + }, + "total_items": { + "type": "integer", + "format": "int64", + "description": "Total number of items", + "minimum": 0 + }, + "total_pages": { + "type": "integer", + "format": "int32", + "description": "Total number of pages", + "minimum": 0 + } + } + }, + "UpdateUpstreamInfoResponse": { + "type": "object", + "required": [ + "id", + "name", + "protocol", + "algorithm", + "sticky_session", + "created_at", + "updated_at", + "upstream_targets" + ], + "properties": { + "algorithm": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "created_by": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "protocol": { + "type": "string" + }, + "sticky_session": { + "type": "boolean" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "upstream_targets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UpstreamTargetBasicInfo" + } + } + } + }, + "UpdateUpstreamRequestBody": { + "type": "object", + "properties": { + "algorithm": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "protocol": { + "type": [ + "string", + "null" + ] + }, + "sticky_session": { + "type": [ + "boolean", + "null" + ] + }, + "upstream_targets": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/UpstreamTargetBasicUpdateInfo" + } + } + } + }, + "UpdateUpstreamTargetInfoResponse": { + "type": "object", + "required": [ + "id", + "host", + "port", + "enabled", + "is_backup", + "weight", + "created_at", + "updated_at", + "upstream_id" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "enabled": { + "type": "boolean" + }, + "host": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "is_backup": { + "type": "boolean" + }, + "port": { + "type": "integer", + "format": "int64" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "upstream_id": { + "type": "string", + "format": "uuid" + }, + "weight": { + "type": "integer", + "format": "int32" + } + } + }, + "UpdateUpstreamTargetRequestBody": { + "type": "object", + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + }, + "host": { + "type": [ + "string", + "null" + ] + }, + "is_backup": { + "type": [ + "boolean", + "null" + ] + }, + "port": { + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "weight": { + "type": [ + "integer", + "null" + ], + "format": "int32" + } + } + }, + "UpstreamBasicInfo": { + "type": "object", + "required": [ + "id", + "name", + "protocol", + "created_at", + "updated_at" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "protocol": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "UpstreamInfoResponse": { + "type": "object", + "required": [ + "id", + "name", + "protocol", + "algorithm", + "sticky_session", + "created_at", + "updated_at", + "upstream_targets" + ], + "properties": { + "algorithm": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "created_by": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "protocol": { + "type": "string" + }, + "sticky_session": { + "type": "boolean" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "upstream_targets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UpstreamTargetBasicInfo" + } + } + } + }, + "UpstreamListResponse": { + "type": "object", + "required": [ + "items", + "pagination" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UpstreamInfoResponse" + } + }, + "pagination": { + "$ref": "#/components/schemas/PaginationInfo" + } + } + }, + "UpstreamTargetBasicInfo": { + "type": "object", + "required": [ + "id", + "target_host", + "target_port", + "enabled", + "is_backup", + "weight", + "created_at", + "updated_at" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "is_backup": { + "type": "boolean" + }, + "target_host": { + "type": "string" + }, + "target_port": { + "type": "integer", + "format": "int64" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "weight": { + "type": "integer", + "format": "int32" + } + } + }, + "UpstreamTargetBasicUpdateInfo": { + "type": "object", + "required": [ + "id", + "enabled" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "id": { + "type": "integer", + "format": "int64" + } + } + }, + "UpstreamTargetInfo": { + "type": "object", + "required": [ + "id", + "target_host", + "target_port", + "enabled", + "is_backup", + "weight", + "created_at", + "updated_at", + "upstream_id" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "is_backup": { + "type": "boolean" + }, + "target_host": { + "type": "string" + }, + "target_port": { + "type": "integer", + "format": "int64" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "upstream": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/UpstreamBasicInfo" + } + ] + }, + "upstream_id": { + "type": "string", + "format": "uuid" + }, + "weight": { + "type": "integer", + "format": "int32" + } + } + }, + "UpstreamTargetInfoResponse": { + "type": "object", + "required": [ + "id", + "host", + "port", + "enabled", + "is_backup", + "weight", + "created_at", + "updated_at", + "upstream_id" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "enabled": { + "type": "boolean" + }, + "host": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "is_backup": { + "type": "boolean" + }, + "port": { + "type": "integer", + "format": "int64" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "upstream_id": { + "type": "string", + "format": "uuid" + }, + "weight": { + "type": "integer", + "format": "int32" + } + } + }, "UserInfo": { "type": "object", "description": "System health information", @@ -245,6 +1172,10 @@ { "name": "User", "description": "User management API" + }, + { + "name": "Nginx", + "description": "Nginx management API" } ] } \ No newline at end of file diff --git a/apps/frontend/app/generated/api-client/api-client.ts b/apps/frontend/app/generated/api-client/api-client.ts index 0b47c95..1d42ada 100644 --- a/apps/frontend/app/generated/api-client/api-client.ts +++ b/apps/frontend/app/generated/api-client/api-client.ts @@ -1,6 +1,42 @@ export namespace Schemas { // export type AdminInitRequest = { password: string; setup_secret: string; username: string }; + export type UpstreamBasicInfo = { + created_at: string; + id: string; + name: string; + protocol: string; + updated_at: string; + }; + export type UpstreamTargetInfo = { + created_at: string; + enabled: boolean; + id: string; + is_backup: boolean; + target_host: string; + target_port: number; + updated_at: string; + upstream?: (null | UpstreamBasicInfo) | undefined; + upstream_id: string; + weight: number; + }; + export type CreateUpstreamRequestBody = { + algorithm?: (string | null) | undefined; + name: string; + protocol: string; + sticky_session?: (boolean | null) | undefined; + upstream_targets: Array; + }; + export type CreateUpstreamTargetInfo = { + enabled?: (boolean | null) | undefined; + host: string; + is_backup?: (boolean | null) | undefined; + port: number; + upstream_id: string; + weight?: (number | null) | undefined; + }; + export type GetUpstreamParams = Partial<{ include_targets: boolean | null }>; + export type GetUpstreamTargetsParams = Partial<{ include_upstream: boolean | null }>; export type HealthInfo = { errors?: (Array | null) | undefined; is_initialized: boolean; @@ -9,6 +45,77 @@ export namespace Schemas { version: string; }; export type LoginRequest = { password: string; username: string }; + export type PaginationInfo = { current_page: number; per_page: number; total_items: number; total_pages: number }; + export type UpstreamTargetBasicInfo = { + created_at: string; + enabled: boolean; + id: string; + is_backup: boolean; + target_host: string; + target_port: number; + updated_at: string; + weight: number; + }; + export type UpdateUpstreamInfoResponse = { + algorithm: string; + created_at: string; + created_by?: (string | null) | undefined; + id: string; + name: string; + protocol: string; + sticky_session: boolean; + updated_at: string; + upstream_targets: Array; + }; + export type UpdateUpstreamRequestBody = Partial<{ + algorithm: string | null; + name: string | null; + protocol: string | null; + sticky_session: boolean | null; + upstream_targets: Array | null; + }>; + export type UpdateUpstreamTargetInfoResponse = { + created_at: string; + enabled: boolean; + host: string; + id: string; + is_backup: boolean; + port: number; + updated_at: string; + upstream_id: string; + weight: number; + }; + export type UpdateUpstreamTargetRequestBody = Partial<{ + enabled: boolean | null; + host: string | null; + is_backup: boolean | null; + port: number | null; + weight: number | null; + }>; + export type UpstreamInfoResponse = { + algorithm: string; + created_at: string; + created_by?: (string | null) | undefined; + id: string; + name: string; + protocol: string; + sticky_session: boolean; + updated_at: string; + upstream_targets: Array; + }; + export type UpstreamListResponse = { items: Array; pagination: PaginationInfo }; + export type UpstreamTargetBasicUpdateInfo = { enabled: boolean; id: number }; + export type UpstreamTargetInfoResponse = { + created_at: string; + enabled: boolean; + host: string; + id: string; + is_backup: boolean; + port: number; + updated_at: string; + upstream_id: string; + weight: number; + }; export type UserInfo = { id: string; username: string }; // @@ -42,6 +149,95 @@ export namespace Endpoints { parameters: never; responses: { 200: Schemas.HealthInfo; 404: unknown }; }; + export type get_Get_upstream_target = { + method: "GET"; + path: "/api/nginx/upstream_targets/{upstream_target_id}"; + requestFormat: "json"; + parameters: { + path: { upstream_target_id: string }; + }; + responses: { 200: Schemas.UpstreamTargetInfo; 404: unknown; 500: unknown }; + }; + export type delete_Remove_upstream_target = { + method: "DELETE"; + path: "/api/nginx/upstream_targets/{upstream_target_id}"; + requestFormat: "json"; + parameters: { + path: { upstream_target_id: string }; + }; + responses: { 200: unknown; 401: unknown; 404: unknown; 500: unknown }; + }; + export type patch_Update_upstream_target = { + method: "PATCH"; + path: "/api/nginx/upstream_targets/{upstream_target_id}"; + requestFormat: "json"; + parameters: { + path: { upstream_target_id: string }; + + body: Schemas.UpdateUpstreamTargetRequestBody; + }; + responses: { + 200: Schemas.UpdateUpstreamTargetInfoResponse; + 401: unknown; + 404: unknown; + 422: unknown; + 500: unknown; + }; + }; + export type get_Get_upstream_list = { + method: "GET"; + path: "/api/nginx/upstreams"; + requestFormat: "json"; + parameters: never; + responses: { 200: Schemas.UpstreamListResponse; 500: unknown }; + }; + export type post_Create_upstream = { + method: "POST"; + path: "/api/nginx/upstreams"; + requestFormat: "json"; + parameters: { + body: Schemas.CreateUpstreamRequestBody; + }; + responses: { 200: Schemas.UpstreamInfoResponse; 401: unknown; 422: unknown; 500: unknown }; + }; + export type get_Get_upstream = { + method: "GET"; + path: "/api/nginx/upstreams/{upstream_id}"; + requestFormat: "json"; + parameters: { + path: { upstream_id: string }; + }; + responses: { 200: Schemas.UpstreamInfoResponse; 404: unknown; 500: unknown }; + }; + export type delete_Remove_upstream = { + method: "DELETE"; + path: "/api/nginx/upstreams/{upstream_id}"; + requestFormat: "json"; + parameters: { + path: { upstream_id: string }; + }; + responses: { 200: unknown; 401: unknown; 404: unknown; 500: unknown }; + }; + export type patch_Update_upstream = { + method: "PATCH"; + path: "/api/nginx/upstreams/{upstream_id}"; + requestFormat: "json"; + parameters: { + path: { upstream_id: string }; + + body: Schemas.UpdateUpstreamRequestBody; + }; + responses: { 200: Schemas.UpdateUpstreamInfoResponse; 401: unknown; 404: unknown; 422: unknown; 500: unknown }; + }; + export type post_Add_upstream_target = { + method: "POST"; + path: "/api/nginx/upstreams/{upstream_id}/targets"; + requestFormat: "json"; + parameters: { + body: Schemas.CreateUpstreamTargetInfo; + }; + responses: { 200: Schemas.UpstreamTargetInfoResponse; 401: unknown; 422: unknown; 500: unknown }; + }; export type get_Get_user_info = { method: "GET"; path: "/api/user/me"; @@ -58,11 +254,24 @@ export type EndpointByMethod = { post: { "/api/auth/init_admin": Endpoints.post_Init_admin; "/api/auth/login": Endpoints.post_Login; + "/api/nginx/upstreams": Endpoints.post_Create_upstream; + "/api/nginx/upstreams/{upstream_id}/targets": Endpoints.post_Add_upstream_target; }; get: { "/api/health/info": Endpoints.get_Get_health_info; + "/api/nginx/upstream_targets/{upstream_target_id}": Endpoints.get_Get_upstream_target; + "/api/nginx/upstreams": Endpoints.get_Get_upstream_list; + "/api/nginx/upstreams/{upstream_id}": Endpoints.get_Get_upstream; "/api/user/me": Endpoints.get_Get_user_info; }; + delete: { + "/api/nginx/upstream_targets/{upstream_target_id}": Endpoints.delete_Remove_upstream_target; + "/api/nginx/upstreams/{upstream_id}": Endpoints.delete_Remove_upstream; + }; + patch: { + "/api/nginx/upstream_targets/{upstream_target_id}": Endpoints.patch_Update_upstream_target; + "/api/nginx/upstreams/{upstream_id}": Endpoints.patch_Update_upstream; + }; }; // @@ -70,6 +279,8 @@ export type EndpointByMethod = { // export type PostEndpoints = EndpointByMethod["post"]; export type GetEndpoints = EndpointByMethod["get"]; +export type DeleteEndpoints = EndpointByMethod["delete"]; +export type PatchEndpoints = EndpointByMethod["patch"]; // // @@ -364,6 +575,68 @@ export class ApiClient { } // + // + delete( + path: Path, + ...params: MaybeOptionalArg< + TEndpoint extends { parameters: infer UParams } + ? NotNever extends true + ? UParams & { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean } + : { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean } + : { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean } + > + ): Promise, { data: {} }>["data"]>; + + delete( + path: Path, + ...params: MaybeOptionalArg< + TEndpoint extends { parameters: infer UParams } + ? NotNever extends true + ? UParams & { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean } + : { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean } + : { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean } + > + ): Promise>; + + delete( + path: Path, + ...params: MaybeOptionalArg + ): Promise { + return this.request("delete", path, ...params); + } + // + + // + patch( + path: Path, + ...params: MaybeOptionalArg< + TEndpoint extends { parameters: infer UParams } + ? NotNever extends true + ? UParams & { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean } + : { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean } + : { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean } + > + ): Promise, { data: {} }>["data"]>; + + patch( + path: Path, + ...params: MaybeOptionalArg< + TEndpoint extends { parameters: infer UParams } + ? NotNever extends true + ? UParams & { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean } + : { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean } + : { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean } + > + ): Promise>; + + patch( + path: Path, + ...params: MaybeOptionalArg + ): Promise { + return this.request("patch", path, ...params); + } + // + // /** * Generic request method with full type-safety for any endpoint diff --git a/apps/frontend/app/generated/api-client/tanstack-client.ts b/apps/frontend/app/generated/api-client/tanstack-client.ts index 0cd843e..5469fe8 100644 --- a/apps/frontend/app/generated/api-client/tanstack-client.ts +++ b/apps/frontend/app/generated/api-client/tanstack-client.ts @@ -43,6 +43,8 @@ const createQueryKey = ( // export type PostEndpoints = EndpointByMethod["post"]; export type GetEndpoints = EndpointByMethod["get"]; +export type DeleteEndpoints = EndpointByMethod["delete"]; +export type PatchEndpoints = EndpointByMethod["patch"]; // // @@ -130,6 +132,66 @@ export class TanstackQueryApiClient { } // + // + delete( + path: Path, + ...params: MaybeOptionalArg + ) { + const queryKey = createQueryKey(path as string, params[0]); + const query = { + /** type-only property if you need easy access to the endpoint params */ + "~endpoint": {} as TEndpoint, + queryKey, + queryFn: {} as "You need to pass .queryOptions to the useQuery hook", + queryOptions: queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const requestParams = { + ...(params[0] || {}), + ...(queryKey[0] || {}), + overrides: { signal }, + withResponse: false as const, + }; + const res = await this.client.delete(path, requestParams as never); + return res as InferResponseData; + }, + queryKey: queryKey, + }), + }; + + return query; + } + // + + // + patch( + path: Path, + ...params: MaybeOptionalArg + ) { + const queryKey = createQueryKey(path as string, params[0]); + const query = { + /** type-only property if you need easy access to the endpoint params */ + "~endpoint": {} as TEndpoint, + queryKey, + queryFn: {} as "You need to pass .queryOptions to the useQuery hook", + queryOptions: queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const requestParams = { + ...(params[0] || {}), + ...(queryKey[0] || {}), + overrides: { signal }, + withResponse: false as const, + }; + const res = await this.client.patch(path, requestParams as never); + return res as InferResponseData; + }, + queryKey: queryKey, + }), + }; + + return query; + } + // + // /** * Generic mutation method with full type-safety for any endpoint; it doesnt require parameters to be passed initially diff --git a/public/database/src/generated/entities/mod.rs b/public/database/src/generated/entities/mod.rs index 1917343..8adf245 100644 --- a/public/database/src/generated/entities/mod.rs +++ b/public/database/src/generated/entities/mod.rs @@ -3,5 +3,7 @@ pub mod prelude; pub mod config; +pub mod upstream; +pub mod upstream_target; pub mod user; pub mod user_identity; diff --git a/public/database/src/generated/entities/prelude.rs b/public/database/src/generated/entities/prelude.rs index f0df089..537ed66 100644 --- a/public/database/src/generated/entities/prelude.rs +++ b/public/database/src/generated/entities/prelude.rs @@ -1,5 +1,7 @@ //! `SeaORM` Entity, @generated by sea-orm-codegen 2.0.0-rc.18 pub use super::config::Entity as Config; +pub use super::upstream::Entity as Upstream; +pub use super::upstream_target::Entity as UpstreamTarget; pub use super::user::Entity as User; pub use super::user_identity::Entity as UserIdentity; diff --git a/public/database/src/generated/entities/upstream.rs b/public/database/src/generated/entities/upstream.rs new file mode 100644 index 0000000..bbbe5de --- /dev/null +++ b/public/database/src/generated/entities/upstream.rs @@ -0,0 +1,23 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0.0-rc.18 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "upstream")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub name: String, + pub protocol: String, + pub algorithm: String, + pub sticky_session: bool, + pub created_by: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[sea_orm(has_many)] + pub upstream_targets: HasMany, +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/public/database/src/generated/entities/session.rs b/public/database/src/generated/entities/upstream_target.rs similarity index 67% rename from public/database/src/generated/entities/session.rs rename to public/database/src/generated/entities/upstream_target.rs index fc26f9f..a35acf7 100644 --- a/public/database/src/generated/entities/session.rs +++ b/public/database/src/generated/entities/upstream_target.rs @@ -5,25 +5,26 @@ use serde::{Deserialize, Serialize}; #[sea_orm::model] #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] -#[sea_orm(table_name = "session")] +#[sea_orm(table_name = "upstream_target")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, - pub user_id: Uuid, - #[sea_orm(unique)] - pub refresh_token_hash: Option, - pub expires_at: DateTimeUtc, - pub revoked_at: Option, + pub upstream_id: Uuid, + pub target_host: String, + pub target_port: i64, + pub weight: i64, + pub is_backup: bool, + pub enabled: bool, pub created_at: DateTimeUtc, pub updated_at: DateTimeUtc, #[sea_orm( belongs_to, - from = "user_id", + from = "upstream_id", to = "id", on_update = "Cascade", on_delete = "Cascade" )] - pub user: HasOne, + pub upstream: HasOne, } impl ActiveModelBehavior for ActiveModel {} diff --git a/public/migration/doc/nginx-tables.md b/public/migration/doc/nginx-tables.md new file mode 100644 index 0000000..2e09164 --- /dev/null +++ b/public/migration/doc/nginx-tables.md @@ -0,0 +1,208 @@ +# Migration Tables → nginx mapping + +This document explains the purpose of each migration table added under `public/migration/src/migrations` and how the rows map to generated nginx configuration (HTTP `http {}` and `stream {}` contexts). + +Summary of tables covered: + +- `upstream` +- `upstream_target` +- `proxy_host` +- `location` +- `stream_service` +- `access_list` +- `access_list_entry` +- `audit_log` + +--- + +## `upstream` + +Purpose: A named backend pool of servers. Shared by HTTP and stream services. + +Key fields: + +- `id`: UUID primary key +- `name`: identifier used when generating nginx `upstream {}` +- `protocol`: `http` | `tcp` | `udp` — determines how nginx will use the pool +- `algorithm`: load balancing strategy (`round_robin`, `least_conn`, `ip_hash`) +- `sticky_session`: whether to enable sticky behavior when supported +- `health_check`: optional JSON describing health probes + +nginx mapping (HTTP): + +```nginx +upstream { + server 10.0.0.5:8080 weight=2; + server 10.0.0.6:8080 backup; + # optional LB settings generated from `algorithm` and `sticky_session` +} + +server { + listen 80; + server_name example.com; + + location / { + proxy_pass http://; + } +} +``` + +nginx mapping (stream): + +```nginx +stream { + upstream { + server 10.0.0.5:3306; + server 10.0.0.6:3306 backup; + } + + server { + listen 3306; + proxy_pass ; + } +} +``` + +Notes: `upstream.protocol` selects which block and directive forms to generate; + +--- + +## `upstream_target` + +Purpose: One row per backend server in an `upstream` pool. + +Key fields: + +- `upstream_id`: FK to `upstream` +- `target_host`, `target_port` +- `weight`, `is_backup`, `enabled` + +nginx mapping: each row becomes a `server` line in the generated `upstream` block (weights and backup flags applied). Disabled targets are omitted. + +Example generated line: + +```nginx +server 10.0.0.5:8080 weight=3; +server 10.0.0.6:8080 backup; +``` + +--- + +## `proxy_host` + +Purpose: Represents an HTTP(S) host (a top-level `server` block in nginx `http` context). + +Key fields: + +- `domain`: `server_name` value (may be a wildcard) +- `listen_port`: port to listen on (80/443) +- `scheme`: http|https (informs UI; TLS handled elsewhere) +- `forward_host/forward_port` or `default_upstream_id`: host-level forwarding fallback +- `preserve_host_header`: whether to forward original `Host` header +- `enable_websocket`: toggles websocket header handling +- `meta`: JSON for optional host-level settings (timeouts, client_max_body_size, custom snippets) + +nginx mapping (host-level default): + +```nginx +server { + listen ; + server_name ; + + # host-level fallback if no matching location + location / { + proxy_pass http://; + } +} +``` + +If `forward_host`/`forward_port` is set instead of `default_upstream_id`, generate `proxy_pass http://forward_host:forward_port;`. + +`meta` entries are injected into the `server` block (careful: snippets can break reloads). + +--- + +## `location` + +Purpose: Path-level routing (`location` blocks inside a `server`). More specific than `proxy_host` default. + +Key fields: + +- `host_id`: FK to `proxy_host` +- `path`: `location` match (e.g., `/api`, `~^/assets/`) +- `match_type`: `prefix` | `exact` | `regex` +- `upstream_id` or `proxy_pass_host`/`proxy_pass_port` +- `allowed_methods`: optional method whitelist +- `custom_config`: raw nginx snippet inserted inside the `location` + +nginx mapping: + +```nginx +location /api { + proxy_pass http://api_upstream; + # optional custom_config injected here +} +``` + +Ordering and match type produce correct nginx `location` selection semantics; `order` field can break ties for equal specificity. + +--- + +## `stream_service` + +Purpose: A TCP/UDP service in nginx `stream` context — corresponds to a `server` block inside `stream {}`. + +Key fields: + +- `listen_host`, `listen_port` +- `protocol`: `tcp` | `udp` +- `mode`: `direct` | `upstream` (direct forwards to `forward_host:forward_port`, `upstream` uses `upstream_id` pool) +- `preserved_client_ip`: whether to enable proxy_protocol or other client-ip forwarding +- `meta`: JSON for advanced stream options (ssl_preread, proxy_timeout, buffer sizes) + +nginx mapping (stream): + +```nginx +stream { + upstream { + server 10.0.0.5:3306; + } + + server { + listen 3306; + proxy_pass ; # or proxy_pass 10.0.0.10:3306 for direct + } +} +``` + +Notes: Stream services bypass HTTP processing. Use `meta` for `proxy_protocol` and `ssl_preread` toggles. + +--- + +## `access_list` and `access_list_entry` + +Purpose: Nameable allow/deny lists for IP/CIDR or other entry types that can be applied to hosts/locations/stream services. + +Key fields (access_list): `id`, `name`, `description`. +Key fields (entry): `access_list_id`, `entry_type` (e.g., `allow`, `deny`, `note`), `value` (IP or CIDR), `comment`. + +nginx mapping (HTTP example): + +```nginx +location /admin { + allow 10.0.0.0/24; + deny all; + proxy_pass http://admin_upstream; +} +``` + +nginx mapping (stream example): + +```nginx +server { + listen 3306; + allow 10.0.0.0/24; + deny all; + proxy_pass backend_pool; +} +``` diff --git a/public/migration/src/lib.rs b/public/migration/src/lib.rs index 83e0779..f31841c 100644 --- a/public/migration/src/lib.rs +++ b/public/migration/src/lib.rs @@ -13,6 +13,8 @@ impl MigratorTrait for Migrator { Box::new(m20251011_000001_create_config_table::Migration), Box::new(m20251011_000002_create_user_table::Migration), Box::new(m20251011_000003_create_user_identity_table::Migration), + Box::new(m20251223_000004_create_upstream_table::Migration), + Box::new(m20251223_000005_create_upstream_target_table::Migration), ] } } diff --git a/public/migration/src/migrations.rs b/public/migration/src/migrations.rs index 2ff1c48..aef516b 100644 --- a/public/migration/src/migrations.rs +++ b/public/migration/src/migrations.rs @@ -1,3 +1,5 @@ pub mod m20251011_000001_create_config_table; pub mod m20251011_000002_create_user_table; pub mod m20251011_000003_create_user_identity_table; +pub mod m20251223_000004_create_upstream_table; +pub mod m20251223_000005_create_upstream_target_table; diff --git a/public/migration/src/migrations/m20251223_000004_create_upstream_table.rs b/public/migration/src/migrations/m20251223_000004_create_upstream_table.rs new file mode 100644 index 0000000..e6df994 --- /dev/null +++ b/public/migration/src/migrations/m20251223_000004_create_upstream_table.rs @@ -0,0 +1,66 @@ +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[forbid(dead_code)] +#[derive(DeriveIden)] +pub enum Upstream { + Table, + Id, + Name, + Protocol, + Algorithm, + StickySession, + CreatedBy, + CreatedAt, + UpdatedAt, +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Upstream::Table) + .if_not_exists() + .col(pk_uuid(Upstream::Id)) + .col(ColumnDef::new(Upstream::Name).string().not_null()) + .col(ColumnDef::new(Upstream::Protocol).string().not_null()) + .col( + ColumnDef::new(Upstream::Algorithm) + .string() + .default("round_robin") + .not_null(), + ) + .col( + ColumnDef::new(Upstream::StickySession) + .boolean() + .default(false) + .not_null(), + ) + .col(ColumnDef::new(Upstream::CreatedBy).uuid().null()) + .col( + ColumnDef::new(Upstream::CreatedAt) + .timestamp() + .default(SimpleExpr::Keyword(Keyword::CurrentTimestamp)) + .not_null(), + ) + .col( + ColumnDef::new(Upstream::UpdatedAt) + .timestamp() + .default(SimpleExpr::Keyword(Keyword::CurrentTimestamp)) + .not_null(), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Upstream::Table).to_owned()) + .await + } +} diff --git a/public/migration/src/migrations/m20251223_000005_create_upstream_target_table.rs b/public/migration/src/migrations/m20251223_000005_create_upstream_target_table.rs new file mode 100644 index 0000000..0d1567a --- /dev/null +++ b/public/migration/src/migrations/m20251223_000005_create_upstream_target_table.rs @@ -0,0 +1,92 @@ +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[forbid(dead_code)] +#[derive(DeriveIden)] +pub enum UpstreamTarget { + Table, + Id, + UpstreamId, + TargetHost, + TargetPort, + Weight, + IsBackup, + Enabled, + CreatedAt, + UpdatedAt, +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(UpstreamTarget::Table) + .if_not_exists() + .col(pk_uuid(UpstreamTarget::Id)) + .col(ColumnDef::new(UpstreamTarget::UpstreamId).uuid().not_null()) + .foreign_key( + ForeignKey::create() + .name("fk-upstream-target-upstream-id") + .from(UpstreamTarget::Table, UpstreamTarget::UpstreamId) + .to( + super::m20251223_000004_create_upstream_table::Upstream::Table, + super::m20251223_000004_create_upstream_table::Upstream::Id, + ) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::Cascade), + ) + .col( + ColumnDef::new(UpstreamTarget::TargetHost) + .string() + .not_null(), + ) + .col( + ColumnDef::new(UpstreamTarget::TargetPort) + .integer() + .not_null(), + ) + .col( + ColumnDef::new(UpstreamTarget::Weight) + .integer() + .default(1) + .not_null(), + ) + .col( + ColumnDef::new(UpstreamTarget::IsBackup) + .boolean() + .default(false) + .not_null(), + ) + .col( + ColumnDef::new(UpstreamTarget::Enabled) + .boolean() + .default(true) + .not_null(), + ) + .col( + ColumnDef::new(UpstreamTarget::CreatedAt) + .timestamp() + .default(SimpleExpr::Keyword(Keyword::CurrentTimestamp)) + .not_null(), + ) + .col( + ColumnDef::new(UpstreamTarget::UpdatedAt) + .timestamp() + .default(SimpleExpr::Keyword(Keyword::CurrentTimestamp)) + .not_null(), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(UpstreamTarget::Table).to_owned()) + .await + } +}