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] 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(_)))); + } +}