From ab840126b3f2a7ddb5337b6a5fe21eb2c6d0e6d7 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Wed, 7 Jan 2026 15:57:13 +0800 Subject: [PATCH 1/8] feat: add location and proxy host entities with migrations --- .../src/generated/entities/location.rs | 46 +++++++ public/database/src/generated/entities/mod.rs | 2 + .../src/generated/entities/prelude.rs | 2 + .../src/generated/entities/proxy_host.rs | 48 +++++++ .../src/generated/entities/upstream.rs | 4 + .../database/src/generated/entities/user.rs | 2 + public/migration/src/lib.rs | 2 + public/migration/src/migrations.rs | 2 + .../m20260102_000006_create_proxy_table.rs | 124 ++++++++++++++++++ .../m20260102_000007_create_location_table.rs | 115 ++++++++++++++++ 10 files changed, 347 insertions(+) create mode 100644 public/database/src/generated/entities/location.rs create mode 100644 public/database/src/generated/entities/proxy_host.rs create mode 100644 public/migration/src/migrations/m20260102_000006_create_proxy_table.rs create mode 100644 public/migration/src/migrations/m20260102_000007_create_location_table.rs diff --git a/public/database/src/generated/entities/location.rs b/public/database/src/generated/entities/location.rs new file mode 100644 index 0000000..bd0e8bb --- /dev/null +++ b/public/database/src/generated/entities/location.rs @@ -0,0 +1,46 @@ +//! `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 = "location")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub host_id: Uuid, + pub path: String, + pub match_type: String, + pub order: i64, + pub upstream_id: Option, + pub proxy_pass_protocol: Option, + pub proxy_pass_host: Option, + pub proxy_pass_port: Option, + pub preserve_host_header: Option, + #[sea_orm(column_type = "JsonBinary", nullable)] + pub allowed_methods: Option, + #[sea_orm(column_type = "Text", nullable)] + pub custom_config: Option, + pub enabled: bool, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[sea_orm( + belongs_to, + from = "host_id", + to = "id", + on_update = "Cascade", + on_delete = "Cascade" + )] + pub proxy_host: HasOne, + #[sea_orm( + belongs_to, + from = "upstream_id", + to = "id", + on_update = "Cascade", + on_delete = "SetNull" + )] + pub upstream: HasOne, +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/public/database/src/generated/entities/mod.rs b/public/database/src/generated/entities/mod.rs index 8adf245..0ce0143 100644 --- a/public/database/src/generated/entities/mod.rs +++ b/public/database/src/generated/entities/mod.rs @@ -3,6 +3,8 @@ pub mod prelude; pub mod config; +pub mod location; +pub mod proxy_host; pub mod upstream; pub mod upstream_target; pub mod user; diff --git a/public/database/src/generated/entities/prelude.rs b/public/database/src/generated/entities/prelude.rs index 537ed66..9e4d28b 100644 --- a/public/database/src/generated/entities/prelude.rs +++ b/public/database/src/generated/entities/prelude.rs @@ -1,6 +1,8 @@ //! `SeaORM` Entity, @generated by sea-orm-codegen 2.0.0-rc.18 pub use super::config::Entity as Config; +pub use super::location::Entity as Location; +pub use super::proxy_host::Entity as ProxyHost; pub use super::upstream::Entity as Upstream; pub use super::upstream_target::Entity as UpstreamTarget; pub use super::user::Entity as User; diff --git a/public/database/src/generated/entities/proxy_host.rs b/public/database/src/generated/entities/proxy_host.rs new file mode 100644 index 0000000..0b5997f --- /dev/null +++ b/public/database/src/generated/entities/proxy_host.rs @@ -0,0 +1,48 @@ +//! `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 = "proxy_host")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub name: Option, + pub domain: String, + pub scheme: String, + pub listen_port: i64, + pub forward_scheme: String, + pub forward_host: Option, + pub forward_port: Option, + pub preserve_host_header: bool, + pub enable_websocket: bool, + pub enabled: bool, + #[sea_orm(column_type = "JsonBinary", nullable)] + pub meta: Option, + pub default_upstream_id: Option, + pub created_by: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[sea_orm(has_many)] + pub locations: HasMany, + #[sea_orm( + belongs_to, + from = "default_upstream_id", + to = "id", + on_update = "Cascade", + on_delete = "SetNull" + )] + pub upstream: HasOne, + #[sea_orm( + belongs_to, + from = "created_by", + to = "id", + on_update = "Cascade", + on_delete = "SetNull" + )] + pub user: HasOne, +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/public/database/src/generated/entities/upstream.rs b/public/database/src/generated/entities/upstream.rs index bbbe5de..0d88dd6 100644 --- a/public/database/src/generated/entities/upstream.rs +++ b/public/database/src/generated/entities/upstream.rs @@ -17,6 +17,10 @@ pub struct Model { pub created_at: DateTimeUtc, pub updated_at: DateTimeUtc, #[sea_orm(has_many)] + pub locations: HasMany, + #[sea_orm(has_many)] + pub proxy_hosts: HasMany, + #[sea_orm(has_many)] pub upstream_targets: HasMany, } diff --git a/public/database/src/generated/entities/user.rs b/public/database/src/generated/entities/user.rs index ab2edb2..b40aea5 100644 --- a/public/database/src/generated/entities/user.rs +++ b/public/database/src/generated/entities/user.rs @@ -18,6 +18,8 @@ pub struct Model { pub last_login_at: Option, pub deleted_at: Option, #[sea_orm(has_many)] + pub proxy_hosts: HasMany, + #[sea_orm(has_many)] pub user_identities: HasMany, } diff --git a/public/migration/src/lib.rs b/public/migration/src/lib.rs index f31841c..19a171a 100644 --- a/public/migration/src/lib.rs +++ b/public/migration/src/lib.rs @@ -15,6 +15,8 @@ impl MigratorTrait for Migrator { 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), + Box::new(m20260102_000006_create_proxy_table::Migration), + Box::new(m20260102_000007_create_location_table::Migration), ] } } diff --git a/public/migration/src/migrations.rs b/public/migration/src/migrations.rs index aef516b..370a7ea 100644 --- a/public/migration/src/migrations.rs +++ b/public/migration/src/migrations.rs @@ -3,3 +3,5 @@ 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; +pub mod m20260102_000006_create_proxy_table; +pub mod m20260102_000007_create_location_table; diff --git a/public/migration/src/migrations/m20260102_000006_create_proxy_table.rs b/public/migration/src/migrations/m20260102_000006_create_proxy_table.rs new file mode 100644 index 0000000..bcdd3fa --- /dev/null +++ b/public/migration/src/migrations/m20260102_000006_create_proxy_table.rs @@ -0,0 +1,124 @@ +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[forbid(dead_code)] +#[derive(DeriveIden)] +pub enum ProxyHost { + Table, + Id, + Name, + Domain, + Scheme, + ListenPort, + ForwardScheme, + ForwardHost, + ForwardPort, + PreserveHostHeader, + EnableWebsocket, + Enabled, + Meta, + DefaultUpstreamId, + 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(ProxyHost::Table) + .if_not_exists() + .col(pk_uuid(ProxyHost::Id)) + .col(ColumnDef::new(ProxyHost::Name).string().null()) + .col(ColumnDef::new(ProxyHost::Domain).string().not_null()) + .col( + ColumnDef::new(ProxyHost::Scheme) + .string() + .default("http") + .not_null(), + ) + .col( + ColumnDef::new(ProxyHost::ListenPort) + .integer() + .default(80) + .not_null(), + ) + .col( + ColumnDef::new(ProxyHost::ForwardScheme) + .string() + .default("http") + .not_null(), + ) + .col(ColumnDef::new(ProxyHost::ForwardHost).string().null()) + .col(ColumnDef::new(ProxyHost::ForwardPort).integer().null()) + .col( + ColumnDef::new(ProxyHost::PreserveHostHeader) + .boolean() + .default(false) + .not_null(), + ) + .col( + ColumnDef::new(ProxyHost::EnableWebsocket) + .boolean() + .default(false) + .not_null(), + ) + .col( + ColumnDef::new(ProxyHost::Enabled) + .boolean() + .default(true) + .not_null(), + ) + .col(ColumnDef::new(ProxyHost::Meta).json_binary().null()) + .col(ColumnDef::new(ProxyHost::DefaultUpstreamId).uuid().null()) + .foreign_key( + ForeignKey::create() + .name("fk-proxy-host-default-upstream-id") + .from(ProxyHost::Table, ProxyHost::DefaultUpstreamId) + .to( + super::m20251223_000004_create_upstream_table::Upstream::Table, + super::m20251223_000004_create_upstream_table::Upstream::Id, + ) + .on_delete(ForeignKeyAction::SetNull) + .on_update(ForeignKeyAction::Cascade), + ) + .col(ColumnDef::new(ProxyHost::CreatedBy).uuid().null()) + .foreign_key( + ForeignKey::create() + .name("fk-proxy-host-created-by") + .from(ProxyHost::Table, ProxyHost::CreatedBy) + .to( + super::m20251011_000002_create_user_table::User::Table, + super::m20251011_000002_create_user_table::User::Id, + ) + .on_delete(ForeignKeyAction::SetNull) + .on_update(ForeignKeyAction::Cascade), + ) + .col( + ColumnDef::new(ProxyHost::CreatedAt) + .timestamp() + .default(SimpleExpr::Keyword(Keyword::CurrentTimestamp)) + .not_null(), + ) + .col( + ColumnDef::new(ProxyHost::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(ProxyHost::Table).to_owned()) + .await + } +} diff --git a/public/migration/src/migrations/m20260102_000007_create_location_table.rs b/public/migration/src/migrations/m20260102_000007_create_location_table.rs new file mode 100644 index 0000000..31c1ea8 --- /dev/null +++ b/public/migration/src/migrations/m20260102_000007_create_location_table.rs @@ -0,0 +1,115 @@ +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[forbid(dead_code)] +#[derive(DeriveIden)] +pub enum Location { + Table, + Id, + HostId, + Path, + MatchType, + Order, + UpstreamId, + ProxyPassProtocol, + ProxyPassHost, + ProxyPassPort, + PreserveHostHeader, + AllowedMethods, + CustomConfig, + 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(Location::Table) + .if_not_exists() + .col(pk_uuid(Location::Id)) + .col(ColumnDef::new(Location::HostId).uuid().not_null()) + .foreign_key( + ForeignKey::create() + .name("fk-location-host-id") + .from(Location::Table, Location::HostId) + .to( + super::m20260102_000006_create_proxy_table::ProxyHost::Table, + super::m20260102_000006_create_proxy_table::ProxyHost::Id, + ) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::Cascade), + ) + .col(ColumnDef::new(Location::Path).string().not_null()) + .col( + ColumnDef::new(Location::MatchType) + .string() + .default("prefix") + .not_null(), + ) + .col( + ColumnDef::new(Location::Order) + .integer() + .default(0) + .not_null(), + ) + .col(ColumnDef::new(Location::UpstreamId).uuid().null()) + .foreign_key( + ForeignKey::create() + .name("fk-location-upstream-id") + .from(Location::Table, Location::UpstreamId) + .to( + super::m20251223_000004_create_upstream_table::Upstream::Table, + super::m20251223_000004_create_upstream_table::Upstream::Id, + ) + .on_delete(ForeignKeyAction::SetNull) + .on_update(ForeignKeyAction::Cascade), + ) + .col(ColumnDef::new(Location::ProxyPassProtocol).string().null()) + .col(ColumnDef::new(Location::ProxyPassHost).string().null()) + .col(ColumnDef::new(Location::ProxyPassPort).integer().null()) + .col( + ColumnDef::new(Location::PreserveHostHeader) + .boolean() + .null(), + ) + .col( + ColumnDef::new(Location::AllowedMethods) + .json_binary() + .null(), + ) + .col(ColumnDef::new(Location::CustomConfig).text().null()) + .col( + ColumnDef::new(Location::Enabled) + .boolean() + .default(true) + .not_null(), + ) + .col( + ColumnDef::new(Location::CreatedAt) + .timestamp() + .default(SimpleExpr::Keyword(Keyword::CurrentTimestamp)) + .not_null(), + ) + .col( + ColumnDef::new(Location::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(Location::Table).to_owned()) + .await + } +} From 1c0053207cafed25f54b58f01158dc5318d2ef9a Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Wed, 7 Jan 2026 15:57:44 +0800 Subject: [PATCH 2/8] feat: Implement ProxyHost and Location services with CRUD operations - Added `ProxyHostInfo`, `ProxyHostCreateInfo`, and `UpdateProxyHostInfo` structs to manage proxy host data. - Created `ProxyService` and `ProxyServiceImpl` for handling proxy host operations including create, read, update, and delete. - Implemented `LocationService` and `LocationServiceImpl` for managing locations associated with proxy hosts. - Introduced database transaction handling for creating proxies and locations. - Added tests for all service methods to ensure functionality and correctness. --- apps/api/src/services/nginx.rs | 21 +- apps/api/src/services/nginx/builder.rs | 10 +- apps/api/src/services/nginx/info.rs | 2 + apps/api/src/services/nginx/info/location.rs | 296 +++++++++ .../api/src/services/nginx/info/proxy_host.rs | 251 ++++++++ apps/api/src/services/nginx/location.rs | 512 +++++++++++++++ apps/api/src/services/nginx/proxy_host.rs | 598 ++++++++++++++++++ 7 files changed, 1684 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/services/nginx/info/location.rs create mode 100644 apps/api/src/services/nginx/info/proxy_host.rs create mode 100644 apps/api/src/services/nginx/location.rs create mode 100644 apps/api/src/services/nginx/proxy_host.rs diff --git a/apps/api/src/services/nginx.rs b/apps/api/src/services/nginx.rs index fd56b07..e21b882 100644 --- a/apps/api/src/services/nginx.rs +++ b/apps/api/src/services/nginx.rs @@ -2,6 +2,8 @@ pub mod builder; pub mod info; pub mod traits; +pub mod location; +pub mod proxy_host; pub mod upstream; use std::sync::Arc; @@ -14,6 +16,8 @@ use crate::{ agent_client::AgentService, nginx::{ builder::{NginxConfigBuilder, NginxConfigProvider}, + location::{LocationService, LocationServiceImpl}, + proxy_host::{ProxyService, ProxyServiceImpl}, upstream::{UpstreamService, UpstreamServiceImpl}, }, }, @@ -23,7 +27,12 @@ pub struct NginxService { #[allow(dead_code)] connection: Arc, // + #[allow(dead_code)] upstream_service: Arc, + #[allow(dead_code)] + proxy_service: Arc, + #[allow(dead_code)] + location_service: Arc, } impl NginxService { @@ -32,6 +41,8 @@ impl NginxService { connection: connection.clone(), // upstream_service: Arc::new(UpstreamServiceImpl::new(connection.clone())), + proxy_service: Arc::new(ProxyServiceImpl::new(connection.clone())), + location_service: Arc::new(LocationServiceImpl::new(connection.clone())), } } @@ -39,6 +50,14 @@ impl NginxService { self.upstream_service.clone() } + pub fn get_proxy_service(&self) -> Arc { + self.proxy_service.clone() + } + + pub fn get_location_service(&self) -> Arc { + self.location_service.clone() + } + #[allow(dead_code)] pub async fn validate_config( &self, @@ -69,7 +88,7 @@ impl NginxService { .generate_config(&mut builder, tx) .await?; - Ok(builder.to_nginx_config(None)) + builder.to_nginx_config(None) } pub async fn regenerate_and_apply_config( diff --git a/apps/api/src/services/nginx/builder.rs b/apps/api/src/services/nginx/builder.rs index 4a55df2..0b435bb 100644 --- a/apps/api/src/services/nginx/builder.rs +++ b/apps/api/src/services/nginx/builder.rs @@ -1,9 +1,9 @@ -use crate::services::nginx::info::upstream::UpstreamInfo; +use crate::{errors::service_error::ServiceError, services::nginx::info::upstream::UpstreamInfo}; pub const INDENT_SIZE: usize = 2; pub trait NginxConfigProvider { - fn to_nginx_config(&self, indent: Option) -> String; + fn to_nginx_config(&self, indent: Option) -> Result; } #[derive(Default)] @@ -24,7 +24,7 @@ impl NginxConfigBuilder { } impl NginxConfigProvider for NginxConfigBuilder { - fn to_nginx_config(&self, indent: Option) -> String { + fn to_nginx_config(&self, indent: Option) -> Result { let mut config = format!( "# Nginx Config Generated by YANPM at {}", chrono::Utc::now() @@ -32,12 +32,12 @@ impl NginxConfigProvider for NginxConfigBuilder { for upstream in &self.upstreams { config.push('\n'); - config.push_str(&upstream.to_nginx_config(indent)); + 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 + Ok(config) } } diff --git a/apps/api/src/services/nginx/info.rs b/apps/api/src/services/nginx/info.rs index b74b8b1..358edb9 100644 --- a/apps/api/src/services/nginx/info.rs +++ b/apps/api/src/services/nginx/info.rs @@ -1,2 +1,4 @@ +pub mod location; +pub mod proxy_host; pub mod upstream; pub mod upstream_target; diff --git a/apps/api/src/services/nginx/info/location.rs b/apps/api/src/services/nginx/info/location.rs new file mode 100644 index 0000000..ea91755 --- /dev/null +++ b/apps/api/src/services/nginx/info/location.rs @@ -0,0 +1,296 @@ +use chrono::{DateTime, Utc}; +use database::generated::entities::{location, proxy_host, upstream}; +use sea_orm::ActiveValue::{Set, Unchanged}; +use tracing::warn; +use uuid::Uuid; + +use crate::{ + errors::service_error::ServiceError, + services::nginx::{builder::NginxConfigProvider, traits::indentable::Indentable}, + set_if_some, +}; + +use serde_json::Value as JsonValue; + +#[derive(Clone)] +pub struct ProxyPassInfo { + pub protocol: String, + pub host: String, + pub port: i64, +} + +#[derive(Clone)] +pub struct LocationInfo { + pub id: Uuid, + pub host_id: Uuid, + pub path: String, + pub match_type: String, + pub order: i64, + pub upstream_id: Option, + pub proxy_pass_info: Option, + pub preserve_host_header: Option, + pub allowed_methods: Option>, + pub custom_config: Option, + pub enabled: bool, + pub created_at: DateTime, + pub updated_at: DateTime, + // + pub upstream: Option, + pub proxy_host: Option, +} + +pub struct CreateLocationInfo { + pub host_id: Uuid, + pub path: String, + pub match_type: String, + pub order: i64, + pub upstream_id: Option, + pub proxy_pass_protocol: Option, + pub proxy_pass_host: Option, + pub proxy_pass_port: Option, + pub preserve_host_header: Option, + pub allowed_methods: Option>, + pub custom_config: Option, + pub enabled: bool, +} + +#[derive(Clone)] +pub struct UpdateLocationInfo { + pub path: Option, + pub match_type: Option, + pub order: Option, + pub upstream_id: Option>, + pub proxy_pass_protocol: Option>, + pub proxy_pass_host: Option>, + pub proxy_pass_port: Option>, + pub preserve_host_header: Option>, + pub allowed_methods: Option>>, + pub custom_config: Option>, + pub enabled: Option, +} + +impl From for LocationInfo { + fn from(model: location::Model) -> Self { + let allowed_methods: Option> = match model.allowed_methods { + Some(JsonValue::Array(arr)) => { + let v: Vec = arr + .into_iter() + .filter_map(|val| val.as_str().map(|s| s.to_string())) + .collect(); + if v.is_empty() { None } else { Some(v) } + } + _ => None, + }; + + Self { + id: model.id, + host_id: model.host_id, + path: model.path, + match_type: model.match_type, + order: model.order, + upstream_id: model.upstream_id, + proxy_pass_info: match ( + model.proxy_pass_protocol, + model.proxy_pass_host, + model.proxy_pass_port, + ) { + (Some(protocol), Some(host), Some(port)) => Some(ProxyPassInfo { + protocol, + host, + port, + }), + (Some(_), _, _) | (_, Some(_), _) | (_, _, Some(_)) => { + warn!("Incomplete proxy_pass_info for location {}", model.id); + None + } + + _ => None, + }, + preserve_host_header: model.preserve_host_header, + allowed_methods, + custom_config: model.custom_config, + enabled: model.enabled, + created_at: model.created_at, + updated_at: model.updated_at, + upstream: None, + proxy_host: None, + } + } +} + +impl From<(location::Model, Option)> for LocationInfo { + fn from(data: (location::Model, Option)) -> Self { + let (location_model, proxy_host_model_opt) = data; + (location_model, proxy_host_model_opt, None).into() + } +} + +impl + From<( + location::Model, + Option, + Option, + )> for LocationInfo +{ + fn from( + data: ( + location::Model, + Option, + Option, + ), + ) -> Self { + let (location_model, proxy_host_model_opt, upstream_model_opt) = data; + let mut location_info = LocationInfo::from(location_model); + + if let Some(upstream_model) = upstream_model_opt { + location_info.upstream = Some(super::upstream::UpstreamInfo::from(upstream_model)); + } + + if let Some(proxy_host_model) = proxy_host_model_opt { + location_info.proxy_host = + Some(super::proxy_host::ProxyHostInfo::from(proxy_host_model)); + } + + location_info + } +} + +impl From for location::ActiveModel { + fn from(val: CreateLocationInfo) -> Self { + location::ActiveModel { + id: Set(Uuid::new_v4()), + host_id: Set(val.host_id), + path: Set(val.path), + match_type: Set(val.match_type), + order: Set(val.order), + upstream_id: Set(val.upstream_id), + proxy_pass_protocol: Set(val.proxy_pass_protocol), + proxy_pass_host: Set(val.proxy_pass_host), + proxy_pass_port: Set(val.proxy_pass_port), + preserve_host_header: Set(val.preserve_host_header), + allowed_methods: Set(val + .allowed_methods + .map(|v| JsonValue::Array(v.into_iter().map(JsonValue::String).collect()))), + custom_config: Set(val.custom_config), + enabled: Set(val.enabled), + created_at: Set(chrono::Utc::now()), + updated_at: Set(chrono::Utc::now()), + } + } +} + +impl UpdateLocationInfo { + pub fn apply_to_model(self, current_model: location::Model) -> location::ActiveModel { + location::ActiveModel { + id: Unchanged(current_model.id), + host_id: Unchanged(current_model.host_id), + path: set_if_some!(self.path), + match_type: set_if_some!(self.match_type), + order: set_if_some!(self.order), + upstream_id: match self.upstream_id { + Some(inner) => Set(inner), + None => Unchanged(current_model.upstream_id), + }, + proxy_pass_protocol: match self.proxy_pass_protocol { + Some(inner) => Set(inner), + None => Unchanged(current_model.proxy_pass_protocol), + }, + proxy_pass_host: match self.proxy_pass_host { + Some(inner) => Set(inner), + None => Unchanged(current_model.proxy_pass_host), + }, + proxy_pass_port: match self.proxy_pass_port { + Some(inner) => Set(inner), + None => Unchanged(current_model.proxy_pass_port), + }, + preserve_host_header: match self.preserve_host_header { + Some(inner) => Set(inner), + None => Unchanged(current_model.preserve_host_header), + }, + allowed_methods: match self.allowed_methods { + Some(inner) => { + let json_opt = inner + .map(|v| JsonValue::Array(v.into_iter().map(JsonValue::String).collect())); + Set(json_opt) + } + None => Unchanged(current_model.allowed_methods), + }, + custom_config: match self.custom_config { + Some(inner) => Set(inner), + None => Unchanged(current_model.custom_config), + }, + enabled: set_if_some!(self.enabled), + created_at: Unchanged(current_model.created_at), + updated_at: Set(chrono::Utc::now()), + } + } +} + +impl NginxConfigProvider for LocationInfo { + fn to_nginx_config(&self, indent: Option) -> Result { + let indent = indent.unwrap_or(0); + + let selector = match self.match_type.as_str() { + "exact" => format!("location = {} ", self.path), + "regex" => format!("location ~ {} ", self.path), + _ => format!("location {} ", self.path), + }; + + let mut body_lines: Vec = Vec::new(); + + if let Some(methods) = &self.allowed_methods + && !methods.is_empty() + { + body_lines.push(format!( + "limit_except {} {{ deny all; }}", + methods.join(" ") + )); + } + + if let Some(upstream) = &self.upstream { + body_lines.push(format!( + "proxy_pass {}://{};", + upstream.protocol, upstream.name + )); + } else if let Some(host) = &self.proxy_pass_info { + body_lines.push(format!( + "proxy_pass {}://{}:{};", + host.protocol, host.host, host.port + )); + } else { + warn!( + "Location {} has neither upstream nor proxy_pass_host defined", + self.id + ); + return Err(ServiceError::InternalError( + "Location must have either an upstream or a proxy_pass_host defined".to_string(), + )); + } + + if let Some(preserve) = self.preserve_host_header { + if preserve { + body_lines.push("proxy_set_header Host $host;".to_string()); + } else { + body_lines.push("proxy_set_header Host $proxy_host;".to_string()); + } + } + + if let Some(cfg) = &self.custom_config + && !cfg.trim().is_empty() + { + body_lines.push(cfg.clone()); + } + + let inner = if body_lines.is_empty() { + "# location has no config".to_string() + } else { + body_lines + .into_iter() + .map(|l| l.indent(indent + 2)) + .collect::>() + .join("\n") + }; + + Ok(format!("{}{{\n{}\n}}", selector.trim_end(), inner).indent(indent)) + } +} diff --git a/apps/api/src/services/nginx/info/proxy_host.rs b/apps/api/src/services/nginx/info/proxy_host.rs new file mode 100644 index 0000000..0971e70 --- /dev/null +++ b/apps/api/src/services/nginx/info/proxy_host.rs @@ -0,0 +1,251 @@ +use chrono::{DateTime, Utc}; +use database::generated::entities::{location, proxy_host}; +use sea_orm::ActiveValue::{Set, Unchanged}; +use serde_json::Value as JsonValue; +use uuid::Uuid; + +use crate::{ + errors::service_error::ServiceError, + services::nginx::{ + builder::{INDENT_SIZE, NginxConfigProvider}, + traits::indentable::Indentable, + }, + set_if_some, +}; + +#[derive(Clone)] +pub struct ProxyHostInfo { + pub id: Uuid, + pub name: Option, + pub domain: String, + pub scheme: String, + pub listen_port: i64, + pub forward_scheme: String, + pub forward_host: Option, + pub forward_port: Option, + pub preserve_host_header: bool, + pub enable_websocket: bool, + pub meta: Option, + pub enabled: bool, + pub created_at: DateTime, + pub updated_at: DateTime, + // + pub upstream: Option, + pub locations: Vec, +} + +pub struct ProxyHostCreateInfo { + pub name: Option, + pub domain: String, + pub scheme: String, + pub listen_port: i64, + pub forward_scheme: String, + pub forward_host: Option, + pub forward_port: Option, + pub preserve_host_header: bool, + pub enable_websocket: bool, + pub enabled: bool, + pub meta: Option, + pub default_upstream_id: Option, + pub created_by: Option, + // + pub locations: Vec, +} + +#[derive(Clone)] +pub struct UpdateProxyHostInfo { + pub name: Option>, + pub domain: Option, + pub scheme: Option, + pub listen_port: Option, + pub forward_scheme: Option, + pub forward_host: Option>, + pub forward_port: Option>, + pub preserve_host_header: Option, + pub enable_websocket: Option, + pub enabled: Option, + pub meta: Option>, + pub default_upstream_id: Option>, +} + +impl From for ProxyHostInfo { + fn from(model: proxy_host::Model) -> Self { + Self { + id: model.id, + name: model.name, + domain: model.domain, + scheme: model.scheme, + listen_port: model.listen_port, + forward_scheme: model.forward_scheme, + forward_host: model.forward_host, + forward_port: model.forward_port, + preserve_host_header: model.preserve_host_header, + enable_websocket: model.enable_websocket, + meta: model.meta, + enabled: model.enabled, + created_at: model.created_at, + updated_at: model.updated_at, + upstream: None, + locations: Vec::new(), + } + } +} + +impl From<(proxy_host::Model, Vec)> for ProxyHostInfo { + fn from(data: (proxy_host::Model, Vec)) -> Self { + let (proxy_model, location_models) = data; + let mut proxy_info = ProxyHostInfo::from(proxy_model); + let locations_info: Vec = + location_models.into_iter().map(|m| m.into()).collect(); + proxy_info.locations = locations_info; + proxy_info + } +} + +impl From for (proxy_host::ActiveModel, Vec) { + fn from(val: ProxyHostCreateInfo) -> Self { + let proxy_host = proxy_host::ActiveModel { + id: Set(Uuid::new_v4()), + name: Set(val.name), + domain: Set(val.domain), + scheme: Set(val.scheme), + listen_port: Set(val.listen_port), + forward_scheme: Set(val.forward_scheme), + forward_host: Set(val.forward_host), + forward_port: Set(val.forward_port), + preserve_host_header: Set(val.preserve_host_header), + enable_websocket: Set(val.enable_websocket), + enabled: Set(val.enabled), + meta: Set(val.meta), + default_upstream_id: Set(val.default_upstream_id), + created_by: Set(val.created_by), + created_at: Set(chrono::Utc::now()), + updated_at: Set(chrono::Utc::now()), + }; + let location_models = val.locations.into_iter().map(|loc| loc.into()).collect(); + (proxy_host, location_models) + } +} + +impl From for proxy_host::ActiveModel { + fn from(val: ProxyHostInfo) -> Self { + proxy_host::ActiveModel { + id: Set(val.id), + name: Set(val.name), + domain: Set(val.domain), + scheme: Set(val.scheme), + listen_port: Set(val.listen_port), + forward_scheme: Set(val.forward_scheme), + forward_host: Set(val.forward_host), + forward_port: Set(val.forward_port), + preserve_host_header: Set(val.preserve_host_header), + enable_websocket: Set(val.enable_websocket), + enabled: Set(val.enabled), + meta: Set(val.meta), + default_upstream_id: Set(val.upstream.as_ref().map(|u| u.id)), + created_by: Set(None), + created_at: Set(val.created_at), + updated_at: Set(val.updated_at), + } + } +} + +impl UpdateProxyHostInfo { + pub fn apply_to_model(self, current_model: proxy_host::Model) -> proxy_host::ActiveModel { + proxy_host::ActiveModel { + id: Unchanged(current_model.id), + name: match self.name { + Some(inner) => Set(inner), + None => Unchanged(current_model.name), + }, + domain: set_if_some!(self.domain), + scheme: set_if_some!(self.scheme), + listen_port: set_if_some!(self.listen_port), + forward_scheme: set_if_some!(self.forward_scheme), + forward_host: match self.forward_host { + Some(inner) => Set(inner), + None => Unchanged(current_model.forward_host), + }, + forward_port: match self.forward_port { + Some(inner) => Set(inner), + None => Unchanged(current_model.forward_port), + }, + preserve_host_header: set_if_some!(self.preserve_host_header), + enable_websocket: set_if_some!(self.enable_websocket), + enabled: set_if_some!(self.enabled), + meta: set_if_some!(self.meta), + default_upstream_id: match self.default_upstream_id { + Some(inner) => Set(inner), + None => Unchanged(current_model.default_upstream_id), + }, + created_by: Unchanged(current_model.created_by), + created_at: Unchanged(current_model.created_at), + updated_at: Set(chrono::Utc::now()), + } + } +} + +impl NginxConfigProvider for ProxyHostInfo { + fn to_nginx_config(&self, indent: Option) -> Result { + let indent = indent.unwrap_or(0); + + let mut body: Vec = Vec::new(); + + // default location or fallback + let default_pass = if let Some(up) = &self.upstream { + format!("proxy_pass http://{};", up.name) + } else if let Some(host) = &self.forward_host { + if let Some(port) = self.forward_port { + format!("proxy_pass http://{}:{};", host, port) + } else { + format!("proxy_pass http://{};", host) + } + } else { + String::new() + }; + + // get locations's index sorted by order to prevent mutable borrow issues + let mut index_list: Vec = (0..self.locations.len()).collect(); + index_list.sort_by(|&a, &b| { + let order_a = self.locations[a].order; + let order_b = self.locations[b].order; + order_a.cmp(&order_b) + }); + + for &index in &index_list { + let loc = &self.locations[index]; + body.push(loc.to_nginx_config(Some(indent + INDENT_SIZE))?); + } + + // If there is a default proxy_pass and no root location for `/`, add it + if !default_pass.is_empty() { + body.insert( + 0, + format!( + "location / {{\n{}\n}}", + default_pass.indent(indent + INDENT_SIZE) + ), + ); + } + + if self.enable_websocket { + body.push("proxy_set_header Upgrade $http_upgrade;".to_string()); + body.push("proxy_set_header Connection \"upgrade\";".to_string()); + } + + let inner = if body.is_empty() { + "# server has no config".to_string() + } else { + body.into_iter() + .map(|l| l.indent(indent + INDENT_SIZE)) + .collect::>() + .join("\n") + }; + + Ok(format!( + "server {{\n listen {};\n server_name {};\n{}\n}}", + self.listen_port, self.domain, inner + ) + .indent(indent)) + } +} diff --git a/apps/api/src/services/nginx/location.rs b/apps/api/src/services/nginx/location.rs new file mode 100644 index 0000000..6cf889f --- /dev/null +++ b/apps/api/src/services/nginx/location.rs @@ -0,0 +1,512 @@ +use std::sync::Arc; + +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DatabaseConnection, DatabaseTransaction, EntityTrait, + ModelTrait, QueryFilter, QuerySelect, QueryTrait, +}; + +use database::generated::entities::{location, proxy_host, upstream}; + +use crate::{ + errors::service_error::ServiceError, + helpers::database::PaginationFilter, + services::nginx::info::location::{CreateLocationInfo, LocationInfo, UpdateLocationInfo}, + with_conn, +}; + +#[async_trait::async_trait] +pub trait LocationService: Send + Sync { + async fn create_location( + &self, + create_info: CreateLocationInfo, + tx: Option<&mut DatabaseTransaction>, + ) -> Result; + #[allow(dead_code)] + async fn get_locations( + &self, + pagination: Option, + options: Option, + tx: Option<&mut DatabaseTransaction>, + ) -> Result, ServiceError>; + async fn get_location( + &self, + location_id: uuid::Uuid, + options: Option, + tx: Option<&mut DatabaseTransaction>, + ) -> Result; + async fn update_location( + &self, + location_id: uuid::Uuid, + update: UpdateLocationInfo, + tx: Option<&mut DatabaseTransaction>, + ) -> Result; + async fn delete_location( + &self, + location_id: uuid::Uuid, + tx: Option<&mut DatabaseTransaction>, + ) -> Result<(), ServiceError>; +} + +pub struct LocationServiceImpl { + connection: Arc, +} + +#[allow(dead_code)] +pub struct LocationTotalCountOptions {} + +#[derive(Default)] +pub struct GetLocationOptions { + pub include_upstream: bool, + #[allow(dead_code)] + pub filter_by_enabled: bool, +} + +impl LocationServiceImpl { + pub fn new(connection: Arc) -> Self { + Self { connection } + } +} + +#[async_trait::async_trait] +impl LocationService for LocationServiceImpl { + async fn create_location( + &self, + create_info: CreateLocationInfo, + tx: Option<&mut DatabaseTransaction>, + ) -> Result { + let model: location::ActiveModel = create_info.into(); + + let r = with_conn!(&*self.connection, tx, conn, { model.insert(*conn).await? }); + Ok(r.into()) + } + + async fn get_locations( + &self, + pagination: Option, + options: Option, + tx: Option<&mut DatabaseTransaction>, + ) -> Result, ServiceError> { + let r = with_conn!(&*self.connection, tx, conn, { + let mut find_query = location::Entity::find(); + if let Some(pagination) = pagination { + let (offset, limit) = pagination.get_offset_limit(); + find_query = find_query.offset(offset).limit(limit); + } + let find_query = find_query + .apply_if( + options + .as_ref() + .is_some_and(|v| v.filter_by_enabled) + .then_some(true), + |q, _v| q.filter(location::Column::Enabled.eq(true)), + ) + .find_also_related(proxy_host::Entity); + + let r: Vec = if options.as_ref().is_some_and(|v| v.include_upstream) { + find_query + .find_also_related(upstream::Entity) + .all(*conn) + .await? + .into_iter() + .map(|v| v.into()) + .collect() + } else { + find_query + .all(*conn) + .await? + .into_iter() + .map(|m| m.into()) + .collect() + }; + r + }); + + Ok(r) + } + + async fn get_location( + &self, + location_id: uuid::Uuid, + options: Option, + tx: Option<&mut DatabaseTransaction>, + ) -> Result { + let r = with_conn!(&*self.connection, tx, conn, { + let find_query = + location::Entity::find_by_id(location_id).find_also_related(proxy_host::Entity); + + let r: Option = if options.as_ref().is_some_and(|v| v.include_upstream) { + find_query + .find_also_related(upstream::Entity) + .one(*conn) + .await? + .map(|v| v.into()) + } else { + find_query.one(*conn).await?.map(|m| m.into()) + }; + r + }); + + Ok(r.ok_or(ServiceError::NotFound(format!( + "Location with id {} not found", + location_id + )))?) + } + + async fn update_location( + &self, + location_id: uuid::Uuid, + update: UpdateLocationInfo, + tx: Option<&mut DatabaseTransaction>, + ) -> Result { + let current_model = with_conn!(&*self.connection, tx, conn, { + location::Entity::find_by_id(location_id) + .one(*conn) + .await? + .ok_or(ServiceError::NotFound(format!( + "Location with id {} not found", + location_id + )))? + }); + let active_model = update.apply_to_model(current_model); + + let r = with_conn!(&*self.connection, tx, conn, { + active_model.update(*conn).await? + }); + Ok(r.into()) + } + + async fn delete_location( + &self, + location_id: uuid::Uuid, + tx: Option<&mut DatabaseTransaction>, + ) -> Result<(), ServiceError> { + let model = with_conn!(&*self.connection, tx, conn, { + location::Entity::find_by_id(location_id) + .one(*conn) + .await? + .ok_or(ServiceError::NotFound(format!( + "Location with id {} not found", + location_id + )))? + }); + with_conn!(&*self.connection, tx, conn, { + model.delete(*conn).await?; + Ok(()) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + use sea_orm::MockExecResult; + use sea_orm::{DatabaseBackend, MockDatabase}; + + use database::generated::entities::{location, proxy_host}; + + #[tokio::test] + async fn create_location_returns_info() { + let host_id = uuid::Uuid::new_v4(); + let created = location::Model { + id: uuid::Uuid::new_v4(), + host_id, + path: "/test".to_string(), + match_type: "prefix".to_string(), + order: 0, + upstream_id: None, + proxy_pass_protocol: None, + proxy_pass_host: None, + proxy_pass_port: None, + preserve_host_header: None, + allowed_methods: None, + custom_config: None, + 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 = LocationServiceImpl::new(Arc::new(db)); + + let create_info = CreateLocationInfo { + host_id, + path: "/test".to_string(), + match_type: "prefix".to_string(), + order: 0, + upstream_id: None, + proxy_pass_protocol: None, + proxy_pass_host: None, + proxy_pass_port: None, + preserve_host_header: None, + allowed_methods: None, + custom_config: None, + enabled: true, + }; + + let res = svc.create_location(create_info, None).await; + assert!(res.is_ok()); + let info = res.expect("Failed to create location"); + assert_eq!(info.path, "/test"); + } + + #[tokio::test] + async fn get_locations_returns_list() { + let host_id = uuid::Uuid::new_v4(); + + let l1 = location::Model { + id: uuid::Uuid::new_v4(), + host_id, + path: "/a".to_string(), + match_type: "prefix".to_string(), + order: 0, + upstream_id: None, + proxy_pass_protocol: None, + proxy_pass_host: None, + proxy_pass_port: None, + preserve_host_header: None, + allowed_methods: None, + custom_config: None, + enabled: true, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + let l2 = location::Model { + id: uuid::Uuid::new_v4(), + host_id, + path: "/b".to_string(), + match_type: "prefix".to_string(), + order: 1, + upstream_id: None, + proxy_pass_protocol: None, + proxy_pass_host: None, + proxy_pass_port: None, + preserve_host_header: None, + allowed_methods: None, + custom_config: None, + enabled: true, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![vec![ + (l1.clone(), None::), + (l2.clone(), None::), + ]]) + .into_connection(); + + let svc = LocationServiceImpl::new(Arc::new(db)); + + let res = svc.get_locations(None, None, None).await; + assert!(res.is_ok()); + let list = res.expect("Failed to get locations"); + assert_eq!(list.len(), 2); + } + + #[tokio::test] + async fn get_location_with_upstream_returns_upstream() { + let host_id = uuid::Uuid::new_v4(); + let up_id = uuid::Uuid::new_v4(); + + let loc = location::Model { + id: uuid::Uuid::new_v4(), + host_id, + path: "/up".to_string(), + match_type: "prefix".to_string(), + order: 0, + upstream_id: Some(up_id), + proxy_pass_protocol: None, + proxy_pass_host: None, + proxy_pass_port: None, + preserve_host_header: None, + allowed_methods: None, + custom_config: None, + enabled: true, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![vec![(loc.clone(), None::)]]) + .into_connection(); + + let svc = LocationServiceImpl::new(Arc::new(db)); + + let res = svc + .get_location( + loc.id, + Some(GetLocationOptions { + include_upstream: false, + filter_by_enabled: false, + }), + None, + ) + .await; + + assert!(res.is_ok()); + let info = res.expect("Failed to get location"); + assert_eq!(info.id, loc.id); + assert_eq!(info.upstream_id, Some(up_id)); + } + + #[tokio::test] + async fn get_location_not_found_returns_not_found() { + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![Vec::::new()]) + .into_connection(); + + let svc = LocationServiceImpl::new(Arc::new(db)); + + let res = svc.get_location(uuid::Uuid::new_v4(), None, None).await; + + assert!(matches!(res, Err(ServiceError::NotFound(_)))); + } + + #[tokio::test] + async fn update_location_success() { + let id = uuid::Uuid::new_v4(); + let host_id = uuid::Uuid::new_v4(); + let existing = location::Model { + id, + host_id, + path: "/old".to_string(), + match_type: "prefix".to_string(), + order: 0, + upstream_id: None, + proxy_pass_protocol: None, + proxy_pass_host: None, + proxy_pass_port: None, + preserve_host_header: None, + allowed_methods: None, + custom_config: None, + enabled: true, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + let updated = location::Model { + id, + host_id, + path: "/new".to_string(), + match_type: "prefix".to_string(), + order: 0, + upstream_id: None, + proxy_pass_protocol: None, + proxy_pass_host: None, + proxy_pass_port: None, + preserve_host_header: None, + allowed_methods: None, + custom_config: None, + 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()]]) // find_by_id + .append_query_results(vec![vec![updated.clone()]]) // update result + .into_connection(); + + let svc = LocationServiceImpl::new(Arc::new(db)); + + let update_info = UpdateLocationInfo { + path: Some("/new".to_string()), + match_type: None, + order: None, + upstream_id: None, + proxy_pass_protocol: None, + proxy_pass_host: None, + proxy_pass_port: None, + preserve_host_header: None, + allowed_methods: None, + custom_config: None, + enabled: None, + }; + + let res = svc.update_location(id, update_info, None).await; + assert!(res.is_ok()); + let got = res.expect("Failed to update location"); + assert_eq!(got.path, "/new"); + } + + #[tokio::test] + async fn update_location_not_found() { + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![Vec::::new()]) + .into_connection(); + + let svc = LocationServiceImpl::new(Arc::new(db)); + + let res = svc + .update_location( + uuid::Uuid::new_v4(), + UpdateLocationInfo { + path: None, + match_type: None, + order: None, + upstream_id: None, + proxy_pass_protocol: None, + proxy_pass_host: None, + proxy_pass_port: None, + preserve_host_header: None, + allowed_methods: None, + custom_config: None, + enabled: None, + }, + None, + ) + .await; + + assert!(matches!(res, Err(ServiceError::NotFound(_)))); + } + + #[tokio::test] + async fn delete_location_success() { + let id = uuid::Uuid::new_v4(); + let existing = location::Model { + id, + host_id: uuid::Uuid::new_v4(), + path: "/del".to_string(), + match_type: "prefix".to_string(), + order: 0, + upstream_id: None, + proxy_pass_protocol: None, + proxy_pass_host: None, + proxy_pass_port: None, + preserve_host_header: None, + allowed_methods: None, + custom_config: None, + 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 = LocationServiceImpl::new(Arc::new(db)); + let res = svc.delete_location(id, None).await; + assert!(res.is_ok()); + } + + #[tokio::test] + async fn delete_location_not_found() { + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![Vec::::new()]) + .into_connection(); + + let svc = LocationServiceImpl::new(Arc::new(db)); + + let res = svc.delete_location(uuid::Uuid::new_v4(), None).await; + assert!(matches!(res, Err(ServiceError::NotFound(_)))); + } +} diff --git a/apps/api/src/services/nginx/proxy_host.rs b/apps/api/src/services/nginx/proxy_host.rs new file mode 100644 index 0000000..e77626e --- /dev/null +++ b/apps/api/src/services/nginx/proxy_host.rs @@ -0,0 +1,598 @@ +use std::sync::Arc; + +use sea_orm::{ + ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection, DatabaseTransaction, + EntityTrait, FromQueryResult, JoinType, ModelTrait, QueryFilter, QuerySelect, QueryTrait, + RelationTrait, TransactionTrait, +}; + +use database::generated::entities::{location, proxy_host}; + +use crate::{ + errors::service_error::ServiceError, + helpers::database::PaginationFilter, + services::nginx::info::proxy_host::{ProxyHostCreateInfo, ProxyHostInfo, UpdateProxyHostInfo}, + with_conn, +}; + +#[async_trait::async_trait] +pub trait ProxyService: Send + Sync { + async fn create_proxy( + &self, + create_info: ProxyHostCreateInfo, + tx: Option<&mut DatabaseTransaction>, + ) -> Result; + async fn get_total_proxies( + &self, + options: Option, + tx: Option<&mut DatabaseTransaction>, + ) -> Result; + async fn get_proxies( + &self, + pagination: Option, + options: Option, + tx: Option<&mut DatabaseTransaction>, + ) -> Result, ServiceError>; + async fn get_proxy( + &self, + proxy_id: uuid::Uuid, + options: Option, + tx: Option<&mut DatabaseTransaction>, + ) -> Result; + async fn update_proxy( + &self, + proxy_id: uuid::Uuid, + update: UpdateProxyHostInfo, + tx: Option<&mut DatabaseTransaction>, + ) -> Result; + async fn delete_proxy( + &self, + proxy_id: uuid::Uuid, + tx: Option<&mut DatabaseTransaction>, + ) -> Result<(), ServiceError>; +} + +pub struct ProxyServiceImpl { + connection: Arc, +} + +#[allow(dead_code)] +pub struct ProxyTotalCountOptions {} + +#[derive(Default)] +pub struct ProxyHostGetOptions { + pub include_upstream: bool, + pub filter_by_enabled: bool, +} + +pub type ProxyHostListOptions = ProxyHostGetOptions; + +impl ProxyServiceImpl { + pub fn new(connection: Arc) -> Self { + Self { connection } + } +} + +#[async_trait::async_trait] +impl ProxyService for ProxyServiceImpl { + async fn create_proxy( + &self, + create_info: ProxyHostCreateInfo, + tx: Option<&mut DatabaseTransaction>, + ) -> Result { + let (proxy_host, location_models): (proxy_host::ActiveModel, Vec) = + create_info.into(); + + 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 inserted_proxy = proxy_host.insert(*conn).await?; + let mut inserted_location_models: Vec = + Vec::with_capacity(location_models.len()); + + for mut loc_model in location_models { + loc_model.host_id = Set(inserted_proxy.id); + let r = loc_model.insert(*conn).await?; + inserted_location_models.push(r); + } + (inserted_proxy, inserted_location_models) + }); + + if let Some(t) = maybe_owned_tx.take() { + t.commit().await?; + } + Ok(r.into()) + } + + async fn get_total_proxies( + &self, + _options: Option, + tx: Option<&mut DatabaseTransaction>, + ) -> Result { + #[derive(Debug, FromQueryResult)] + struct CountResult { + count: i64, + } + let count_info = with_conn!(&*self.connection, tx, conn, { + proxy_host::Entity::find() + .select_only() + .column_as(proxy_host::Column::Id.count(), "count") + .into_model::() + .one(*conn) + .await? + }); + Ok(count_info.map_or(0, |c| c.count) as u64) + } + + async fn get_proxies( + &self, + pagination: Option, + options: Option, + tx: Option<&mut DatabaseTransaction>, + ) -> Result, ServiceError> { + let r = with_conn!(&*self.connection, tx, conn, { + let mut find_query = proxy_host::Entity::find(); + if let Some(pagination) = pagination { + let (offset, limit) = pagination.get_offset_limit(); + find_query = find_query.offset(offset).limit(limit); + } + + let find_query = find_query + .apply_if( + options + .as_ref() + .is_some_and(|v| v.filter_by_enabled) + .then_some(true), + |q, _v| q.filter(location::Column::Enabled.eq(true)), + ) + .find_with_related(location::Entity); + + let r: Vec = if options.as_ref().is_some_and(|v| v.include_upstream) { + find_query + .join(JoinType::LeftJoin, proxy_host::Relation::Upstream.def()) + .all(*conn) + .await? + .into_iter() + .map(|v| v.into()) + .collect() + } else { + find_query + .all(*conn) + .await? + .into_iter() + .map(|m| m.into()) + .collect() + }; + r + }); + Ok(r) + } + + async fn get_proxy( + &self, + proxy_id: uuid::Uuid, + options: Option, + tx: Option<&mut DatabaseTransaction>, + ) -> Result { + let r: ProxyHostInfo = with_conn!(&*self.connection, tx, conn, { + let find_query = proxy_host::Entity::find_by_id(proxy_id) + .apply_if( + options + .as_ref() + .is_some_and(|v| v.filter_by_enabled) + .then_some(true), + |q, _v| q.filter(location::Column::Enabled.eq(true)), + ) + .find_with_related(location::Entity); + let r: Option = if options.as_ref().is_some_and(|v| v.include_upstream) { + find_query + .join(JoinType::LeftJoin, proxy_host::Relation::Upstream.def()) + .all(*conn) + .await? + .into_iter() + .next() + .map(|v| v.into()) + } else { + find_query + .all(*conn) + .await? + .into_iter() + .map(|m| m.into()) + .next() + }; + r.ok_or(ServiceError::NotFound(format!( + "Proxy host with id {} not found", + proxy_id + )))? + }); + Ok(r) + } + + async fn update_proxy( + &self, + proxy_id: uuid::Uuid, + update: UpdateProxyHostInfo, + tx: Option<&mut DatabaseTransaction>, + ) -> Result { + let current_model = with_conn!(&*self.connection, tx, conn, { + proxy_host::Entity::find_by_id(proxy_id) + .one(*conn) + .await? + .ok_or(ServiceError::NotFound(format!( + "Proxy host with id {} not found", + proxy_id + )))? + }); + let active_model = update.apply_to_model(current_model); + + let r = with_conn!(&*self.connection, tx, conn, { + active_model.update(*conn).await? + }); + Ok(r.into()) + } + + async fn delete_proxy( + &self, + proxy_id: uuid::Uuid, + tx: Option<&mut DatabaseTransaction>, + ) -> Result<(), ServiceError> { + let model = with_conn!(&*self.connection, tx, conn, { + proxy_host::Entity::find_by_id(proxy_id) + .one(*conn) + .await? + .ok_or(ServiceError::NotFound(format!( + "Proxy host with id {} not found", + proxy_id + )))? + }); + with_conn!(&*self.connection, tx, conn, { + model.delete(*conn).await?; + Ok(()) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + use sea_orm::MockExecResult; + use sea_orm::{DatabaseBackend, MockDatabase}; + + use database::generated::entities::{location, proxy_host}; + + #[tokio::test] + async fn create_proxy_returns_info() { + let id = uuid::Uuid::new_v4(); + let created = proxy_host::Model { + id, + name: Some("test_proxy".to_string()), + domain: "example.com".to_string(), + scheme: "http".to_string(), + listen_port: 80, + forward_scheme: "http".to_string(), + forward_host: None, + forward_port: None, + preserve_host_header: false, + enable_websocket: false, + enabled: true, + meta: None, + default_upstream_id: None, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let loc = location::Model { + id: uuid::Uuid::new_v4(), + host_id: id, + path: "/".to_string(), + match_type: "prefix".to_string(), + order: 0, + upstream_id: None, + proxy_pass_protocol: None, + proxy_pass_host: None, + proxy_pass_port: None, + preserve_host_header: None, + allowed_methods: None, + custom_config: None, + 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()]]) + .append_query_results(vec![vec![loc.clone()]]) + .into_connection(); + + let svc = ProxyServiceImpl::new(Arc::new(db)); + + let create_info = crate::services::nginx::info::proxy_host::ProxyHostCreateInfo { + name: Some("test_proxy".to_string()), + domain: "example.com".to_string(), + scheme: "http".to_string(), + listen_port: 80, + forward_scheme: "http".to_string(), + forward_host: None, + forward_port: None, + preserve_host_header: false, + enable_websocket: false, + enabled: true, + meta: None, + default_upstream_id: None, + created_by: None, + locations: Vec::new(), + }; + + let res = svc.create_proxy(create_info, None).await; + assert!(res.is_ok()); + let info = res.expect("Failed to create proxy"); + assert_eq!(info.domain, "example.com"); + } + + #[tokio::test] + async fn get_total_proxies_returns_count() { + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![Vec::::new()]) + .into_connection(); + + let svc = ProxyServiceImpl::new(Arc::new(db)); + let res = svc + .get_total_proxies(None, None) + .await + .expect("Failed to get total proxies"); + assert_eq!(res, 0u64); + } + + #[tokio::test] + async fn get_proxies_returns_list() { + let p1 = proxy_host::Model { + id: uuid::Uuid::new_v4(), + name: Some("p1".to_string()), + domain: "d1".to_string(), + scheme: "http".to_string(), + listen_port: 80, + forward_scheme: "http".to_string(), + forward_host: None, + forward_port: None, + preserve_host_header: false, + enable_websocket: false, + enabled: true, + meta: None, + default_upstream_id: None, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + let p2 = proxy_host::Model { + id: uuid::Uuid::new_v4(), + name: Some("p2".to_string()), + domain: "d2".to_string(), + scheme: "http".to_string(), + listen_port: 80, + forward_scheme: "http".to_string(), + forward_host: None, + forward_port: None, + preserve_host_header: false, + enable_websocket: false, + enabled: true, + meta: None, + default_upstream_id: None, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![vec![ + (p1.clone(), None::), + (p2.clone(), None::), + ]]) + .into_connection(); + + let svc = ProxyServiceImpl::new(Arc::new(db)); + let res = svc.get_proxies(None, None, None).await; + assert!(res.is_ok()); + let list = res.expect("Failed to get proxies"); + assert_eq!(list.len(), 2); + } + + #[tokio::test] + async fn get_proxy_returns_info() { + let id = uuid::Uuid::new_v4(); + let p = proxy_host::Model { + id, + name: Some("proxy".to_string()), + domain: "ex.com".to_string(), + scheme: "http".to_string(), + listen_port: 80, + forward_scheme: "http".to_string(), + forward_host: None, + forward_port: None, + preserve_host_header: false, + enable_websocket: false, + enabled: true, + meta: None, + default_upstream_id: None, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![vec![(p.clone(), None::)]]) + .into_connection(); + + let svc = ProxyServiceImpl::new(Arc::new(db)); + let res = svc.get_proxy(id, None, None).await; + assert!(res.is_ok()); + let got = res.expect("Failed to get proxy"); + assert_eq!(got.id, id); + } + + #[tokio::test] + async fn get_proxy_not_found() { + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![Vec::::new()]) + .into_connection(); + + let svc = ProxyServiceImpl::new(Arc::new(db)); + let res = svc.get_proxy(uuid::Uuid::new_v4(), None, None).await; + assert!(matches!(res, Err(ServiceError::NotFound(_)))); + } + + #[tokio::test] + async fn update_proxy_success() { + let id = uuid::Uuid::new_v4(); + let existing = proxy_host::Model { + id, + name: Some("old".to_string()), + domain: "d".to_string(), + scheme: "http".to_string(), + listen_port: 80, + forward_scheme: "http".to_string(), + forward_host: None, + forward_port: None, + preserve_host_header: false, + enable_websocket: false, + enabled: true, + meta: None, + default_upstream_id: None, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + let updated = proxy_host::Model { + id, + name: Some("new".to_string()), + domain: existing.domain.clone(), + scheme: existing.scheme.clone(), + listen_port: existing.listen_port, + forward_scheme: existing.forward_scheme.clone(), + forward_host: existing.forward_host.clone(), + forward_port: existing.forward_port, + preserve_host_header: existing.preserve_host_header, + enable_websocket: existing.enable_websocket, + enabled: existing.enabled, + meta: existing.meta.clone(), + default_upstream_id: existing.default_upstream_id, + created_by: existing.created_by, + 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 = ProxyServiceImpl::new(Arc::new(db)); + + let update_info = crate::services::nginx::info::proxy_host::UpdateProxyHostInfo { + name: None, + domain: None, + scheme: None, + listen_port: None, + forward_scheme: None, + forward_host: None, + forward_port: None, + preserve_host_header: None, + enable_websocket: None, + enabled: None, + meta: None, + default_upstream_id: None, + }; + + let res = svc.update_proxy(id, update_info, None).await; + assert!(res.is_ok()); + let got = res.expect("Failed to update proxy"); + assert_eq!(got.name.expect("Name should be present"), "new"); + } + + #[tokio::test] + async fn update_proxy_not_found() { + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![Vec::::new()]) + .into_connection(); + + let svc = ProxyServiceImpl::new(Arc::new(db)); + + let res = svc + .update_proxy( + uuid::Uuid::new_v4(), + crate::services::nginx::info::proxy_host::UpdateProxyHostInfo { + name: None, + domain: None, + scheme: None, + listen_port: None, + forward_scheme: None, + forward_host: None, + forward_port: None, + preserve_host_header: None, + enable_websocket: None, + enabled: None, + meta: None, + default_upstream_id: None, + }, + None, + ) + .await; + + assert!(matches!(res, Err(ServiceError::NotFound(_)))); + } + + #[tokio::test] + async fn delete_proxy_success() { + let id = uuid::Uuid::new_v4(); + let existing = proxy_host::Model { + id, + name: Some("to-delete".to_string()), + domain: "d".to_string(), + scheme: "http".to_string(), + listen_port: 80, + forward_scheme: "http".to_string(), + forward_host: None, + forward_port: None, + preserve_host_header: false, + enable_websocket: false, + enabled: true, + meta: None, + default_upstream_id: None, + 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, + }]) + .into_connection(); + + let svc = ProxyServiceImpl::new(Arc::new(db)); + + let res = svc.delete_proxy(id, None).await; + assert!(res.is_ok()); + } + + #[tokio::test] + async fn delete_proxy_not_found() { + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![Vec::::new()]) + .into_connection(); + + let svc = ProxyServiceImpl::new(Arc::new(db)); + + let res = svc.delete_proxy(uuid::Uuid::new_v4(), None).await; + assert!(matches!(res, Err(ServiceError::NotFound(_)))); + } +} From eb1afc87cc819695da5d4ac380c2558fcfb60c7c Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Wed, 7 Jan 2026 15:57:53 +0800 Subject: [PATCH 3/8] feat: Update NginxConfigProvider to return Result with ServiceError for upstream and upstream target --- apps/api/src/services/nginx/info/upstream.rs | 10 +++++++--- apps/api/src/services/nginx/info/upstream_target.rs | 7 ++++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/api/src/services/nginx/info/upstream.rs b/apps/api/src/services/nginx/info/upstream.rs index 4c38b61..b7cb0af 100644 --- a/apps/api/src/services/nginx/info/upstream.rs +++ b/apps/api/src/services/nginx/info/upstream.rs @@ -6,6 +6,7 @@ use tracing::warn; use uuid::Uuid; use crate::{ + errors::service_error::ServiceError, services::nginx::{ builder::{INDENT_SIZE, NginxConfigProvider}, info::upstream_target as upstream_target_info, @@ -51,12 +52,12 @@ pub struct UpdateUpstreamInfo { } impl NginxConfigProvider for UpstreamInfo { - fn to_nginx_config(&self, indent: Option) -> String { + fn to_nginx_config(&self, indent: Option) -> Result { let targets_config: Vec = self .upstream_targets .iter() .map(|target| target.to_nginx_config(Some(indent.unwrap_or(0) + INDENT_SIZE))) - .collect(); + .collect::, ServiceError>>()?; let mut targets_config_str = { let config_str = match self.algorithm.as_str() { @@ -104,7 +105,10 @@ impl NginxConfigProvider for UpstreamInfo { .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)) + Ok( + format!("upstream {} {{\n{}\n}}", self.name, targets_config_str) + .indent(indent.unwrap_or(0)), + ) } } diff --git a/apps/api/src/services/nginx/info/upstream_target.rs b/apps/api/src/services/nginx/info/upstream_target.rs index b558566..acee880 100644 --- a/apps/api/src/services/nginx/info/upstream_target.rs +++ b/apps/api/src/services/nginx/info/upstream_target.rs @@ -6,6 +6,7 @@ use uuid::Uuid; use database::generated::entities::{upstream, upstream_target}; use crate::{ + errors::service_error::ServiceError, services::nginx::{builder::NginxConfigProvider, traits::indentable::Indentable}, set_if_some, }; @@ -128,8 +129,8 @@ impl From for upstream_target::ActiveModel { } impl NginxConfigProvider for UpstreamTargetInfo { - fn to_nginx_config(&self, indent: Option) -> String { - format!( + fn to_nginx_config(&self, indent: Option) -> Result { + Ok(format!( "server {}:{} weight={}{}{};", self.target_host, self.target_port, @@ -137,7 +138,7 @@ impl NginxConfigProvider for UpstreamTargetInfo { if self.is_backup { " backup" } else { "" }, if !self.enabled { " down" } else { "" }, ) - .indent(indent.unwrap_or(0)) + .indent(indent.unwrap_or(0))) } } From 83e02acb22a70a38723cc67616ffe1602313e9f9 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Wed, 7 Jan 2026 15:58:03 +0800 Subject: [PATCH 4/8] Fix: Refactor upstream count retrieval and improve query filtering logic --- apps/api/src/services/nginx/upstream.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/api/src/services/nginx/upstream.rs b/apps/api/src/services/nginx/upstream.rs index 9678989..5ed9a14 100644 --- a/apps/api/src/services/nginx/upstream.rs +++ b/apps/api/src/services/nginx/upstream.rs @@ -173,7 +173,7 @@ impl UpstreamService for UpstreamServiceImpl { let count_info = with_conn!(&*self.connection, tx, conn, { upstream::Entity::find() .select_only() - .column_as(upstream::Column::Id, "count") + .column_as(upstream::Column::Id.count(), "count") .into_model::() .one(*conn) .await? @@ -199,9 +199,10 @@ impl UpstreamService for UpstreamServiceImpl { )))?; 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)) - }) + .apply_if( + concrete_options.filter_by_enabled.then_some(true), + |query, _v| query.filter(upstream_target::Column::Enabled.eq(true)), + ) .all(*conn) .await?; (up, targets) From 9b8232d94d792ca90b398ede2ba4e6d6855ca007 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Wed, 7 Jan 2026 15:58:21 +0800 Subject: [PATCH 5/8] feat: Implement NGINX proxy host and location management endpoints - Add `get_location` endpoint to retrieve location information with optional upstream inclusion. - Introduce `get_proxy_list` and `get_proxy` endpoints for listing and retrieving proxy hosts. - Implement `remove_location` and `remove_proxy` endpoints for deleting locations and proxy hosts respectively. - Add `update_location` and `update_proxy` endpoints for modifying existing locations and proxy hosts. - Create response structures for location and proxy host information. - Implement tests for all new endpoints to ensure correct functionality and error handling. --- apps/api/src/routes/api/openapi.rs | 20 + apps/api/src/routes/api/restricted/nginx.rs | 5 +- .../routes/api/restricted/nginx/proxy_host.rs | 43 ++ .../nginx/proxy_host/create_location.rs | 221 ++++++ .../nginx/proxy_host/create_proxy.rs | 308 ++++++++ .../nginx/proxy_host/get_location.rs | 150 ++++ .../restricted/nginx/proxy_host/get_proxy.rs | 281 +++++++ .../restricted/nginx/proxy_host/info/mod.rs | 1 + .../nginx/proxy_host/info/response.rs | 91 +++ .../nginx/proxy_host/remove_location.rs | 157 ++++ .../nginx/proxy_host/remove_proxy.rs | 180 +++++ .../nginx/proxy_host/update_location.rs | 218 ++++++ .../nginx/proxy_host/update_proxy.rs | 225 ++++++ apps/api/swagger.json | 707 ++++++++++++++++++ 14 files changed, 2606 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/routes/api/restricted/nginx/proxy_host.rs create mode 100644 apps/api/src/routes/api/restricted/nginx/proxy_host/create_location.rs create mode 100644 apps/api/src/routes/api/restricted/nginx/proxy_host/create_proxy.rs create mode 100644 apps/api/src/routes/api/restricted/nginx/proxy_host/get_location.rs create mode 100644 apps/api/src/routes/api/restricted/nginx/proxy_host/get_proxy.rs create mode 100644 apps/api/src/routes/api/restricted/nginx/proxy_host/info/mod.rs create mode 100644 apps/api/src/routes/api/restricted/nginx/proxy_host/info/response.rs create mode 100644 apps/api/src/routes/api/restricted/nginx/proxy_host/remove_location.rs create mode 100644 apps/api/src/routes/api/restricted/nginx/proxy_host/remove_proxy.rs create mode 100644 apps/api/src/routes/api/restricted/nginx/proxy_host/update_location.rs create mode 100644 apps/api/src/routes/api/restricted/nginx/proxy_host/update_proxy.rs diff --git a/apps/api/src/routes/api/openapi.rs b/apps/api/src/routes/api/openapi.rs index cc855e3..8bf5873 100644 --- a/apps/api/src/routes/api/openapi.rs +++ b/apps/api/src/routes/api/openapi.rs @@ -25,6 +25,17 @@ pub mod tag { 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, + // Nginx proxy host management + crate::routes::api::restricted::nginx::proxy_host::get_proxy::get_proxy_list, + crate::routes::api::restricted::nginx::proxy_host::get_proxy::get_proxy, + crate::routes::api::restricted::nginx::proxy_host::create_proxy::create_proxy, + crate::routes::api::restricted::nginx::proxy_host::update_proxy::update_proxy, + crate::routes::api::restricted::nginx::proxy_host::remove_proxy::remove_proxy, + // Proxy host locations + crate::routes::api::restricted::nginx::proxy_host::create_location::create_location, + crate::routes::api::restricted::nginx::proxy_host::get_location::get_location, + crate::routes::api::restricted::nginx::proxy_host::update_location::update_location, + crate::routes::api::restricted::nginx::proxy_host::remove_location::remove_location, ), components( schemas(crate::routes::api::health::info::HealthInfo), @@ -46,6 +57,15 @@ pub mod tag { 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), + // Nginx proxy host schemas + schemas(crate::routes::api::restricted::nginx::proxy_host::create_proxy::CreateLocationReq), + schemas(crate::routes::api::restricted::nginx::proxy_host::create_proxy::CreateProxyRequestBody), + schemas(crate::routes::api::restricted::nginx::proxy_host::create_location::CreateLocationRequestBody), + schemas(crate::routes::api::restricted::nginx::proxy_host::update_proxy::UpdateProxyRequestBody), + schemas(crate::routes::api::restricted::nginx::proxy_host::update_location::UpdateLocationRequestBody), + schemas(crate::routes::api::restricted::nginx::proxy_host::info::response::LocationInfoResponse), + schemas(crate::routes::api::restricted::nginx::proxy_host::info::response::ProxyHostInfoResponse), + schemas(crate::routes::api::restricted::nginx::proxy_host::info::response::ProxyListResponse), ), tags( (name = tag::HEALTH_TAG, description = "Health information API"), diff --git a/apps/api/src/routes/api/restricted/nginx.rs b/apps/api/src/routes/api/restricted/nginx.rs index 50803cf..613732f 100644 --- a/apps/api/src/routes/api/restricted/nginx.rs +++ b/apps/api/src/routes/api/restricted/nginx.rs @@ -1,3 +1,4 @@ +pub mod proxy_host; pub mod upstream; use std::sync::Arc; @@ -7,5 +8,7 @@ use axum::Router; use crate::routes::AppState; pub fn get_nginx_router(state: Arc) -> Router { - Router::new().merge(upstream::get_upstream_router(state.clone())) + Router::new() + .merge(proxy_host::get_proxy_router(state.clone())) + .merge(upstream::get_upstream_router(state.clone())) } diff --git a/apps/api/src/routes/api/restricted/nginx/proxy_host.rs b/apps/api/src/routes/api/restricted/nginx/proxy_host.rs new file mode 100644 index 0000000..f8a42f6 --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/proxy_host.rs @@ -0,0 +1,43 @@ +pub mod create_location; +pub mod create_proxy; +pub mod get_location; +pub mod get_proxy; +pub mod info; +pub mod remove_location; +pub mod remove_proxy; +pub mod update_location; +pub mod update_proxy; + +use std::sync::Arc; + +use axum::{ + Router, + routing::{get, post}, +}; + +use crate::routes::AppState; + +pub fn get_proxy_router(state: Arc) -> Router { + Router::new() + .route( + "/proxy_hosts", + get(get_proxy::get_proxy_list).post(create_proxy::create_proxy), + ) + .route( + "/proxy_hosts/{proxy_id}", + get(get_proxy::get_proxy) + .patch(update_proxy::update_proxy) + .delete(remove_proxy::remove_proxy), + ) + .route( + "/proxy_hosts/{proxy_id}/locations", + post(create_location::create_location), + ) + .route( + "/locations/{location_id}", + get(get_location::get_location) + .patch(update_location::update_location) + .delete(remove_location::remove_location), + ) + .with_state(state) +} diff --git a/apps/api/src/routes/api/restricted/nginx/proxy_host/create_location.rs b/apps/api/src/routes/api/restricted/nginx/proxy_host/create_location.rs new file mode 100644 index 0000000..f184c27 --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/proxy_host/create_location.rs @@ -0,0 +1,221 @@ +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::proxy_host::info::response::LocationInfoResponse, + }, + }, + services::nginx::info::location::CreateLocationInfo, +}; + +#[derive(serde::Deserialize, utoipa::ToSchema, serde::Serialize)] +pub struct CreateLocationRequestBody { + pub path: String, + pub match_type: String, + pub order: i64, + pub upstream_id: Option, +} + +#[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::{location, upstream, upstream_target}; + + use crate::{ + configs::{FromConfig, ProgramSettings}, + middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER, + routes::api::restricted::nginx::proxy_host::create_location::CreateLocationRequestBody, + routes::api::restricted::nginx::proxy_host::get_proxy_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_proxy_router(state).layer(axum::middleware::from_fn( + crate::middlewares::require_auth::mock::mock_require_auth, + )) + } + + #[tokio::test] + async fn handler_create_location_succeeds_returns_created() { + let ph_id = uuid::Uuid::new_v4(); + let loc_id = uuid::Uuid::new_v4(); + + let loc_model = location::Model { + id: loc_id, + host_id: ph_id, + path: "/".to_string(), + match_type: "prefix".to_string(), + order: 1, + upstream_id: None, + proxy_pass_protocol: None, + proxy_pass_host: None, + proxy_pass_port: None, + preserve_host_header: None, + allowed_methods: None, + custom_config: None, + enabled: true, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let up_id = uuid::Uuid::new_v4(); + let up_model = upstream::Model { + id: up_id, + 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 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![loc_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 = CreateLocationRequestBody { + path: "/".to_string(), + match_type: "prefix".to_string(), + order: 1, + upstream_id: None, + }; + + let res = server + .post(&format!("/proxy_hosts/{}/locations", ph_id)) + .json(&payload) + .await; + res.assert_status_ok(); + let body = res.json::(); + assert_eq!(body.id, loc_id); + } + + #[tokio::test] + async fn handler_create_location_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!("/proxy_hosts/{}/locations", uuid::Uuid::new_v4())) + .json(&serde_json::json!({})) + .await; + res.assert_status(StatusCode::UNPROCESSABLE_ENTITY); + } + + #[tokio::test] + async fn handler_create_location_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 = CreateLocationRequestBody { + path: "/".to_string(), + match_type: "prefix".to_string(), + order: 1, + upstream_id: None, + }; + + let res = server + .post(&format!("/proxy_hosts/{}/locations", uuid::Uuid::new_v4())) + .add_header(REQUEST_AUTH_USER_INVALID_HEADER, "true") + .json(&payload) + .await; + + res.assert_status(StatusCode::UNAUTHORIZED); + } +} + +impl From<(uuid::Uuid, CreateLocationRequestBody)> for CreateLocationInfo { + fn from(val: (uuid::Uuid, CreateLocationRequestBody)) -> Self { + Self { + host_id: val.0, + path: val.1.path, + match_type: val.1.match_type, + order: val.1.order, + upstream_id: val.1.upstream_id, + proxy_pass_protocol: None, + proxy_pass_host: None, + proxy_pass_port: None, + preserve_host_header: None, + allowed_methods: None, + custom_config: None, + enabled: true, + } + } +} + +#[axum::debug_handler] +#[utoipa::path( + post, + path = "/api/nginx/proxy_hosts/{proxy_id}/locations", + request_body = CreateLocationRequestBody, + responses( + (status = 200, description = "Location created", body = LocationInfoResponse), + (status = 401, description = "Unauthorized"), + (status = 422, description = "Invalid request"), + (status = 500, description = "Internal server error"), + ), + tag = NGINX_TAG, +)] +pub async fn create_location( + _request_info: AuthenticatedRequestInfo, + axum::extract::Path(proxy_id): axum::extract::Path, + State(state): State>, + Json(payload): Json, +) -> AxumResult, ApiError> { + let svc = &state.service.nginx.get_location_service(); + let create_info: CreateLocationInfo = (proxy_id, payload).into(); + + let mut tx = state.database_connection.begin().await?; + let info = svc.create_location(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(info.into())) +} diff --git a/apps/api/src/routes/api/restricted/nginx/proxy_host/create_proxy.rs b/apps/api/src/routes/api/restricted/nginx/proxy_host/create_proxy.rs new file mode 100644 index 0000000..da1ad9e --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/proxy_host/create_proxy.rs @@ -0,0 +1,308 @@ +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::proxy_host::info::response::ProxyHostInfoResponse, + }, + }, + services::nginx::info::proxy_host::ProxyHostCreateInfo, +}; + +#[derive(serde::Deserialize, utoipa::ToSchema, serde::Serialize)] +pub struct CreateLocationReq { + pub path: String, + pub match_type: String, + pub order: i64, + pub upstream_id: Option, +} + +#[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::{location, proxy_host, upstream, upstream_target}; + + use crate::{ + configs::{FromConfig, ProgramSettings}, + middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER, + routes::api::restricted::nginx::proxy_host::create_proxy::CreateLocationReq as ReqLocation, + routes::api::restricted::nginx::proxy_host::create_proxy::CreateProxyRequestBody, + routes::api::restricted::nginx::proxy_host::get_proxy_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_proxy_router(state).layer(axum::middleware::from_fn( + crate::middlewares::require_auth::mock::mock_require_auth, + )) + } + + #[tokio::test] + async fn handler_create_proxy_succeeds_returns_created() { + let ph_id = uuid::Uuid::new_v4(); + + let ph_model = proxy_host::Model { + id: ph_id, + name: Some("myproxy".to_string()), + domain: "example.com".to_string(), + scheme: "http".to_string(), + listen_port: 80, + forward_scheme: "http".to_string(), + forward_host: None, + forward_port: None, + preserve_host_header: false, + enable_websocket: false, + enabled: true, + meta: None, + default_upstream_id: None, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let loc_id = uuid::Uuid::new_v4(); + let loc_model = location::Model { + id: loc_id, + host_id: ph_id, + path: "/".to_string(), + match_type: "prefix".to_string(), + order: 1, + upstream_id: None, + proxy_pass_protocol: None, + proxy_pass_host: None, + proxy_pass_port: None, + preserve_host_header: None, + allowed_methods: None, + custom_config: None, + enabled: true, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let up_id = uuid::Uuid::new_v4(); + let up_model = upstream::Model { + id: up_id, + 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 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![ph_model.clone()]]) + .append_query_results(vec![vec![loc_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 = CreateProxyRequestBody { + name: Some("myproxy".to_string()), + domain: "example.com".to_string(), + scheme: "http".to_string(), + listen_port: 80, + forward_scheme: "http".to_string(), + forward_host: None, + forward_port: None, + preserve_host_header: false, + enable_websocket: false, + enabled: true, + meta: None, + default_upstream_id: None, + locations: vec![ReqLocation { + path: "/".to_string(), + match_type: "prefix".to_string(), + order: 1, + upstream_id: None, + }], + }; + + let res = server.post("/proxy_hosts").json(&payload).await; + res.assert_status_ok(); + let body: crate::routes::api::restricted::nginx::proxy_host::info::response::ProxyHostInfoResponse = + res.json(); + assert_eq!(body.id, ph_id); + assert_eq!(body.domain, "example.com"); + assert_eq!(body.locations.len(), 1); + assert_eq!(body.locations[0].id, loc_id); + } + + #[tokio::test] + async fn handler_create_proxy_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("/proxy_hosts") + .json(&serde_json::json!({})) + .await; + res.assert_status(StatusCode::UNPROCESSABLE_ENTITY); + } + + #[tokio::test] + async fn handler_create_proxy_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 = CreateProxyRequestBody { + name: Some("myproxy".to_string()), + domain: "example.com".to_string(), + scheme: "http".to_string(), + listen_port: 80, + forward_scheme: "http".to_string(), + forward_host: None, + forward_port: None, + preserve_host_header: false, + enable_websocket: false, + enabled: true, + meta: None, + default_upstream_id: None, + locations: vec![], + }; + + let res = server + .post("/proxy_hosts") + .add_header(REQUEST_AUTH_USER_INVALID_HEADER, "true") + .json(&payload) + .await; + + res.assert_status(StatusCode::UNAUTHORIZED); + } +} + +#[derive(serde::Deserialize, utoipa::ToSchema, serde::Serialize)] +pub struct CreateProxyRequestBody { + pub name: Option, + pub domain: String, + pub scheme: String, + pub listen_port: i64, + pub forward_scheme: String, + pub forward_host: Option, + pub forward_port: Option, + pub preserve_host_header: bool, + pub enable_websocket: bool, + pub enabled: bool, + pub meta: Option, + pub default_upstream_id: Option, + pub locations: Vec, +} + +impl From for ProxyHostCreateInfo { + fn from(val: CreateProxyRequestBody) -> Self { + Self { + name: val.name, + domain: val.domain, + scheme: val.scheme, + listen_port: val.listen_port, + forward_scheme: val.forward_scheme, + forward_host: val.forward_host, + forward_port: val.forward_port, + preserve_host_header: val.preserve_host_header, + enable_websocket: val.enable_websocket, + enabled: val.enabled, + meta: val.meta, + default_upstream_id: val.default_upstream_id, + created_by: None, + locations: val + .locations + .into_iter() + .map( + |l| crate::services::nginx::info::location::CreateLocationInfo { + host_id: uuid::Uuid::nil(), + path: l.path, + match_type: l.match_type, + order: l.order, + upstream_id: l.upstream_id, + proxy_pass_protocol: None, + proxy_pass_host: None, + proxy_pass_port: None, + preserve_host_header: None, + allowed_methods: None, + custom_config: None, + enabled: true, + }, + ) + .collect(), + } + } +} + +#[axum::debug_handler] +#[utoipa::path( + post, + path = "/api/nginx/proxy_hosts", + request_body = CreateProxyRequestBody, + responses( + (status = 200, description = "Proxy created successfully", body = ProxyHostInfoResponse), + (status = 401, description = "Unauthorized"), + (status = 422, description = "Invalid request"), + (status = 500, description = "Internal server error"), + ), + tag = NGINX_TAG, +)] +pub async fn create_proxy( + request_info: AuthenticatedRequestInfo, + State(state): State>, + Json(payload): Json, +) -> AxumResult, ApiError> { + let proxy_service = &state.service.nginx.get_proxy_service(); + let mut create_info: ProxyHostCreateInfo = payload.into(); + create_info.created_by = Some(request_info.user_id); + + let mut tx = state.database_connection.begin().await?; + let info = proxy_service + .create_proxy(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(info.into())) +} diff --git a/apps/api/src/routes/api/restricted/nginx/proxy_host/get_location.rs b/apps/api/src/routes/api/restricted/nginx/proxy_host/get_location.rs new file mode 100644 index 0000000..392a333 --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/proxy_host/get_location.rs @@ -0,0 +1,150 @@ +use std::sync::Arc; + +use crate::services::nginx::location::GetLocationOptions; +use axum::{ + Json, + extract::{Path, Query, State}, + response::Result as AxumResult, +}; +use serde::{Deserialize, Serialize}; + +use crate::{ + errors::api_error::ApiError, + routes::{ + AppState, + api::{ + openapi::tag::NGINX_TAG, + restricted::nginx::proxy_host::info::response::LocationInfoResponse, + }, + }, +}; + +#[derive(Serialize, Deserialize, utoipa::ToSchema)] +pub struct GetLocationParams { + pub include_upstream: Option, +} + +pub struct ConcreteGetLocationParams { + pub include_upstream: bool, +} + +impl From for ConcreteGetLocationParams { + fn from(params: GetLocationParams) -> Self { + Self { + include_upstream: params.include_upstream.unwrap_or(false), + } + } +} + +#[utoipa::path( + get, + path = "/api/nginx/locations/{location_id}", + responses( + (status = 200, description = "Get location info", body = LocationInfoResponse), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + tag = NGINX_TAG, +)] +#[axum::debug_handler] +pub async fn get_location( + Path(location_id): Path, + Query(params): Query, + State(state): State>, +) -> AxumResult, ApiError> { + let concrete_params: ConcreteGetLocationParams = params.into(); + let svc = &state.service.nginx.get_location_service(); + let info = if concrete_params.include_upstream { + svc.get_location( + location_id, + Some(GetLocationOptions { + include_upstream: true, + filter_by_enabled: false, + }), + None, + ) + .await? + } else { + svc.get_location(location_id, None, None).await? + }; + Ok(Json(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::{location, proxy_host}; + + use crate::{ + configs::{FromConfig, ProgramSettings}, + routes::api::restricted::nginx::proxy_host::get_proxy_router, + services::get_app_service, + }; + + fn get_router_with_state(db: DatabaseConnection) -> axum::Router { + let program_settings = ProgramSettings::mock(); + let app_service = get_app_service(&Arc::new(db.clone()), &program_settings); + let state = Arc::new(crate::routes::AppState { + database_connection: Arc::new(db), + service: Arc::new(app_service), + config: Arc::new(program_settings), + }); + get_proxy_router(state) + } + + #[tokio::test] + async fn handler_get_location_returns_info() { + let loc_id = uuid::Uuid::new_v4(); + let loc_model = location::Model { + id: loc_id, + host_id: uuid::Uuid::new_v4(), + path: "/".to_string(), + match_type: "prefix".to_string(), + order: 1, + upstream_id: None, + proxy_pass_protocol: None, + proxy_pass_host: None, + proxy_pass_port: None, + preserve_host_header: None, + allowed_methods: None, + custom_config: None, + enabled: true, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![vec![( + loc_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 res = server.get(&format!("/locations/{}", loc_id)).await; + res.assert_status_ok(); + let body = res.json::(); + assert_eq!(body.id, loc_id); + } + + #[tokio::test] + async fn handler_get_location_not_found_returns_not_found() { + 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 res = server + .get(&format!("/locations/{}", uuid::Uuid::new_v4())) + .await; + res.assert_status(StatusCode::NOT_FOUND); + } +} diff --git a/apps/api/src/routes/api/restricted/nginx/proxy_host/get_proxy.rs b/apps/api/src/routes/api/restricted/nginx/proxy_host/get_proxy.rs new file mode 100644 index 0000000..f57fa06 --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/proxy_host/get_proxy.rs @@ -0,0 +1,281 @@ +use std::sync::Arc; + +use axum::{ + Json, + extract::{Path, Query, State}, + response::Result as AxumResult, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::services::nginx::proxy_host::{ProxyHostGetOptions, ProxyHostListOptions}; +use crate::{ + errors::{api_error::ApiError, service_error::ServiceError}, + routes::{ + AppState, + api::restricted::nginx::proxy_host::info::response::{ + ProxyHostInfoResponse, ProxyListResponse, + }, + api::{ + helper::pagination::{ExtractPagination, PaginationInfo}, + openapi::tag::NGINX_TAG, + }, + }, +}; + +#[utoipa::path( + get, + path = "/api/nginx/proxy_hosts", + responses( + (status = 200, description = "List proxies", body = ProxyListResponse), + (status = 500, description = "Internal server error"), + ), + tag = NGINX_TAG, +)] +#[axum::debug_handler] +pub async fn get_proxy_list( + ExtractPagination(pagination): ExtractPagination, + State(state): State>, +) -> AxumResult, ServiceError> { + let svc = &state.service.nginx.get_proxy_service(); + + let (proxies_res, proxies_count_res) = tokio::join!( + svc.get_proxies( + Some(pagination.clone().into()), + Some(ProxyHostListOptions { + include_upstream: true, + filter_by_enabled: false, + }), + None, + ), + svc.get_total_proxies(None, None), + ); + + let proxies = proxies_res?; + let proxies_count = proxies_count_res?; + + let items: Vec = proxies.into_iter().map(|i| i.into()).collect(); + + Ok(Json(ProxyListResponse { + items, + pagination: PaginationInfo { + total_items: proxies_count, + total_pages: if proxies_count == 0 { + 0 + } else { + (proxies_count as f32 / pagination.per_page as f32).ceil() as u32 + }, + current_page: pagination.page, + per_page: pagination.per_page, + }, + })) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use axum::http::StatusCode; + use axum_test::TestServer; + use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, Value}; + + use database::generated::entities::{location, proxy_host}; + + use crate::{ + configs::{FromConfig, ProgramSettings}, + routes::api::restricted::nginx::proxy_host::get_proxy_router, + services::get_app_service, + }; + + fn get_router_with_state(db: DatabaseConnection) -> axum::Router { + let program_settings = ProgramSettings::mock(); + let app_service = get_app_service(&Arc::new(db.clone()), &program_settings); + let state = Arc::new(crate::routes::AppState { + database_connection: Arc::new(db), + service: Arc::new(app_service), + config: Arc::new(program_settings), + }); + get_proxy_router(state) + } + + #[tokio::test] + async fn handler_get_proxy_list_returns_list() { + let p1 = proxy_host::Model { + id: uuid::Uuid::new_v4(), + name: Some("p1".to_string()), + domain: "a.com".to_string(), + scheme: "http".to_string(), + listen_port: 80, + forward_scheme: "http".to_string(), + forward_host: None, + forward_port: None, + preserve_host_header: false, + enable_websocket: false, + enabled: true, + meta: None, + default_upstream_id: None, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + let p2 = proxy_host::Model { + id: uuid::Uuid::new_v4(), + name: Some("p2".to_string()), + domain: "b.com".to_string(), + scheme: "http".to_string(), + listen_port: 80, + forward_scheme: "http".to_string(), + forward_host: None, + forward_port: None, + preserve_host_header: false, + enable_websocket: false, + enabled: true, + meta: None, + default_upstream_id: None, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![vec![ + (p1.clone(), None::), + (p2.clone(), None::), + ]]) + .append_query_results(vec![vec![std::collections::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("/proxy_hosts").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_proxy_with_locations_returns_locations() { + let ph_id = uuid::Uuid::new_v4(); + let ph_model = proxy_host::Model { + id: ph_id, + name: Some("with_locations".to_string()), + domain: "with.com".to_string(), + scheme: "http".to_string(), + listen_port: 80, + forward_scheme: "http".to_string(), + forward_host: None, + forward_port: None, + preserve_host_header: false, + enable_websocket: false, + enabled: true, + meta: None, + default_upstream_id: None, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let loc_model = location::Model { + id: uuid::Uuid::new_v4(), + host_id: ph_id, + path: "/path".to_string(), + match_type: "prefix".to_string(), + order: 1, + upstream_id: None, + proxy_pass_protocol: None, + proxy_pass_host: None, + proxy_pass_port: None, + preserve_host_header: None, + allowed_methods: None, + custom_config: None, + enabled: true, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![vec![(ph_model.clone(), Some(loc_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!("/proxy_hosts/{}", ph_id); + let res = server.get(&url).await; + res.assert_status_ok(); + let body = res.json::(); + assert_eq!(body.id, ph_id); + assert_eq!(body.locations.len(), 1); + assert_eq!(body.locations[0].path, "/path"); + } + + #[tokio::test] + async fn handler_get_proxy_not_found_returns_not_found() { + 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 res = server + .get(&format!("/proxy_hosts/{}", uuid::Uuid::new_v4())) + .await; + res.assert_status(StatusCode::NOT_FOUND); + } +} + +#[derive(Serialize, Deserialize, utoipa::ToSchema)] +pub struct GetProxyParams { + pub include_upstream: Option, +} + +pub struct ConcreteGetProxyParams { + pub include_upstream: bool, +} + +impl From for ConcreteGetProxyParams { + fn from(params: GetProxyParams) -> Self { + Self { + include_upstream: params.include_upstream.unwrap_or(false), + } + } +} + +#[utoipa::path( + get, + path = "/api/nginx/proxy_hosts/{proxy_id}", + responses( + (status = 200, description = "Get proxy info", body = ProxyHostInfoResponse), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + tag = NGINX_TAG, +)] +pub async fn get_proxy( + Path(proxy_id): Path, + Query(params): Query, + State(state): State>, +) -> AxumResult, ApiError> { + let concrete_params: ConcreteGetProxyParams = params.into(); + let svc = &state.service.nginx.get_proxy_service(); + let info = if concrete_params.include_upstream { + svc.get_proxy( + proxy_id, + Some(ProxyHostGetOptions { + include_upstream: true, + filter_by_enabled: false, + }), + None, + ) + .await? + } else { + svc.get_proxy(proxy_id, None, None).await? + }; + Ok(Json(info.into())) +} diff --git a/apps/api/src/routes/api/restricted/nginx/proxy_host/info/mod.rs b/apps/api/src/routes/api/restricted/nginx/proxy_host/info/mod.rs new file mode 100644 index 0000000..4c6f2cd --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/proxy_host/info/mod.rs @@ -0,0 +1 @@ +pub mod response; diff --git a/apps/api/src/routes/api/restricted/nginx/proxy_host/info/response.rs b/apps/api/src/routes/api/restricted/nginx/proxy_host/info/response.rs new file mode 100644 index 0000000..85622de --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/proxy_host/info/response.rs @@ -0,0 +1,91 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::routes::api::helper::pagination::PaginationInfo; + +#[derive(Serialize, Deserialize, utoipa::ToSchema)] +pub struct LocationInfoResponse { + pub id: uuid::Uuid, + pub host_id: uuid::Uuid, + pub path: String, + pub match_type: String, + pub order: i64, + pub upstream_id: Option, + pub enabled: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl From for LocationInfoResponse { + fn from(info: crate::services::nginx::info::location::LocationInfo) -> Self { + Self { + id: info.id, + host_id: info.host_id, + path: info.path, + match_type: info.match_type, + order: info.order, + upstream_id: info.upstream_id, + enabled: info.enabled, + created_at: info.created_at, + updated_at: info.updated_at, + } + } +} + +#[derive(Serialize, Deserialize, utoipa::ToSchema)] +pub struct ProxyHostInfoResponse { + pub id: uuid::Uuid, + pub name: Option, + pub domain: String, + pub scheme: String, + pub listen_port: i64, + pub forward_scheme: String, + pub forward_host: Option, + pub forward_port: Option, + pub preserve_host_header: bool, + pub enable_websocket: bool, + pub enabled: bool, + pub created_at: DateTime, + pub updated_at: DateTime, + pub upstream: Option, + pub locations: Vec, +} + +#[derive(Serialize, Deserialize, utoipa::ToSchema)] +pub struct ProxyHostUpstreamBasic { + pub id: uuid::Uuid, + pub name: String, + pub protocol: String, +} + +impl From for ProxyHostInfoResponse { + fn from(info: crate::services::nginx::info::proxy_host::ProxyHostInfo) -> Self { + Self { + id: info.id, + name: info.name, + domain: info.domain, + scheme: info.scheme, + listen_port: info.listen_port, + forward_scheme: info.forward_scheme, + forward_host: info.forward_host, + forward_port: info.forward_port, + preserve_host_header: info.preserve_host_header, + enable_websocket: info.enable_websocket, + enabled: info.enabled, + created_at: info.created_at, + updated_at: info.updated_at, + upstream: info.upstream.map(|u| ProxyHostUpstreamBasic { + id: u.id, + name: u.name, + protocol: u.protocol, + }), + locations: info.locations.into_iter().map(|l| l.into()).collect(), + } + } +} + +#[derive(Serialize, Deserialize, utoipa::ToSchema)] +pub struct ProxyListResponse { + pub items: Vec, + pub pagination: PaginationInfo, +} diff --git a/apps/api/src/routes/api/restricted/nginx/proxy_host/remove_location.rs b/apps/api/src/routes/api/restricted/nginx/proxy_host/remove_location.rs new file mode 100644 index 0000000..8035c8e --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/proxy_host/remove_location.rs @@ -0,0 +1,157 @@ +use std::sync::Arc; + +use axum::{ + Json, + extract::{Path, 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}, +}; + +#[utoipa::path( + delete, + path = "/api/nginx/locations/{location_id}", + responses( + (status = 200, description = "Location removed successfully", body = ()), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + tag = NGINX_TAG, +)] +#[axum::debug_handler] +pub async fn remove_location( + _request_info: AuthenticatedRequestInfo, + Path(location_id): Path, + State(state): State>, +) -> AxumResult, ApiError> { + let svc = &state.service.nginx.get_location_service(); + + let mut tx = state.database_connection.begin().await?; + svc.delete_location(location_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}; + + use database::generated::entities::{location, upstream, upstream_target}; + + use crate::{ + configs::{FromConfig, ProgramSettings}, + routes::api::restricted::nginx::proxy_host::get_proxy_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_proxy_router(state).layer(axum::middleware::from_fn( + crate::middlewares::require_auth::mock::mock_require_auth, + )) + } + + #[tokio::test] + async fn handler_remove_location_succeeds_returns_ok() { + let loc_id = uuid::Uuid::new_v4(); + + let existing = location::Model { + id: loc_id, + host_id: uuid::Uuid::new_v4(), + path: "/".to_string(), + match_type: "prefix".to_string(), + order: 1, + upstream_id: None, + proxy_pass_protocol: None, + proxy_pass_host: None, + proxy_pass_port: None, + preserve_host_header: None, + allowed_methods: None, + custom_config: None, + enabled: true, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let up_id = uuid::Uuid::new_v4(); + let up_model = upstream::Model { + id: up_id, + 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 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![sea_orm::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(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!("/locations/{}", loc_id)).await; + res.assert_status_ok(); + } + + #[tokio::test] + async fn handler_remove_location_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!("/locations/{}", uuid::Uuid::new_v4())) + .await; + res.assert_status(StatusCode::NOT_FOUND); + } +} diff --git a/apps/api/src/routes/api/restricted/nginx/proxy_host/remove_proxy.rs b/apps/api/src/routes/api/restricted/nginx/proxy_host/remove_proxy.rs new file mode 100644 index 0000000..1ab462e --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/proxy_host/remove_proxy.rs @@ -0,0 +1,180 @@ +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}, +}; + +#[axum::debug_handler] +#[utoipa::path( + delete, + path = "/api/nginx/proxy_hosts/{proxy_id}", + responses( + (status = 200, description = "Proxy 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_proxy( + _request_info: AuthenticatedRequestInfo, + Path(proxy_id): Path, + State(state): State>, +) -> AxumResult, ApiError> { + let svc = &state.service.nginx.get_proxy_service(); + + let mut tx = state.database_connection.begin().await?; + svc.delete_proxy(proxy_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::{proxy_host, upstream, upstream_target}; + + use crate::{ + configs::{FromConfig, ProgramSettings}, + middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER, + routes::api::restricted::nginx::proxy_host::get_proxy_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_proxy_router(state).layer(axum::middleware::from_fn( + crate::middlewares::require_auth::mock::mock_require_auth, + )) + } + + #[tokio::test] + async fn handler_remove_proxy_succeeds_returns_ok() { + let ph_id = uuid::Uuid::new_v4(); + + let existing = proxy_host::Model { + id: ph_id, + name: Some("todelete".to_string()), + domain: "d.com".to_string(), + scheme: "http".to_string(), + listen_port: 80, + forward_scheme: "http".to_string(), + forward_host: None, + forward_port: None, + preserve_host_header: false, + enable_websocket: false, + enabled: true, + meta: None, + default_upstream_id: None, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let up_id = uuid::Uuid::new_v4(); + let up_model = upstream::Model { + id: up_id, + 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 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![(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 res = server.delete(&format!("/proxy_hosts/{}", ph_id)).await; + res.assert_status_ok(); + } + + #[tokio::test] + async fn handler_remove_proxy_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!("/proxy_hosts/{}", uuid::Uuid::new_v4())) + .add_header(REQUEST_AUTH_USER_INVALID_HEADER, "true") + .await; + + res.assert_status(StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn handler_remove_proxy_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!("/proxy_hosts/{}", uuid::Uuid::new_v4())) + .await; + res.assert_status(StatusCode::NOT_FOUND); + } +} diff --git a/apps/api/src/routes/api/restricted/nginx/proxy_host/update_location.rs b/apps/api/src/routes/api/restricted/nginx/proxy_host/update_location.rs new file mode 100644 index 0000000..2f67ce5 --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/proxy_host/update_location.rs @@ -0,0 +1,218 @@ +use std::sync::Arc; + +use axum::{ + Json, + extract::{Path, 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::proxy_host::info::response::LocationInfoResponse, + }, + }, + services::nginx::info::location::UpdateLocationInfo, +}; + +#[derive(serde::Deserialize, utoipa::ToSchema, serde::Serialize)] +pub struct UpdateLocationRequestBody { + pub path: Option, + pub match_type: Option, + pub order: Option, + pub upstream_id: Option>, +} + +#[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::{location, upstream, upstream_target}; + + use super::UpdateLocationRequestBody; + use crate::{ + configs::{FromConfig, ProgramSettings}, + routes::api::restricted::nginx::proxy_host::get_proxy_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_proxy_router(state).layer(axum::middleware::from_fn( + crate::middlewares::require_auth::mock::mock_require_auth, + )) + } + + #[tokio::test] + async fn handler_update_location_succeeds_returns_ok() { + let loc_id = uuid::Uuid::new_v4(); + + let current = location::Model { + id: loc_id, + host_id: uuid::Uuid::new_v4(), + path: "/old".to_string(), + match_type: "prefix".to_string(), + order: 1, + upstream_id: None, + proxy_pass_protocol: None, + proxy_pass_host: None, + proxy_pass_port: None, + preserve_host_header: None, + allowed_methods: None, + custom_config: None, + enabled: true, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let updated = location::Model { ..current.clone() }; + + let first: Vec> = vec![vec![current.clone()]]; + let second: Vec> = vec![vec![updated.clone()]]; + let up_id = uuid::Uuid::new_v4(); + let up_model = upstream::Model { + id: up_id, + 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 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(first) + .append_query_results(second) + // 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 = UpdateLocationRequestBody { + path: Some("/new".to_string()), + match_type: None, + order: None, + upstream_id: None, + }; + + let res = server + .patch(&format!("/locations/{}", loc_id)) + .json(&payload) + .await; + res.assert_status_ok(); + let body = res.json::(); + assert_eq!(body.id, loc_id); + } + + #[tokio::test] + async fn handler_update_location_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 = UpdateLocationRequestBody { + path: Some("/new".to_string()), + match_type: None, + order: None, + upstream_id: None, + }; + + let res = server + .patch(&format!("/locations/{}", uuid::Uuid::new_v4())) + .json(&payload) + .await; + res.assert_status(StatusCode::NOT_FOUND); + } +} + +impl From for UpdateLocationInfo { + fn from(val: UpdateLocationRequestBody) -> Self { + Self { + path: val.path, + match_type: val.match_type, + order: val.order, + upstream_id: val.upstream_id, + proxy_pass_protocol: None, + proxy_pass_host: None, + proxy_pass_port: None, + preserve_host_header: None, + allowed_methods: None, + custom_config: None, + enabled: None, + } + } +} + +#[axum::debug_handler] +#[utoipa::path( + patch, + path = "/api/nginx/locations/{location_id}", + request_body = UpdateLocationRequestBody, + responses( + (status = 200, description = "Location updated successfully", body = LocationInfoResponse), + (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_location( + _request_info: AuthenticatedRequestInfo, + Path(location_id): Path, + State(state): State>, + Json(payload): Json, +) -> AxumResult, ApiError> { + let svc = &state.service.nginx.get_location_service(); + let update: UpdateLocationInfo = payload.into(); + + let mut tx = state.database_connection.begin().await?; + let info = svc + .update_location(location_id, update, 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(info.into())) +} diff --git a/apps/api/src/routes/api/restricted/nginx/proxy_host/update_proxy.rs b/apps/api/src/routes/api/restricted/nginx/proxy_host/update_proxy.rs new file mode 100644 index 0000000..64fbfe5 --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/proxy_host/update_proxy.rs @@ -0,0 +1,225 @@ +use std::sync::Arc; + +use axum::{ + Json, + extract::{Path, State}, + response::Result as AxumResult, +}; +use sea_orm::TransactionTrait; + +use crate::{ + errors::api_error::ApiError, + middlewares::request_info::AuthenticatedRequestInfo, + routes::{AppState, api::restricted::nginx::proxy_host::info::response::ProxyHostInfoResponse}, + services::nginx::info::proxy_host::UpdateProxyHostInfo, +}; + +#[derive(serde::Deserialize, utoipa::ToSchema, serde::Serialize)] +pub struct UpdateProxyRequestBody { + pub name: Option>, + pub domain: Option, +} + +#[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::{proxy_host, upstream, upstream_target}; + + use super::UpdateProxyRequestBody; + use crate::{ + configs::{FromConfig, ProgramSettings}, + middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER, + routes::api::restricted::nginx::proxy_host::get_proxy_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_proxy_router(state).layer(axum::middleware::from_fn( + crate::middlewares::require_auth::mock::mock_require_auth, + )) + } + + #[tokio::test] + async fn handler_update_proxy_succeeds_returns_ok() { + let ph_id = uuid::Uuid::new_v4(); + + let current = proxy_host::Model { + id: ph_id, + name: Some("oldname".to_string()), + domain: "a.com".to_string(), + scheme: "http".to_string(), + listen_port: 80, + forward_scheme: "http".to_string(), + forward_host: None, + forward_port: None, + preserve_host_header: false, + enable_websocket: false, + enabled: true, + meta: None, + default_upstream_id: None, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let updated = proxy_host::Model { ..current.clone() }; + + let first: Vec> = vec![vec![current.clone()]]; + let second: Vec> = vec![vec![updated.clone()]]; + let up_id = uuid::Uuid::new_v4(); + let up_model = upstream::Model { + id: up_id, + 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 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(first) + .append_query_results(second) + // 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 = UpdateProxyRequestBody { + name: Some(Some("newname".to_string())), + domain: Some("a.com".to_string()), + }; + + let res = server + .patch(&format!("/proxy_hosts/{}", ph_id)) + .json(&payload) + .await; + res.assert_status_ok(); + let body: crate::routes::api::restricted::nginx::proxy_host::info::response::ProxyHostInfoResponse = res.json(); + assert_eq!(body.id, ph_id); + } + + #[tokio::test] + async fn handler_update_proxy_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 = UpdateProxyRequestBody { + name: Some(Some("newname".to_string())), + domain: None, + }; + + let res = server + .patch(&format!("/proxy_hosts/{}", 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_proxy_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 = UpdateProxyRequestBody { + name: Some(Some("newname".to_string())), + domain: None, + }; + + let res = server + .patch(&format!("/proxy_hosts/{}", uuid::Uuid::new_v4())) + .json(&payload) + .await; + res.assert_status(StatusCode::NOT_FOUND); + } +} + +impl From for UpdateProxyHostInfo { + fn from(val: UpdateProxyRequestBody) -> Self { + Self { + name: val.name, + domain: val.domain, + scheme: None, + listen_port: None, + forward_scheme: None, + forward_host: None, + forward_port: None, + preserve_host_header: None, + enable_websocket: None, + enabled: None, + meta: None, + default_upstream_id: None, + } + } +} + +#[axum::debug_handler] +#[utoipa::path( + patch, + path = "/api/nginx/proxy_hosts/{proxy_id}", + request_body = UpdateProxyRequestBody, + responses( + (status = 200, description = "Proxy updated successfully", body = ProxyHostInfoResponse), + (status = 401, description = "Unauthorized"), + (status = 422, description = "Invalid request"), + (status = 500, description = "Internal server error"), + ), +)] +pub async fn update_proxy( + _request_info: AuthenticatedRequestInfo, + Path(proxy_id): Path, + State(state): State>, + Json(payload): Json, +) -> AxumResult, ApiError> { + let svc = &state.service.nginx.get_proxy_service(); + let update: UpdateProxyHostInfo = payload.into(); + + let mut tx = state.database_connection.begin().await?; + let info = svc.update_proxy(proxy_id, update, 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(info.into())) +} diff --git a/apps/api/swagger.json b/apps/api/swagger.json index ae006db..fc81dd5 100644 --- a/apps/api/swagger.json +++ b/apps/api/swagger.json @@ -106,6 +106,365 @@ } } }, + "/api/nginx/locations/{location_id}": { + "get": { + "tags": [ + "Nginx" + ], + "operationId": "get_location", + "parameters": [ + { + "name": "location_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Get location info", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LocationInfoResponse" + } + } + } + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + } + }, + "delete": { + "tags": [ + "Nginx" + ], + "operationId": "remove_location", + "parameters": [ + { + "name": "location_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Location 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_location", + "parameters": [ + { + "name": "location_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateLocationRequestBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Location updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LocationInfoResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Invalid request" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/api/nginx/proxy_hosts": { + "get": { + "tags": [ + "Nginx" + ], + "operationId": "get_proxy_list", + "responses": { + "200": { + "description": "List proxies", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProxyListResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + }, + "post": { + "tags": [ + "Nginx" + ], + "operationId": "create_proxy", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateProxyRequestBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Proxy created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProxyHostInfoResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "422": { + "description": "Invalid request" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/api/nginx/proxy_hosts/{proxy_id}": { + "get": { + "tags": [ + "Nginx" + ], + "operationId": "get_proxy", + "parameters": [ + { + "name": "proxy_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Get proxy info", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProxyHostInfoResponse" + } + } + } + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + } + }, + "delete": { + "tags": [ + "Nginx" + ], + "operationId": "remove_proxy", + "parameters": [ + { + "name": "proxy_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Proxy removed successfully", + "content": { + "application/json": { + "schema": { + "default": null + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + } + }, + "patch": { + "tags": [ + "crate::routes::api::restricted::nginx::proxy_host::update_proxy" + ], + "operationId": "update_proxy", + "parameters": [ + { + "name": "proxy_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateProxyRequestBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Proxy updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProxyHostInfoResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "422": { + "description": "Invalid request" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/api/nginx/proxy_hosts/{proxy_id}/locations": { + "post": { + "tags": [ + "Nginx" + ], + "operationId": "create_location", + "parameters": [ + { + "name": "proxy_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateLocationRequestBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Location created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LocationInfoResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "422": { + "description": "Invalid request" + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/api/nginx/upstream_targets/{upstream_target_id}": { "get": { "tags": [ @@ -508,6 +867,130 @@ } } }, + "CreateLocationReq": { + "type": "object", + "required": [ + "path", + "match_type", + "order" + ], + "properties": { + "match_type": { + "type": "string" + }, + "order": { + "type": "integer", + "format": "int64" + }, + "path": { + "type": "string" + }, + "upstream_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } + } + }, + "CreateLocationRequestBody": { + "type": "object", + "required": [ + "path", + "match_type", + "order" + ], + "properties": { + "match_type": { + "type": "string" + }, + "order": { + "type": "integer", + "format": "int64" + }, + "path": { + "type": "string" + }, + "upstream_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } + } + }, + "CreateProxyRequestBody": { + "type": "object", + "required": [ + "domain", + "scheme", + "listen_port", + "forward_scheme", + "preserve_host_header", + "enable_websocket", + "enabled", + "locations" + ], + "properties": { + "default_upstream_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "domain": { + "type": "string" + }, + "enable_websocket": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "forward_host": { + "type": [ + "string", + "null" + ] + }, + "forward_port": { + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "forward_scheme": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "format": "int64" + }, + "locations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CreateLocationReq" + } + }, + "meta": {}, + "name": { + "type": [ + "string", + "null" + ] + }, + "preserve_host_header": { + "type": "boolean" + }, + "scheme": { + "type": "string" + } + } + }, "CreateUpstreamRequestBody": { "type": "object", "required": [ @@ -643,6 +1126,57 @@ } } }, + "LocationInfoResponse": { + "type": "object", + "required": [ + "id", + "host_id", + "path", + "match_type", + "order", + "enabled", + "created_at", + "updated_at" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "enabled": { + "type": "boolean" + }, + "host_id": { + "type": "string", + "format": "uuid" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "match_type": { + "type": "string" + }, + "order": { + "type": "integer", + "format": "int64" + }, + "path": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "upstream_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } + } + }, "LoginRequest": { "type": "object", "description": "Login request payload", @@ -695,6 +1229,179 @@ } } }, + "ProxyHostInfoResponse": { + "type": "object", + "required": [ + "id", + "domain", + "scheme", + "listen_port", + "forward_scheme", + "preserve_host_header", + "enable_websocket", + "enabled", + "created_at", + "updated_at", + "locations" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "domain": { + "type": "string" + }, + "enable_websocket": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "forward_host": { + "type": [ + "string", + "null" + ] + }, + "forward_port": { + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "forward_scheme": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "listen_port": { + "type": "integer", + "format": "int64" + }, + "locations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LocationInfoResponse" + } + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "preserve_host_header": { + "type": "boolean" + }, + "scheme": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "upstream": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ProxyHostUpstreamBasic" + } + ] + } + } + }, + "ProxyHostUpstreamBasic": { + "type": "object", + "required": [ + "id", + "name", + "protocol" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "protocol": { + "type": "string" + } + } + }, + "ProxyListResponse": { + "type": "object", + "required": [ + "items", + "pagination" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProxyHostInfoResponse" + } + }, + "pagination": { + "$ref": "#/components/schemas/PaginationInfo" + } + } + }, + "UpdateLocationRequestBody": { + "type": "object", + "properties": { + "match_type": { + "type": [ + "string", + "null" + ] + }, + "order": { + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "upstream_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } + } + }, + "UpdateProxyRequestBody": { + "type": "object", + "properties": { + "domain": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": [ + "string", + "null" + ] + } + } + }, "UpdateUpstreamInfoResponse": { "type": "object", "required": [ From 7ae76f622c31dec5beeebaa594f02cb7d6c5e224 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Wed, 7 Jan 2026 16:37:51 +0800 Subject: [PATCH 6/8] feat: added proxy_host builder, and improve transaction borrowing --- .../nginx/proxy_host/create_location.rs | 6 +- .../nginx/proxy_host/create_proxy.rs | 8 +- .../restricted/nginx/proxy_host/get_proxy.rs | 8 +- .../nginx/proxy_host/remove_location.rs | 6 +- .../nginx/proxy_host/remove_proxy.rs | 8 +- .../nginx/proxy_host/update_location.rs | 6 +- .../nginx/proxy_host/update_proxy.rs | 8 +- .../nginx/upstream/create_upstream.rs | 6 +- .../nginx/upstream/create_upstream_target.rs | 8 +- .../nginx/upstream/remove_upstream.rs | 6 +- .../nginx/upstream/remove_upstream_target.rs | 6 +- .../nginx/upstream/update_upstream.rs | 6 +- .../nginx/upstream/update_upstream_target.rs | 5 +- apps/api/src/services/nginx.rs | 5 +- apps/api/src/services/nginx/builder.rs | 20 ++++- apps/api/src/services/nginx/proxy_host.rs | 86 ++++++++++++------- apps/api/src/services/nginx/upstream.rs | 4 +- 17 files changed, 136 insertions(+), 66 deletions(-) diff --git a/apps/api/src/routes/api/restricted/nginx/proxy_host/create_location.rs b/apps/api/src/routes/api/restricted/nginx/proxy_host/create_location.rs index f184c27..d804553 100644 --- a/apps/api/src/routes/api/restricted/nginx/proxy_host/create_location.rs +++ b/apps/api/src/routes/api/restricted/nginx/proxy_host/create_location.rs @@ -30,7 +30,7 @@ mod tests { use axum::http::StatusCode; use axum_test::TestServer; - use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase}; + use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, MockRow}; use database::generated::entities::{location, upstream, upstream_target}; @@ -105,11 +105,13 @@ mod tests { created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), }; + let dummy_row: Vec = vec![]; let db = MockDatabase::new(DatabaseBackend::Sqlite) .append_query_results(vec![vec![loc_model.clone()]]) // additional query result for regenerate_and_apply_config -> generate_config .append_query_results(vec![vec![(up_model.clone(), Some(target_model.clone()))]]) + .append_query_results(vec![dummy_row]) .into_connection(); let router = get_router_with_state(db.clone()); @@ -213,7 +215,7 @@ pub async fn create_location( state .service .nginx - .regenerate_and_apply_config(state.service.agent_client.clone(), Some(&mut tx)) + .regenerate_and_apply_config(state.service.agent_client.clone(), &Some(&mut tx)) .await?; tx.commit().await?; diff --git a/apps/api/src/routes/api/restricted/nginx/proxy_host/create_proxy.rs b/apps/api/src/routes/api/restricted/nginx/proxy_host/create_proxy.rs index da1ad9e..abb1f70 100644 --- a/apps/api/src/routes/api/restricted/nginx/proxy_host/create_proxy.rs +++ b/apps/api/src/routes/api/restricted/nginx/proxy_host/create_proxy.rs @@ -30,7 +30,7 @@ mod tests { use axum::http::StatusCode; use axum_test::TestServer; - use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase}; + use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, MockRow}; use database::generated::entities::{location, proxy_host, upstream, upstream_target}; @@ -125,12 +125,14 @@ mod tests { created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), }; + let dummy_row: Vec = vec![]; let db = MockDatabase::new(DatabaseBackend::Sqlite) .append_query_results(vec![vec![ph_model.clone()]]) .append_query_results(vec![vec![loc_model.clone()]]) // additional query result for regenerate_and_apply_config -> generate_config .append_query_results(vec![vec![(up_model.clone(), Some(target_model.clone()))]]) + .append_query_results(vec![dummy_row]) .into_connection(); let router = get_router_with_state(db.clone()); @@ -293,13 +295,13 @@ pub async fn create_proxy( let mut tx = state.database_connection.begin().await?; let info = proxy_service - .create_proxy(create_info, Some(&mut tx)) + .create_proxy(create_info, &Some(&mut tx)) .await?; state .service .nginx - .regenerate_and_apply_config(state.service.agent_client.clone(), Some(&mut tx)) + .regenerate_and_apply_config(state.service.agent_client.clone(), &Some(&mut tx)) .await?; tx.commit().await?; diff --git a/apps/api/src/routes/api/restricted/nginx/proxy_host/get_proxy.rs b/apps/api/src/routes/api/restricted/nginx/proxy_host/get_proxy.rs index f57fa06..63eb584 100644 --- a/apps/api/src/routes/api/restricted/nginx/proxy_host/get_proxy.rs +++ b/apps/api/src/routes/api/restricted/nginx/proxy_host/get_proxy.rs @@ -46,9 +46,9 @@ pub async fn get_proxy_list( include_upstream: true, filter_by_enabled: false, }), - None, + &None, ), - svc.get_total_proxies(None, None), + svc.get_total_proxies(None, &None), ); let proxies = proxies_res?; @@ -271,11 +271,11 @@ pub async fn get_proxy( include_upstream: true, filter_by_enabled: false, }), - None, + &None, ) .await? } else { - svc.get_proxy(proxy_id, None, None).await? + svc.get_proxy(proxy_id, None, &None).await? }; Ok(Json(info.into())) } diff --git a/apps/api/src/routes/api/restricted/nginx/proxy_host/remove_location.rs b/apps/api/src/routes/api/restricted/nginx/proxy_host/remove_location.rs index 8035c8e..1dc8c3a 100644 --- a/apps/api/src/routes/api/restricted/nginx/proxy_host/remove_location.rs +++ b/apps/api/src/routes/api/restricted/nginx/proxy_host/remove_location.rs @@ -37,7 +37,7 @@ pub async fn remove_location( state .service .nginx - .regenerate_and_apply_config(state.service.agent_client.clone(), Some(&mut tx)) + .regenerate_and_apply_config(state.service.agent_client.clone(), &Some(&mut tx)) .await?; tx.commit().await?; @@ -50,7 +50,7 @@ mod tests { use axum::http::StatusCode; use axum_test::TestServer; - use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase}; + use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, MockRow}; use database::generated::entities::{location, upstream, upstream_target}; @@ -122,6 +122,7 @@ mod tests { created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), }; + let dummy_row: Vec = vec![]; let db = MockDatabase::new(DatabaseBackend::Sqlite) .append_query_results(vec![vec![existing.clone()]]) @@ -131,6 +132,7 @@ mod tests { }]) // additional query result for regenerate_and_apply_config -> generate_config .append_query_results(vec![vec![(up_model.clone(), Some(target_model.clone()))]]) + .append_query_results(vec![dummy_row]) .into_connection(); let router = get_router_with_state(db.clone()); diff --git a/apps/api/src/routes/api/restricted/nginx/proxy_host/remove_proxy.rs b/apps/api/src/routes/api/restricted/nginx/proxy_host/remove_proxy.rs index 1ab462e..b53a740 100644 --- a/apps/api/src/routes/api/restricted/nginx/proxy_host/remove_proxy.rs +++ b/apps/api/src/routes/api/restricted/nginx/proxy_host/remove_proxy.rs @@ -34,11 +34,11 @@ pub async fn remove_proxy( let svc = &state.service.nginx.get_proxy_service(); let mut tx = state.database_connection.begin().await?; - svc.delete_proxy(proxy_id, Some(&mut tx)).await?; + svc.delete_proxy(proxy_id, &Some(&mut tx)).await?; state .service .nginx - .regenerate_and_apply_config(state.service.agent_client.clone(), Some(&mut tx)) + .regenerate_and_apply_config(state.service.agent_client.clone(), &Some(&mut tx)) .await?; tx.commit().await?; @@ -51,7 +51,7 @@ mod tests { use axum::http::StatusCode; use axum_test::TestServer; - use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, MockExecResult}; + use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, MockExecResult, MockRow}; use database::generated::entities::{proxy_host, upstream, upstream_target}; @@ -125,6 +125,7 @@ mod tests { created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), }; + let dummy_row: Vec = vec![]; let db = MockDatabase::new(DatabaseBackend::Sqlite) .append_query_results(vec![vec![existing.clone()]]) @@ -140,6 +141,7 @@ mod tests { ]) // additional query result for regenerate_and_apply_config -> generate_config .append_query_results(vec![vec![(up_model.clone(), Some(target_model.clone()))]]) + .append_query_results(vec![dummy_row]) .into_connection(); let router = get_router_with_state(db.clone()); diff --git a/apps/api/src/routes/api/restricted/nginx/proxy_host/update_location.rs b/apps/api/src/routes/api/restricted/nginx/proxy_host/update_location.rs index 2f67ce5..b1d5144 100644 --- a/apps/api/src/routes/api/restricted/nginx/proxy_host/update_location.rs +++ b/apps/api/src/routes/api/restricted/nginx/proxy_host/update_location.rs @@ -34,7 +34,7 @@ mod tests { use axum::http::StatusCode; use axum_test::TestServer; - use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase}; + use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, MockRow}; use database::generated::entities::{location, upstream, upstream_target}; @@ -111,12 +111,14 @@ mod tests { created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), }; + let dummy_row: Vec = vec![]; 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(), Some(target_model.clone()))]]) + .append_query_results(vec![dummy_row]) .into_connection(); let router = get_router_with_state(db.clone()); @@ -210,7 +212,7 @@ pub async fn update_location( state .service .nginx - .regenerate_and_apply_config(state.service.agent_client.clone(), Some(&mut tx)) + .regenerate_and_apply_config(state.service.agent_client.clone(), &Some(&mut tx)) .await?; tx.commit().await?; diff --git a/apps/api/src/routes/api/restricted/nginx/proxy_host/update_proxy.rs b/apps/api/src/routes/api/restricted/nginx/proxy_host/update_proxy.rs index 64fbfe5..4c22135 100644 --- a/apps/api/src/routes/api/restricted/nginx/proxy_host/update_proxy.rs +++ b/apps/api/src/routes/api/restricted/nginx/proxy_host/update_proxy.rs @@ -26,7 +26,7 @@ mod tests { use axum::http::StatusCode; use axum_test::TestServer; - use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase}; + use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, MockRow}; use database::generated::entities::{proxy_host, upstream, upstream_target}; @@ -105,12 +105,14 @@ mod tests { created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), }; + let dummy_row: Vec = vec![]; 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(), Some(target_model.clone()))]]) + .append_query_results(vec![dummy_row]) .into_connection(); let router = get_router_with_state(db.clone()); @@ -213,11 +215,11 @@ pub async fn update_proxy( let update: UpdateProxyHostInfo = payload.into(); let mut tx = state.database_connection.begin().await?; - let info = svc.update_proxy(proxy_id, update, Some(&mut tx)).await?; + let info = svc.update_proxy(proxy_id, update, &Some(&mut tx)).await?; state .service .nginx - .regenerate_and_apply_config(state.service.agent_client.clone(), Some(&mut tx)) + .regenerate_and_apply_config(state.service.agent_client.clone(), &Some(&mut tx)) .await?; tx.commit().await?; 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 index 67cb4e7..c781f32 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream.rs @@ -131,7 +131,7 @@ pub async fn create_upstream( state .service .nginx - .regenerate_and_apply_config(state.service.agent_client.clone(), Some(&mut tx)) + .regenerate_and_apply_config(state.service.agent_client.clone(), &Some(&mut tx)) .await?; tx.commit().await?; @@ -145,7 +145,7 @@ mod tests { use axum::http::StatusCode; use axum_test::TestServer; - use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase}; + use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, MockRow}; use database::generated::entities::{upstream, upstream_target}; @@ -204,6 +204,7 @@ mod tests { created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), }; + let dummy_row: Vec = vec![]; // service will likely perform an insert and then query to return created models let db = MockDatabase::new(DatabaseBackend::Sqlite) @@ -213,6 +214,7 @@ mod tests { // `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()))]]) + .append_query_results(vec![dummy_row]) .into_connection(); let router = get_router_with_state(db.clone()); 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 index 1584871..93e20c3 100644 --- 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 @@ -86,7 +86,7 @@ pub async fn add_upstream_target( state .service .nginx - .regenerate_and_apply_config(state.service.agent_client.clone(), Some(&mut tx)) + .regenerate_and_apply_config(state.service.agent_client.clone(), &Some(&mut tx)) .await?; tx.commit().await?; @@ -100,7 +100,7 @@ mod tests { use axum::http::StatusCode; use axum_test::TestServer; - use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase}; + use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, MockRow}; use database::generated::entities::{upstream, upstream_target}; @@ -158,6 +158,7 @@ mod tests { created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), }; + let dummy_row: Vec = vec![]; // configure mock agent to return an error on apply let mut mock = MockAgentService::new(); @@ -175,6 +176,7 @@ mod tests { 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()))]]) + .append_query_results(vec![dummy_row]) .into_connection(); let program_settings = ProgramSettings::mock(); @@ -235,11 +237,13 @@ mod tests { created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), }; + let dummy_row: Vec = vec![]; 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()))]]) + .append_query_results(vec![dummy_row]) .into_connection(); let router = get_router_with_state(db.clone()); 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 index 9b8ea83..ec8be97 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream.rs @@ -40,7 +40,7 @@ pub async fn remove_upstream( state .service .nginx - .regenerate_and_apply_config(state.service.agent_client.clone(), Some(&mut tx)) + .regenerate_and_apply_config(state.service.agent_client.clone(), &Some(&mut tx)) .await?; tx.commit().await?; @@ -54,7 +54,7 @@ mod tests { use axum::http::StatusCode; use axum_test::TestServer; - use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, MockExecResult}; + use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, MockExecResult, MockRow}; use database::generated::entities::{upstream, upstream_target}; @@ -109,6 +109,7 @@ mod tests { created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), }; + let dummy_row: Vec = vec![]; let db = MockDatabase::new(DatabaseBackend::Sqlite) .append_query_results(vec![vec![existing.clone()]]) @@ -124,6 +125,7 @@ mod tests { ]) // additional query result for regenerate_and_apply_config -> generate_config .append_query_results(vec![vec![(existing.clone(), Some(target_model.clone()))]]) + .append_query_results(vec![dummy_row]) .into_connection(); let router = get_router_with_state(db.clone()); 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 index e4c858e..749f3d2 100644 --- 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 @@ -40,7 +40,7 @@ pub async fn remove_upstream_target( state .service .nginx - .regenerate_and_apply_config(state.service.agent_client.clone(), Some(&mut tx)) + .regenerate_and_apply_config(state.service.agent_client.clone(), &Some(&mut tx)) .await?; tx.commit().await?; @@ -54,7 +54,7 @@ mod tests { use axum::http::StatusCode; use axum_test::TestServer; - use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, MockExecResult}; + use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, MockExecResult, MockRow}; use database::generated::entities::{upstream, upstream_target}; @@ -110,6 +110,7 @@ mod tests { created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), }; + let dummy_row: Vec = vec![]; let first: Vec> = vec![vec![current_model.clone()]]; let db = MockDatabase::new(DatabaseBackend::Sqlite) @@ -120,6 +121,7 @@ mod tests { }]) // additional query result for regenerate_and_apply_config -> generate_config .append_query_results(vec![vec![(up_model.clone(), Some(current_model.clone()))]]) + .append_query_results(vec![dummy_row]) .into_connection(); let router = get_router_with_state(db.clone()); 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 index 86da499..1cf668c 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream/update_upstream.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream/update_upstream.rs @@ -78,7 +78,7 @@ pub async fn update_upstream( state .service .nginx - .regenerate_and_apply_config(state.service.agent_client.clone(), Some(&mut tx)) + .regenerate_and_apply_config(state.service.agent_client.clone(), &Some(&mut tx)) .await?; tx.commit().await?; @@ -92,7 +92,7 @@ mod tests { use axum::http::StatusCode; use axum_test::TestServer; - use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase}; + use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, MockRow}; use database::generated::entities::{upstream, upstream_target}; @@ -152,6 +152,7 @@ mod tests { let up_model = current_model.clone(); let first: Vec> = vec![vec![current_model.clone()]]; let second: Vec> = vec![vec![updated_model.clone()]]; + let dummy_row: Vec = vec![]; let db = MockDatabase::new(DatabaseBackend::Sqlite) .append_query_results(first) .append_query_results(second) @@ -160,6 +161,7 @@ mod tests { up_model.clone(), Option::::None, )]]) + .append_query_results(vec![dummy_row]) .into_connection(); let router = get_router_with_state(db.clone()); 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 index ef8ac57..666cfb0 100644 --- 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 @@ -70,7 +70,7 @@ pub async fn update_upstream_target( state .service .nginx - .regenerate_and_apply_config(state.service.agent_client.clone(), Some(&mut tx)) + .regenerate_and_apply_config(state.service.agent_client.clone(), &Some(&mut tx)) .await?; tx.commit().await?; @@ -165,11 +165,14 @@ mod tests { // additional query result for regenerate_and_apply_config -> generate_config let third: Vec)>> = vec![vec![(up_model.clone(), Some(updated_model.clone()))]]; + // placeholder for other queries called by regenerate_and_apply_config + let fourth: Vec> = vec![vec![]]; let db = MockDatabase::new(DatabaseBackend::Sqlite) .append_query_results(first) .append_query_results(second) .append_query_results(third) + .append_query_results(fourth) .into_connection(); let router = get_router_with_state(db.clone()); diff --git a/apps/api/src/services/nginx.rs b/apps/api/src/services/nginx.rs index e21b882..a732dc5 100644 --- a/apps/api/src/services/nginx.rs +++ b/apps/api/src/services/nginx.rs @@ -81,12 +81,13 @@ impl NginxService { pub async fn generate_config( &self, - tx: Option<&mut DatabaseTransaction>, + tx: &Option<&mut DatabaseTransaction>, ) -> Result { let mut builder = NginxConfigBuilder::default(); self.upstream_service .generate_config(&mut builder, tx) .await?; + self.proxy_service.generate_config(&mut builder, tx).await?; builder.to_nginx_config(None) } @@ -94,7 +95,7 @@ impl NginxService { pub async fn regenerate_and_apply_config( &self, agent: Arc, - tx: Option<&mut DatabaseTransaction>, + tx: &Option<&mut DatabaseTransaction>, ) -> Result<(), ServiceError> { let config = self.generate_config(tx).await?; diff --git a/apps/api/src/services/nginx/builder.rs b/apps/api/src/services/nginx/builder.rs index 0b435bb..632d63b 100644 --- a/apps/api/src/services/nginx/builder.rs +++ b/apps/api/src/services/nginx/builder.rs @@ -1,4 +1,7 @@ -use crate::{errors::service_error::ServiceError, services::nginx::info::upstream::UpstreamInfo}; +use crate::{ + errors::service_error::ServiceError, + services::nginx::info::{proxy_host::ProxyHostInfo, upstream::UpstreamInfo}, +}; pub const INDENT_SIZE: usize = 2; @@ -9,6 +12,7 @@ pub trait NginxConfigProvider { #[derive(Default)] pub struct NginxConfigBuilder { upstreams: Vec, + proxy_hosts: Vec, } impl NginxConfigBuilder { @@ -21,6 +25,16 @@ impl NginxConfigBuilder { self.add_upstream(upstream); } } + + pub fn add_proxy_host(&mut self, proxy_host: ProxyHostInfo) { + self.proxy_hosts.push(proxy_host); + } + + pub fn add_proxy_hosts(&mut self, proxy_hosts: Vec) { + for proxy_host in proxy_hosts { + self.add_proxy_host(proxy_host); + } + } } impl NginxConfigProvider for NginxConfigBuilder { @@ -35,6 +49,10 @@ impl NginxConfigProvider for NginxConfigBuilder { config.push_str(&upstream.to_nginx_config(indent)?); } + for proxy_host in &self.proxy_hosts { + config.push('\n'); + config.push_str(&proxy_host.to_nginx_config(indent)?); + } // TODO: Add other sections like servers, locations, etc. // trailing newline for file ending config.push('\n'); diff --git a/apps/api/src/services/nginx/proxy_host.rs b/apps/api/src/services/nginx/proxy_host.rs index e77626e..afc3688 100644 --- a/apps/api/src/services/nginx/proxy_host.rs +++ b/apps/api/src/services/nginx/proxy_host.rs @@ -11,7 +11,10 @@ use database::generated::entities::{location, proxy_host}; use crate::{ errors::service_error::ServiceError, helpers::database::PaginationFilter, - services::nginx::info::proxy_host::{ProxyHostCreateInfo, ProxyHostInfo, UpdateProxyHostInfo}, + services::nginx::{ + builder::NginxConfigBuilder, + info::proxy_host::{ProxyHostCreateInfo, ProxyHostInfo, UpdateProxyHostInfo}, + }, with_conn, }; @@ -20,35 +23,40 @@ pub trait ProxyService: Send + Sync { async fn create_proxy( &self, create_info: ProxyHostCreateInfo, - tx: Option<&mut DatabaseTransaction>, + tx: &Option<&mut DatabaseTransaction>, ) -> Result; async fn get_total_proxies( &self, options: Option, - tx: Option<&mut DatabaseTransaction>, + tx: &Option<&mut DatabaseTransaction>, ) -> Result; async fn get_proxies( &self, pagination: Option, options: Option, - tx: Option<&mut DatabaseTransaction>, + tx: &Option<&mut DatabaseTransaction>, ) -> Result, ServiceError>; async fn get_proxy( &self, proxy_id: uuid::Uuid, options: Option, - tx: Option<&mut DatabaseTransaction>, + tx: &Option<&mut DatabaseTransaction>, ) -> Result; async fn update_proxy( &self, proxy_id: uuid::Uuid, update: UpdateProxyHostInfo, - tx: Option<&mut DatabaseTransaction>, + tx: &Option<&mut DatabaseTransaction>, ) -> Result; async fn delete_proxy( &self, proxy_id: uuid::Uuid, - tx: Option<&mut DatabaseTransaction>, + tx: &Option<&mut DatabaseTransaction>, + ) -> Result<(), ServiceError>; + async fn generate_config( + &self, + builder: &mut NginxConfigBuilder, + tx: &Option<&mut DatabaseTransaction>, ) -> Result<(), ServiceError>; } @@ -78,18 +86,18 @@ impl ProxyService for ProxyServiceImpl { async fn create_proxy( &self, create_info: ProxyHostCreateInfo, - tx: Option<&mut DatabaseTransaction>, + tx: &Option<&mut DatabaseTransaction>, ) -> Result { let (proxy_host, location_models): (proxy_host::ActiveModel, Vec) = create_info.into(); - 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 owned_tx = match tx { + Some(_) => None, + None => Some(self.connection.begin().await.map_err(ServiceError::from)?), }; + + let tx_ref = owned_tx.as_ref().or(tx.as_deref()); + let r = with_conn!(&*self.connection, tx_ref, conn, { let inserted_proxy = proxy_host.insert(*conn).await?; let mut inserted_location_models: Vec = @@ -103,16 +111,13 @@ impl ProxyService for ProxyServiceImpl { (inserted_proxy, inserted_location_models) }); - if let Some(t) = maybe_owned_tx.take() { - t.commit().await?; - } Ok(r.into()) } async fn get_total_proxies( &self, _options: Option, - tx: Option<&mut DatabaseTransaction>, + tx: &Option<&mut DatabaseTransaction>, ) -> Result { #[derive(Debug, FromQueryResult)] struct CountResult { @@ -133,7 +138,7 @@ impl ProxyService for ProxyServiceImpl { &self, pagination: Option, options: Option, - tx: Option<&mut DatabaseTransaction>, + tx: &Option<&mut DatabaseTransaction>, ) -> Result, ServiceError> { let r = with_conn!(&*self.connection, tx, conn, { let mut find_query = proxy_host::Entity::find(); @@ -177,7 +182,7 @@ impl ProxyService for ProxyServiceImpl { &self, proxy_id: uuid::Uuid, options: Option, - tx: Option<&mut DatabaseTransaction>, + tx: &Option<&mut DatabaseTransaction>, ) -> Result { let r: ProxyHostInfo = with_conn!(&*self.connection, tx, conn, { let find_query = proxy_host::Entity::find_by_id(proxy_id) @@ -217,7 +222,7 @@ impl ProxyService for ProxyServiceImpl { &self, proxy_id: uuid::Uuid, update: UpdateProxyHostInfo, - tx: Option<&mut DatabaseTransaction>, + tx: &Option<&mut DatabaseTransaction>, ) -> Result { let current_model = with_conn!(&*self.connection, tx, conn, { proxy_host::Entity::find_by_id(proxy_id) @@ -239,7 +244,7 @@ impl ProxyService for ProxyServiceImpl { async fn delete_proxy( &self, proxy_id: uuid::Uuid, - tx: Option<&mut DatabaseTransaction>, + tx: &Option<&mut DatabaseTransaction>, ) -> Result<(), ServiceError> { let model = with_conn!(&*self.connection, tx, conn, { proxy_host::Entity::find_by_id(proxy_id) @@ -255,6 +260,25 @@ impl ProxyService for ProxyServiceImpl { Ok(()) }) } + + async fn generate_config( + &self, + builder: &mut NginxConfigBuilder, + tx: &Option<&mut DatabaseTransaction>, + ) -> Result<(), ServiceError> { + let proxies = self + .get_proxies( + None, + Some(ProxyHostListOptions { + include_upstream: true, + ..Default::default() + }), + tx, + ) + .await?; + builder.add_proxy_hosts(proxies); + Ok(()) + } } #[cfg(test)] @@ -331,7 +355,7 @@ mod tests { locations: Vec::new(), }; - let res = svc.create_proxy(create_info, None).await; + let res = svc.create_proxy(create_info, &None).await; assert!(res.is_ok()); let info = res.expect("Failed to create proxy"); assert_eq!(info.domain, "example.com"); @@ -345,7 +369,7 @@ mod tests { let svc = ProxyServiceImpl::new(Arc::new(db)); let res = svc - .get_total_proxies(None, None) + .get_total_proxies(None, &None) .await .expect("Failed to get total proxies"); assert_eq!(res, 0u64); @@ -398,7 +422,7 @@ mod tests { .into_connection(); let svc = ProxyServiceImpl::new(Arc::new(db)); - let res = svc.get_proxies(None, None, None).await; + let res = svc.get_proxies(None, None, &None).await; assert!(res.is_ok()); let list = res.expect("Failed to get proxies"); assert_eq!(list.len(), 2); @@ -431,7 +455,7 @@ mod tests { .into_connection(); let svc = ProxyServiceImpl::new(Arc::new(db)); - let res = svc.get_proxy(id, None, None).await; + let res = svc.get_proxy(id, None, &None).await; assert!(res.is_ok()); let got = res.expect("Failed to get proxy"); assert_eq!(got.id, id); @@ -444,7 +468,7 @@ mod tests { .into_connection(); let svc = ProxyServiceImpl::new(Arc::new(db)); - let res = svc.get_proxy(uuid::Uuid::new_v4(), None, None).await; + let res = svc.get_proxy(uuid::Uuid::new_v4(), None, &None).await; assert!(matches!(res, Err(ServiceError::NotFound(_)))); } @@ -510,7 +534,7 @@ mod tests { default_upstream_id: None, }; - let res = svc.update_proxy(id, update_info, None).await; + let res = svc.update_proxy(id, update_info, &None).await; assert!(res.is_ok()); let got = res.expect("Failed to update proxy"); assert_eq!(got.name.expect("Name should be present"), "new"); @@ -541,7 +565,7 @@ mod tests { meta: None, default_upstream_id: None, }, - None, + &None, ) .await; @@ -580,7 +604,7 @@ mod tests { let svc = ProxyServiceImpl::new(Arc::new(db)); - let res = svc.delete_proxy(id, None).await; + let res = svc.delete_proxy(id, &None).await; assert!(res.is_ok()); } @@ -592,7 +616,7 @@ mod tests { let svc = ProxyServiceImpl::new(Arc::new(db)); - let res = svc.delete_proxy(uuid::Uuid::new_v4(), None).await; + let res = svc.delete_proxy(uuid::Uuid::new_v4(), &None).await; assert!(matches!(res, Err(ServiceError::NotFound(_)))); } } diff --git a/apps/api/src/services/nginx/upstream.rs b/apps/api/src/services/nginx/upstream.rs index 5ed9a14..8c8dd31 100644 --- a/apps/api/src/services/nginx/upstream.rs +++ b/apps/api/src/services/nginx/upstream.rs @@ -88,7 +88,7 @@ pub trait UpstreamService: Send + Sync { async fn generate_config( &self, builder: &mut NginxConfigBuilder, - tx: Option<&mut DatabaseTransaction>, + tx: &Option<&mut DatabaseTransaction>, ) -> Result<(), ServiceError>; } @@ -457,7 +457,7 @@ impl UpstreamService for UpstreamServiceImpl { async fn generate_config( &self, builder: &mut NginxConfigBuilder, - tx: Option<&mut DatabaseTransaction>, + tx: &Option<&mut DatabaseTransaction>, ) -> Result<(), ServiceError> { // get all upstreams and their targets let upstreams = with_conn!(&*self.connection, tx, conn, { From b4a36dbe4c187b05d92b64bb7ffa54ceda96d822 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Mon, 12 Jan 2026 11:41:55 +0800 Subject: [PATCH 7/8] Fix: create location support for proxy pass --- .../nginx/proxy_host/create_location.rs | 92 ++++++++-- apps/api/swagger.json | 89 ++++++++- .../app/generated/api-client/api-client.ts | 173 ++++++++++++++++++ 3 files changed, 335 insertions(+), 19 deletions(-) diff --git a/apps/api/src/routes/api/restricted/nginx/proxy_host/create_location.rs b/apps/api/src/routes/api/restricted/nginx/proxy_host/create_location.rs index d804553..99a038e 100644 --- a/apps/api/src/routes/api/restricted/nginx/proxy_host/create_location.rs +++ b/apps/api/src/routes/api/restricted/nginx/proxy_host/create_location.rs @@ -17,11 +17,36 @@ use crate::{ }; #[derive(serde::Deserialize, utoipa::ToSchema, serde::Serialize)] -pub struct CreateLocationRequestBody { +#[serde(untagged)] +pub enum CreateLocationRequestBody { + // #[serde(rename = "upstream_id")] + UpstreamId(CreateLocationRequestBodyByUpstreamId), + // #[serde(rename = "proxy_pass")] + ProxyPass(CreateLocationRequestBodyByProxyPass), +} + +#[derive(serde::Deserialize, utoipa::ToSchema, serde::Serialize)] +pub struct CreateLocationRequestBodyByUpstreamId { pub path: String, pub match_type: String, pub order: i64, - pub upstream_id: Option, + pub upstream_id: uuid::Uuid, + pub preserve_host_header: Option, + pub allowed_methods: Option>, + pub custom_config: Option, +} + +#[derive(serde::Deserialize, utoipa::ToSchema, serde::Serialize)] +pub struct CreateLocationRequestBodyByProxyPass { + pub path: String, + pub match_type: String, + pub order: i64, + pub proxy_pass_protocol: String, + pub proxy_pass_host: String, + pub proxy_pass_port: i64, + pub preserve_host_header: Option, + pub allowed_methods: Option>, + pub custom_config: Option, } #[cfg(test)] @@ -37,8 +62,9 @@ mod tests { use crate::{ configs::{FromConfig, ProgramSettings}, middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER, - routes::api::restricted::nginx::proxy_host::create_location::CreateLocationRequestBody, - routes::api::restricted::nginx::proxy_host::get_proxy_router, + routes::api::restricted::nginx::proxy_host::{ + create_location::CreateLocationRequestBodyByUpstreamId, get_proxy_router, + }, services::{agent_client::MockAgentService, get_mock_app_service}, }; @@ -117,11 +143,14 @@ mod tests { let router = get_router_with_state(db.clone()); let server = TestServer::new(router).expect("failed to create test server"); - let payload = CreateLocationRequestBody { + let payload = CreateLocationRequestBodyByUpstreamId { path: "/".to_string(), match_type: "prefix".to_string(), order: 1, - upstream_id: None, + upstream_id: up_id, + preserve_host_header: None, + allowed_methods: None, + custom_config: None, }; let res = server @@ -152,11 +181,14 @@ mod tests { let router = get_router_with_state(db.clone()); let server = TestServer::new(router).expect("failed to create test server"); - let payload = CreateLocationRequestBody { + let payload = CreateLocationRequestBodyByUpstreamId { path: "/".to_string(), match_type: "prefix".to_string(), order: 1, - upstream_id: None, + upstream_id: uuid::Uuid::new_v4(), + preserve_host_header: None, + allowed_methods: None, + custom_config: None, }; let res = server @@ -171,18 +203,46 @@ mod tests { impl From<(uuid::Uuid, CreateLocationRequestBody)> for CreateLocationInfo { fn from(val: (uuid::Uuid, CreateLocationRequestBody)) -> Self { + match val.1 { + CreateLocationRequestBody::UpstreamId(body) => Self::from((val.0, body)), + CreateLocationRequestBody::ProxyPass(body) => Self::from((val.0, body)), + } + } +} + +impl From<(uuid::Uuid, CreateLocationRequestBodyByUpstreamId)> for CreateLocationInfo { + fn from((proxy_id, payload): (uuid::Uuid, CreateLocationRequestBodyByUpstreamId)) -> Self { Self { - host_id: val.0, - path: val.1.path, - match_type: val.1.match_type, - order: val.1.order, - upstream_id: val.1.upstream_id, + host_id: proxy_id, + path: payload.path, + match_type: payload.match_type, + order: payload.order, + upstream_id: Some(payload.upstream_id), proxy_pass_protocol: None, proxy_pass_host: None, proxy_pass_port: None, - preserve_host_header: None, - allowed_methods: None, - custom_config: None, + preserve_host_header: payload.preserve_host_header, + allowed_methods: payload.allowed_methods, + custom_config: payload.custom_config, + enabled: true, + } + } +} + +impl From<(uuid::Uuid, CreateLocationRequestBodyByProxyPass)> for CreateLocationInfo { + fn from((proxy_id, payload): (uuid::Uuid, CreateLocationRequestBodyByProxyPass)) -> Self { + Self { + host_id: proxy_id, + path: payload.path, + match_type: payload.match_type, + order: payload.order, + upstream_id: None, + proxy_pass_protocol: Some(payload.proxy_pass_protocol), + proxy_pass_host: Some(payload.proxy_pass_host), + proxy_pass_port: Some(payload.proxy_pass_port), + preserve_host_header: payload.preserve_host_header, + allowed_methods: payload.allowed_methods, + custom_config: payload.custom_config, enabled: true, } } diff --git a/apps/api/swagger.json b/apps/api/swagger.json index fc81dd5..837e753 100644 --- a/apps/api/swagger.json +++ b/apps/api/swagger.json @@ -895,13 +895,41 @@ } }, "CreateLocationRequestBody": { + "oneOf": [ + { + "$ref": "#/components/schemas/CreateLocationRequestBodyByUpstreamId" + }, + { + "$ref": "#/components/schemas/CreateLocationRequestBodyByProxyPass" + } + ] + }, + "CreateLocationRequestBodyByProxyPass": { "type": "object", "required": [ "path", "match_type", - "order" + "order", + "proxy_pass_protocol", + "proxy_pass_host", + "proxy_pass_port" ], "properties": { + "allowed_methods": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "custom_config": { + "type": [ + "string", + "null" + ] + }, "match_type": { "type": "string" }, @@ -912,11 +940,66 @@ "path": { "type": "string" }, - "upstream_id": { + "preserve_host_header": { + "type": [ + "boolean", + "null" + ] + }, + "proxy_pass_host": { + "type": "string" + }, + "proxy_pass_port": { + "type": "integer", + "format": "int64" + }, + "proxy_pass_protocol": { + "type": "string" + } + } + }, + "CreateLocationRequestBodyByUpstreamId": { + "type": "object", + "required": [ + "path", + "match_type", + "order", + "upstream_id" + ], + "properties": { + "allowed_methods": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "custom_config": { "type": [ "string", "null" - ], + ] + }, + "match_type": { + "type": "string" + }, + "order": { + "type": "integer", + "format": "int64" + }, + "path": { + "type": "string" + }, + "preserve_host_header": { + "type": [ + "boolean", + "null" + ] + }, + "upstream_id": { + "type": "string", "format": "uuid" } } diff --git a/apps/frontend/app/generated/api-client/api-client.ts b/apps/frontend/app/generated/api-client/api-client.ts index 1d42ada..04ece1e 100644 --- a/apps/frontend/app/generated/api-client/api-client.ts +++ b/apps/frontend/app/generated/api-client/api-client.ts @@ -1,6 +1,48 @@ export namespace Schemas { // export type AdminInitRequest = { password: string; setup_secret: string; username: string }; + export type CreateLocationReq = { + match_type: string; + order: number; + path: string; + upstream_id?: (string | null) | undefined; + }; + export type CreateLocationRequestBodyByUpstreamId = { + allowed_methods?: (Array | null) | undefined; + custom_config?: (string | null) | undefined; + match_type: string; + order: number; + path: string; + preserve_host_header?: (boolean | null) | undefined; + upstream_id: string; + }; + export type CreateLocationRequestBodyByProxyPass = { + allowed_methods?: (Array | null) | undefined; + custom_config?: (string | null) | undefined; + match_type: string; + order: number; + path: string; + preserve_host_header?: (boolean | null) | undefined; + proxy_pass_host: string; + proxy_pass_port: number; + proxy_pass_protocol: string; + }; + export type CreateLocationRequestBody = CreateLocationRequestBodyByUpstreamId | CreateLocationRequestBodyByProxyPass; + export type CreateProxyRequestBody = { + default_upstream_id?: (string | null) | undefined; + domain: string; + enable_websocket: boolean; + enabled: boolean; + forward_host?: (string | null) | undefined; + forward_port?: (number | null) | undefined; + forward_scheme: string; + listen_port: number; + locations: Array; + meta?: unknown | undefined; + name?: (string | null) | undefined; + preserve_host_header: boolean; + scheme: string; + }; export type UpstreamBasicInfo = { created_at: string; id: string; @@ -44,8 +86,45 @@ export namespace Schemas { up_since: string; version: string; }; + export type LocationInfoResponse = { + created_at: string; + enabled: boolean; + host_id: string; + id: string; + match_type: string; + order: number; + path: string; + updated_at: string; + upstream_id?: (string | null) | undefined; + }; export type LoginRequest = { password: string; username: string }; export type PaginationInfo = { current_page: number; per_page: number; total_items: number; total_pages: number }; + export type ProxyHostUpstreamBasic = { id: string; name: string; protocol: string }; + export type ProxyHostInfoResponse = { + created_at: string; + domain: string; + enable_websocket: boolean; + enabled: boolean; + forward_host?: (string | null) | undefined; + forward_port?: (number | null) | undefined; + forward_scheme: string; + id: string; + listen_port: number; + locations: Array; + name?: (string | null) | undefined; + preserve_host_header: boolean; + scheme: string; + updated_at: string; + upstream?: (null | ProxyHostUpstreamBasic) | undefined; + }; + export type ProxyListResponse = { items: Array; pagination: PaginationInfo }; + export type UpdateLocationRequestBody = Partial<{ + match_type: string | null; + order: number | null; + path: string | null; + upstream_id: string | null; + }>; + export type UpdateProxyRequestBody = Partial<{ domain: string | null; name: string | null }>; export type UpstreamTargetBasicInfo = { created_at: string; enabled: boolean; @@ -149,6 +228,91 @@ export namespace Endpoints { parameters: never; responses: { 200: Schemas.HealthInfo; 404: unknown }; }; + export type get_Get_location = { + method: "GET"; + path: "/api/nginx/locations/{location_id}"; + requestFormat: "json"; + parameters: { + path: { location_id: string }; + }; + responses: { 200: Schemas.LocationInfoResponse; 404: unknown; 500: unknown }; + }; + export type delete_Remove_location = { + method: "DELETE"; + path: "/api/nginx/locations/{location_id}"; + requestFormat: "json"; + parameters: { + path: { location_id: string }; + }; + responses: { 200: unknown; 401: unknown; 404: unknown; 500: unknown }; + }; + export type patch_Update_location = { + method: "PATCH"; + path: "/api/nginx/locations/{location_id}"; + requestFormat: "json"; + parameters: { + path: { location_id: string }; + + body: Schemas.UpdateLocationRequestBody; + }; + responses: { 200: Schemas.LocationInfoResponse; 401: unknown; 404: unknown; 422: unknown; 500: unknown }; + }; + export type get_Get_proxy_list = { + method: "GET"; + path: "/api/nginx/proxy_hosts"; + requestFormat: "json"; + parameters: never; + responses: { 200: Schemas.ProxyListResponse; 500: unknown }; + }; + export type post_Create_proxy = { + method: "POST"; + path: "/api/nginx/proxy_hosts"; + requestFormat: "json"; + parameters: { + body: Schemas.CreateProxyRequestBody; + }; + responses: { 200: Schemas.ProxyHostInfoResponse; 401: unknown; 422: unknown; 500: unknown }; + }; + export type get_Get_proxy = { + method: "GET"; + path: "/api/nginx/proxy_hosts/{proxy_id}"; + requestFormat: "json"; + parameters: { + path: { proxy_id: string }; + }; + responses: { 200: Schemas.ProxyHostInfoResponse; 404: unknown; 500: unknown }; + }; + export type delete_Remove_proxy = { + method: "DELETE"; + path: "/api/nginx/proxy_hosts/{proxy_id}"; + requestFormat: "json"; + parameters: { + path: { proxy_id: string }; + }; + responses: { 200: unknown; 401: unknown; 404: unknown; 500: unknown }; + }; + export type patch_Update_proxy = { + method: "PATCH"; + path: "/api/nginx/proxy_hosts/{proxy_id}"; + requestFormat: "json"; + parameters: { + path: { proxy_id: string }; + + body: Schemas.UpdateProxyRequestBody; + }; + responses: { 200: Schemas.ProxyHostInfoResponse; 401: unknown; 422: unknown; 500: unknown }; + }; + export type post_Create_location = { + method: "POST"; + path: "/api/nginx/proxy_hosts/{proxy_id}/locations"; + requestFormat: "json"; + parameters: { + path: { proxy_id: string }; + + body: Schemas.CreateLocationRequestBody; + }; + responses: { 200: Schemas.LocationInfoResponse; 401: unknown; 422: unknown; 500: unknown }; + }; export type get_Get_upstream_target = { method: "GET"; path: "/api/nginx/upstream_targets/{upstream_target_id}"; @@ -254,21 +418,30 @@ export type EndpointByMethod = { post: { "/api/auth/init_admin": Endpoints.post_Init_admin; "/api/auth/login": Endpoints.post_Login; + "/api/nginx/proxy_hosts": Endpoints.post_Create_proxy; + "/api/nginx/proxy_hosts/{proxy_id}/locations": Endpoints.post_Create_location; "/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/locations/{location_id}": Endpoints.get_Get_location; + "/api/nginx/proxy_hosts": Endpoints.get_Get_proxy_list; + "/api/nginx/proxy_hosts/{proxy_id}": Endpoints.get_Get_proxy; "/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/locations/{location_id}": Endpoints.delete_Remove_location; + "/api/nginx/proxy_hosts/{proxy_id}": Endpoints.delete_Remove_proxy; "/api/nginx/upstream_targets/{upstream_target_id}": Endpoints.delete_Remove_upstream_target; "/api/nginx/upstreams/{upstream_id}": Endpoints.delete_Remove_upstream; }; patch: { + "/api/nginx/locations/{location_id}": Endpoints.patch_Update_location; + "/api/nginx/proxy_hosts/{proxy_id}": Endpoints.patch_Update_proxy; "/api/nginx/upstream_targets/{upstream_target_id}": Endpoints.patch_Update_upstream_target; "/api/nginx/upstreams/{upstream_id}": Endpoints.patch_Update_upstream; }; From 4fe03b245eac90c676fff237da4308e92ac4e048 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Mon, 12 Jan 2026 11:49:46 +0800 Subject: [PATCH 8/8] test: added create location with proxy pass tests --- .../nginx/proxy_host/create_location.rs | 241 ++++++++++++------ 1 file changed, 160 insertions(+), 81 deletions(-) diff --git a/apps/api/src/routes/api/restricted/nginx/proxy_host/create_location.rs b/apps/api/src/routes/api/restricted/nginx/proxy_host/create_location.rs index 99a038e..312398e 100644 --- a/apps/api/src/routes/api/restricted/nginx/proxy_host/create_location.rs +++ b/apps/api/src/routes/api/restricted/nginx/proxy_host/create_location.rs @@ -49,6 +49,87 @@ pub struct CreateLocationRequestBodyByProxyPass { pub custom_config: Option, } +impl From<(uuid::Uuid, CreateLocationRequestBody)> for CreateLocationInfo { + fn from(val: (uuid::Uuid, CreateLocationRequestBody)) -> Self { + match val.1 { + CreateLocationRequestBody::UpstreamId(body) => Self::from((val.0, body)), + CreateLocationRequestBody::ProxyPass(body) => Self::from((val.0, body)), + } + } +} + +impl From<(uuid::Uuid, CreateLocationRequestBodyByUpstreamId)> for CreateLocationInfo { + fn from((proxy_id, payload): (uuid::Uuid, CreateLocationRequestBodyByUpstreamId)) -> Self { + Self { + host_id: proxy_id, + path: payload.path, + match_type: payload.match_type, + order: payload.order, + upstream_id: Some(payload.upstream_id), + proxy_pass_protocol: None, + proxy_pass_host: None, + proxy_pass_port: None, + preserve_host_header: payload.preserve_host_header, + allowed_methods: payload.allowed_methods, + custom_config: payload.custom_config, + enabled: true, + } + } +} + +impl From<(uuid::Uuid, CreateLocationRequestBodyByProxyPass)> for CreateLocationInfo { + fn from((proxy_id, payload): (uuid::Uuid, CreateLocationRequestBodyByProxyPass)) -> Self { + Self { + host_id: proxy_id, + path: payload.path, + match_type: payload.match_type, + order: payload.order, + upstream_id: None, + proxy_pass_protocol: Some(payload.proxy_pass_protocol), + proxy_pass_host: Some(payload.proxy_pass_host), + proxy_pass_port: Some(payload.proxy_pass_port), + preserve_host_header: payload.preserve_host_header, + allowed_methods: payload.allowed_methods, + custom_config: payload.custom_config, + enabled: true, + } + } +} + +#[axum::debug_handler] +#[utoipa::path( + post, + path = "/api/nginx/proxy_hosts/{proxy_id}/locations", + request_body = CreateLocationRequestBody, + responses( + (status = 200, description = "Location created", body = LocationInfoResponse), + (status = 401, description = "Unauthorized"), + (status = 422, description = "Invalid request"), + (status = 500, description = "Internal server error"), + ), + tag = NGINX_TAG, +)] +pub async fn create_location( + _request_info: AuthenticatedRequestInfo, + axum::extract::Path(proxy_id): axum::extract::Path, + State(state): State>, + Json(payload): Json, +) -> AxumResult, ApiError> { + let svc = &state.service.nginx.get_location_service(); + let create_info: CreateLocationInfo = (proxy_id, payload).into(); + + let mut tx = state.database_connection.begin().await?; + let info = svc.create_location(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(info.into())) +} + #[cfg(test)] mod tests { use std::sync::Arc; @@ -63,6 +144,7 @@ mod tests { configs::{FromConfig, ProgramSettings}, middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER, routes::api::restricted::nginx::proxy_host::{ + create_location::CreateLocationRequestBodyByProxyPass, create_location::CreateLocationRequestBodyByUpstreamId, get_proxy_router, }, services::{agent_client::MockAgentService, get_mock_app_service}, @@ -162,6 +244,84 @@ mod tests { assert_eq!(body.id, loc_id); } + #[tokio::test] + async fn handler_create_location_proxy_pass_succeeds_returns_created() { + let ph_id = uuid::Uuid::new_v4(); + let loc_id = uuid::Uuid::new_v4(); + + let loc_model = location::Model { + id: loc_id, + host_id: ph_id, + path: "/".to_string(), + match_type: "prefix".to_string(), + order: 1, + upstream_id: None, + proxy_pass_protocol: Some("http".to_string()), + proxy_pass_host: Some("127.0.0.1".to_string()), + proxy_pass_port: Some(8080), + preserve_host_header: None, + allowed_methods: None, + custom_config: None, + enabled: true, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let up_id = uuid::Uuid::new_v4(); + let up_model = upstream::Model { + id: up_id, + 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 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 dummy_row: Vec = vec![]; + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![vec![loc_model.clone()]]) + // additional query result for regenerate_and_apply_config -> generate_config + .append_query_results(vec![vec![(up_model.clone(), Some(target_model.clone()))]]) + .append_query_results(vec![dummy_row]) + .into_connection(); + + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let payload = CreateLocationRequestBodyByProxyPass { + path: "/".to_string(), + match_type: "prefix".to_string(), + order: 1, + proxy_pass_protocol: "http".to_string(), + proxy_pass_host: "127.0.0.1".to_string(), + proxy_pass_port: 8080, + preserve_host_header: None, + allowed_methods: None, + custom_config: None, + }; + + let res = server + .post(&format!("/proxy_hosts/{}/locations", ph_id)) + .json(&payload) + .await; + res.assert_status_ok(); + let body = res.json::(); + assert_eq!(body.id, loc_id); + } + #[tokio::test] async fn handler_create_location_invalid_payload_returns_bad_request() { let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection(); @@ -200,84 +360,3 @@ mod tests { res.assert_status(StatusCode::UNAUTHORIZED); } } - -impl From<(uuid::Uuid, CreateLocationRequestBody)> for CreateLocationInfo { - fn from(val: (uuid::Uuid, CreateLocationRequestBody)) -> Self { - match val.1 { - CreateLocationRequestBody::UpstreamId(body) => Self::from((val.0, body)), - CreateLocationRequestBody::ProxyPass(body) => Self::from((val.0, body)), - } - } -} - -impl From<(uuid::Uuid, CreateLocationRequestBodyByUpstreamId)> for CreateLocationInfo { - fn from((proxy_id, payload): (uuid::Uuid, CreateLocationRequestBodyByUpstreamId)) -> Self { - Self { - host_id: proxy_id, - path: payload.path, - match_type: payload.match_type, - order: payload.order, - upstream_id: Some(payload.upstream_id), - proxy_pass_protocol: None, - proxy_pass_host: None, - proxy_pass_port: None, - preserve_host_header: payload.preserve_host_header, - allowed_methods: payload.allowed_methods, - custom_config: payload.custom_config, - enabled: true, - } - } -} - -impl From<(uuid::Uuid, CreateLocationRequestBodyByProxyPass)> for CreateLocationInfo { - fn from((proxy_id, payload): (uuid::Uuid, CreateLocationRequestBodyByProxyPass)) -> Self { - Self { - host_id: proxy_id, - path: payload.path, - match_type: payload.match_type, - order: payload.order, - upstream_id: None, - proxy_pass_protocol: Some(payload.proxy_pass_protocol), - proxy_pass_host: Some(payload.proxy_pass_host), - proxy_pass_port: Some(payload.proxy_pass_port), - preserve_host_header: payload.preserve_host_header, - allowed_methods: payload.allowed_methods, - custom_config: payload.custom_config, - enabled: true, - } - } -} - -#[axum::debug_handler] -#[utoipa::path( - post, - path = "/api/nginx/proxy_hosts/{proxy_id}/locations", - request_body = CreateLocationRequestBody, - responses( - (status = 200, description = "Location created", body = LocationInfoResponse), - (status = 401, description = "Unauthorized"), - (status = 422, description = "Invalid request"), - (status = 500, description = "Internal server error"), - ), - tag = NGINX_TAG, -)] -pub async fn create_location( - _request_info: AuthenticatedRequestInfo, - axum::extract::Path(proxy_id): axum::extract::Path, - State(state): State>, - Json(payload): Json, -) -> AxumResult, ApiError> { - let svc = &state.service.nginx.get_location_service(); - let create_info: CreateLocationInfo = (proxy_id, payload).into(); - - let mut tx = state.database_connection.begin().await?; - let info = svc.create_location(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(info.into())) -}