Merge pull request 'feature/proxy-service' (#14) from feature/proxy-service into master
All checks were successful
Test / test-frontend (push) Successful in 22s
Test / lint-frontend (push) Successful in 26s
Test / frontend-build (push) Successful in 30s
Verify / verify-generated-database-code (push) Successful in 1m5s
Verify / verify-generated-agent-code (push) Successful in 1m8s
Verify / verify-openapi-spec (push) Successful in 1m10s
Verify / verify-frontend-api-client (push) Successful in 19s
Test / test-crates (push) Successful in 1m0s
Test / lint-crates (push) Successful in 1m8s

Reviewed-on: #14
This commit was merged in pull request #14.
This commit is contained in:
2026-01-12 11:56:37 +08:00
41 changed files with 5133 additions and 32 deletions

View File

@@ -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"),

View File

@@ -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<AppState>) -> 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()))
}

View File

@@ -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<AppState>) -> 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)
}

View File

@@ -0,0 +1,362 @@
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)]
#[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: uuid::Uuid,
pub preserve_host_header: Option<bool>,
pub allowed_methods: Option<Vec<String>>,
pub custom_config: Option<String>,
}
#[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<bool>,
pub allowed_methods: Option<Vec<String>>,
pub custom_config: Option<String>,
}
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<uuid::Uuid>,
State(state): State<Arc<AppState>>,
Json(payload): Json<CreateLocationRequestBody>,
) -> AxumResult<Json<LocationInfoResponse>, 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;
use axum::http::StatusCode;
use axum_test::TestServer;
use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, MockRow};
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::CreateLocationRequestBodyByProxyPass,
create_location::CreateLocationRequestBodyByUpstreamId, 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 dummy_row: Vec<MockRow> = 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 = CreateLocationRequestBodyByUpstreamId {
path: "/".to_string(),
match_type: "prefix".to_string(),
order: 1,
upstream_id: up_id,
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::<crate::routes::api::restricted::nginx::proxy_host::info::response::LocationInfoResponse>();
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<MockRow> = 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::<crate::routes::api::restricted::nginx::proxy_host::info::response::LocationInfoResponse>();
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 = CreateLocationRequestBodyByUpstreamId {
path: "/".to_string(),
match_type: "prefix".to_string(),
order: 1,
upstream_id: uuid::Uuid::new_v4(),
preserve_host_header: None,
allowed_methods: None,
custom_config: 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);
}
}

View File

@@ -0,0 +1,310 @@
use std::sync::Arc;
use axum::{Json, extract::State, response::Result as AxumResult};
use sea_orm::TransactionTrait;
use crate::{
errors::api_error::ApiError,
middlewares::request_info::AuthenticatedRequestInfo,
routes::{
AppState,
api::{
openapi::tag::NGINX_TAG,
restricted::nginx::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<uuid::Uuid>,
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use axum::http::StatusCode;
use axum_test::TestServer;
use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, MockRow};
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 dummy_row: Vec<MockRow> = 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());
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<String>,
pub domain: String,
pub scheme: String,
pub listen_port: i64,
pub forward_scheme: String,
pub forward_host: Option<String>,
pub forward_port: Option<i64>,
pub preserve_host_header: bool,
pub enable_websocket: bool,
pub enabled: bool,
pub meta: Option<serde_json::Value>,
pub default_upstream_id: Option<uuid::Uuid>,
pub locations: Vec<CreateLocationReq>,
}
impl From<CreateProxyRequestBody> 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<Arc<AppState>>,
Json(payload): Json<CreateProxyRequestBody>,
) -> AxumResult<Json<ProxyHostInfoResponse>, 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()))
}

View File

@@ -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<bool>,
}
pub struct ConcreteGetLocationParams {
pub include_upstream: bool,
}
impl From<GetLocationParams> 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<uuid::Uuid>,
Query(params): Query<GetLocationParams>,
State(state): State<Arc<AppState>>,
) -> AxumResult<Json<LocationInfoResponse>, 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::<proxy_host::Model>::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::<crate::routes::api::restricted::nginx::proxy_host::info::response::LocationInfoResponse>();
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::<sea_orm::MockRow>::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);
}
}

View File

@@ -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<Arc<AppState>>,
) -> AxumResult<Json<ProxyListResponse>, 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<ProxyHostInfoResponse> = 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::<location::Model>),
(p2.clone(), None::<location::Model>),
]])
.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::<crate::routes::api::restricted::nginx::proxy_host::info::response::ProxyListResponse>();
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::<crate::routes::api::restricted::nginx::proxy_host::info::response::ProxyHostInfoResponse>();
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::<sea_orm::MockRow>::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<bool>,
}
pub struct ConcreteGetProxyParams {
pub include_upstream: bool,
}
impl From<GetProxyParams> 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<Uuid>,
Query(params): Query<GetProxyParams>,
State(state): State<Arc<AppState>>,
) -> AxumResult<Json<ProxyHostInfoResponse>, 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()))
}

View File

@@ -0,0 +1 @@
pub mod response;

View File

@@ -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<uuid::Uuid>,
pub enabled: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl From<crate::services::nginx::info::location::LocationInfo> 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<String>,
pub domain: String,
pub scheme: String,
pub listen_port: i64,
pub forward_scheme: String,
pub forward_host: Option<String>,
pub forward_port: Option<i64>,
pub preserve_host_header: bool,
pub enable_websocket: bool,
pub enabled: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub upstream: Option<ProxyHostUpstreamBasic>,
pub locations: Vec<LocationInfoResponse>,
}
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
pub struct ProxyHostUpstreamBasic {
pub id: uuid::Uuid,
pub name: String,
pub protocol: String,
}
impl From<crate::services::nginx::info::proxy_host::ProxyHostInfo> 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<ProxyHostInfoResponse>,
pub pagination: PaginationInfo,
}

View File

@@ -0,0 +1,159 @@
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<uuid::Uuid>,
State(state): State<Arc<AppState>>,
) -> AxumResult<Json<()>, 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, MockRow};
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 dummy_row: Vec<MockRow> = vec![];
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()))]])
.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 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<location::Model>> = vec![Vec::<location::Model>::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);
}
}

View File

@@ -0,0 +1,182 @@
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<Uuid>,
State(state): State<Arc<AppState>>,
) -> AxumResult<Json<()>, 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, MockRow};
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 dummy_row: Vec<MockRow> = vec![];
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()))]])
.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 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<proxy_host::Model>> = vec![Vec::<proxy_host::Model>::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);
}
}

View File

@@ -0,0 +1,220 @@
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<String>,
pub match_type: Option<String>,
pub order: Option<i64>,
pub upstream_id: Option<Option<uuid::Uuid>>,
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use axum::http::StatusCode;
use axum_test::TestServer;
use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, MockRow};
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<location::Model>> = vec![vec![current.clone()]];
let second: Vec<Vec<location::Model>> = 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 dummy_row: Vec<MockRow> = 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());
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::<crate::routes::api::restricted::nginx::proxy_host::info::response::LocationInfoResponse>();
assert_eq!(body.id, loc_id);
}
#[tokio::test]
async fn handler_update_location_not_found_returns_not_found() {
let empty_results: Vec<Vec<location::Model>> = vec![Vec::<location::Model>::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<UpdateLocationRequestBody> 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<uuid::Uuid>,
State(state): State<Arc<AppState>>,
Json(payload): Json<UpdateLocationRequestBody>,
) -> AxumResult<Json<LocationInfoResponse>, 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()))
}

View File

@@ -0,0 +1,227 @@
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<Option<String>>,
pub domain: Option<String>,
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use axum::http::StatusCode;
use axum_test::TestServer;
use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, MockRow};
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<proxy_host::Model>> = vec![vec![current.clone()]];
let second: Vec<Vec<proxy_host::Model>> = 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 dummy_row: Vec<MockRow> = 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());
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<proxy_host::Model>> = vec![Vec::<proxy_host::Model>::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<UpdateProxyRequestBody> 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<uuid::Uuid>,
State(state): State<Arc<AppState>>,
Json(payload): Json<UpdateProxyRequestBody>,
) -> AxumResult<Json<ProxyHostInfoResponse>, 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()))
}

View File

@@ -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<MockRow> = 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<target>)` which
// the mock DB expects as `(Model, Option<Model>)` 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());

View File

@@ -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<MockRow> = 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<MockRow> = 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());

View File

@@ -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<MockRow> = 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());

View File

@@ -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<MockRow> = vec![];
let first: Vec<Vec<upstream_target::Model>> = 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());

View File

@@ -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<upstream::Model>> = vec![vec![current_model.clone()]];
let second: Vec<Vec<upstream::Model>> = vec![vec![updated_model.clone()]];
let dummy_row: Vec<MockRow> = 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::<upstream_target::Model>::None,
)]])
.append_query_results(vec![dummy_row])
.into_connection();
let router = get_router_with_state(db.clone());

View File

@@ -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<(upstream::Model, Option<upstream_target::Model>)>> =
vec![vec![(up_model.clone(), Some(updated_model.clone()))]];
// placeholder for other queries called by regenerate_and_apply_config
let fourth: Vec<Vec<upstream::Model>> = 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());

View File

@@ -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<DatabaseConnection>,
//
#[allow(dead_code)]
upstream_service: Arc<dyn UpstreamService>,
#[allow(dead_code)]
proxy_service: Arc<dyn ProxyService>,
#[allow(dead_code)]
location_service: Arc<dyn LocationService>,
}
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<dyn ProxyService> {
self.proxy_service.clone()
}
pub fn get_location_service(&self) -> Arc<dyn LocationService> {
self.location_service.clone()
}
#[allow(dead_code)]
pub async fn validate_config(
&self,
@@ -62,20 +81,21 @@ impl NginxService {
pub async fn generate_config(
&self,
tx: Option<&mut DatabaseTransaction>,
tx: &Option<&mut DatabaseTransaction>,
) -> Result<String, ServiceError> {
let mut builder = NginxConfigBuilder::default();
self.upstream_service
.generate_config(&mut builder, tx)
.await?;
self.proxy_service.generate_config(&mut builder, tx).await?;
Ok(builder.to_nginx_config(None))
builder.to_nginx_config(None)
}
pub async fn regenerate_and_apply_config(
&self,
agent: Arc<dyn AgentService>,
tx: Option<&mut DatabaseTransaction>,
tx: &Option<&mut DatabaseTransaction>,
) -> Result<(), ServiceError> {
let config = self.generate_config(tx).await?;

View File

@@ -1,14 +1,18 @@
use crate::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;
pub trait NginxConfigProvider {
fn to_nginx_config(&self, indent: Option<usize>) -> String;
fn to_nginx_config(&self, indent: Option<usize>) -> Result<String, ServiceError>;
}
#[derive(Default)]
pub struct NginxConfigBuilder {
upstreams: Vec<UpstreamInfo>,
proxy_hosts: Vec<ProxyHostInfo>,
}
impl NginxConfigBuilder {
@@ -21,10 +25,20 @@ 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<ProxyHostInfo>) {
for proxy_host in proxy_hosts {
self.add_proxy_host(proxy_host);
}
}
}
impl NginxConfigProvider for NginxConfigBuilder {
fn to_nginx_config(&self, indent: Option<usize>) -> String {
fn to_nginx_config(&self, indent: Option<usize>) -> Result<String, ServiceError> {
let mut config = format!(
"# Nginx Config Generated by YANPM at {}",
chrono::Utc::now()
@@ -32,12 +46,16 @@ 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)?);
}
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');
config
Ok(config)
}
}

View File

@@ -1,2 +1,4 @@
pub mod location;
pub mod proxy_host;
pub mod upstream;
pub mod upstream_target;

View File

@@ -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<Uuid>,
pub proxy_pass_info: Option<ProxyPassInfo>,
pub preserve_host_header: Option<bool>,
pub allowed_methods: Option<Vec<String>>,
pub custom_config: Option<String>,
pub enabled: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
//
pub upstream: Option<super::upstream::UpstreamInfo>,
pub proxy_host: Option<super::proxy_host::ProxyHostInfo>,
}
pub struct CreateLocationInfo {
pub host_id: Uuid,
pub path: String,
pub match_type: String,
pub order: i64,
pub upstream_id: Option<Uuid>,
pub proxy_pass_protocol: Option<String>,
pub proxy_pass_host: Option<String>,
pub proxy_pass_port: Option<i64>,
pub preserve_host_header: Option<bool>,
pub allowed_methods: Option<Vec<String>>,
pub custom_config: Option<String>,
pub enabled: bool,
}
#[derive(Clone)]
pub struct UpdateLocationInfo {
pub path: Option<String>,
pub match_type: Option<String>,
pub order: Option<i64>,
pub upstream_id: Option<Option<Uuid>>,
pub proxy_pass_protocol: Option<Option<String>>,
pub proxy_pass_host: Option<Option<String>>,
pub proxy_pass_port: Option<Option<i64>>,
pub preserve_host_header: Option<Option<bool>>,
pub allowed_methods: Option<Option<Vec<String>>>,
pub custom_config: Option<Option<String>>,
pub enabled: Option<bool>,
}
impl From<location::Model> for LocationInfo {
fn from(model: location::Model) -> Self {
let allowed_methods: Option<Vec<String>> = match model.allowed_methods {
Some(JsonValue::Array(arr)) => {
let v: Vec<String> = 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<proxy_host::Model>)> for LocationInfo {
fn from(data: (location::Model, Option<proxy_host::Model>)) -> Self {
let (location_model, proxy_host_model_opt) = data;
(location_model, proxy_host_model_opt, None).into()
}
}
impl
From<(
location::Model,
Option<proxy_host::Model>,
Option<upstream::Model>,
)> for LocationInfo
{
fn from(
data: (
location::Model,
Option<proxy_host::Model>,
Option<upstream::Model>,
),
) -> 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<CreateLocationInfo> 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<usize>) -> Result<String, ServiceError> {
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<String> = 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::<Vec<String>>()
.join("\n")
};
Ok(format!("{}{{\n{}\n}}", selector.trim_end(), inner).indent(indent))
}
}

View File

@@ -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<String>,
pub domain: String,
pub scheme: String,
pub listen_port: i64,
pub forward_scheme: String,
pub forward_host: Option<String>,
pub forward_port: Option<i64>,
pub preserve_host_header: bool,
pub enable_websocket: bool,
pub meta: Option<JsonValue>,
pub enabled: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
//
pub upstream: Option<super::upstream::UpstreamInfo>,
pub locations: Vec<super::location::LocationInfo>,
}
pub struct ProxyHostCreateInfo {
pub name: Option<String>,
pub domain: String,
pub scheme: String,
pub listen_port: i64,
pub forward_scheme: String,
pub forward_host: Option<String>,
pub forward_port: Option<i64>,
pub preserve_host_header: bool,
pub enable_websocket: bool,
pub enabled: bool,
pub meta: Option<JsonValue>,
pub default_upstream_id: Option<Uuid>,
pub created_by: Option<Uuid>,
//
pub locations: Vec<super::location::CreateLocationInfo>,
}
#[derive(Clone)]
pub struct UpdateProxyHostInfo {
pub name: Option<Option<String>>,
pub domain: Option<String>,
pub scheme: Option<String>,
pub listen_port: Option<i64>,
pub forward_scheme: Option<String>,
pub forward_host: Option<Option<String>>,
pub forward_port: Option<Option<i64>>,
pub preserve_host_header: Option<bool>,
pub enable_websocket: Option<bool>,
pub enabled: Option<bool>,
pub meta: Option<Option<JsonValue>>,
pub default_upstream_id: Option<Option<Uuid>>,
}
impl From<proxy_host::Model> 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<location::Model>)> for ProxyHostInfo {
fn from(data: (proxy_host::Model, Vec<location::Model>)) -> Self {
let (proxy_model, location_models) = data;
let mut proxy_info = ProxyHostInfo::from(proxy_model);
let locations_info: Vec<super::location::LocationInfo> =
location_models.into_iter().map(|m| m.into()).collect();
proxy_info.locations = locations_info;
proxy_info
}
}
impl From<ProxyHostCreateInfo> for (proxy_host::ActiveModel, Vec<location::ActiveModel>) {
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<ProxyHostInfo> 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<usize>) -> Result<String, ServiceError> {
let indent = indent.unwrap_or(0);
let mut body: Vec<String> = 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<usize> = (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::<Vec<String>>()
.join("\n")
};
Ok(format!(
"server {{\n listen {};\n server_name {};\n{}\n}}",
self.listen_port, self.domain, inner
)
.indent(indent))
}
}

View File

@@ -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<usize>) -> String {
fn to_nginx_config(&self, indent: Option<usize>) -> Result<String, ServiceError> {
let targets_config: Vec<String> = self
.upstream_targets
.iter()
.map(|target| target.to_nginx_config(Some(indent.unwrap_or(0) + INDENT_SIZE)))
.collect();
.collect::<Result<Vec<String>, 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)),
)
}
}

View File

@@ -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<UpstreamTargetCreateInfo> for upstream_target::ActiveModel {
}
impl NginxConfigProvider for UpstreamTargetInfo {
fn to_nginx_config(&self, indent: Option<usize>) -> String {
format!(
fn to_nginx_config(&self, indent: Option<usize>) -> Result<String, ServiceError> {
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)))
}
}

View File

@@ -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<LocationInfo, ServiceError>;
#[allow(dead_code)]
async fn get_locations(
&self,
pagination: Option<PaginationFilter>,
options: Option<GetLocationOptions>,
tx: Option<&mut DatabaseTransaction>,
) -> Result<Vec<LocationInfo>, ServiceError>;
async fn get_location(
&self,
location_id: uuid::Uuid,
options: Option<GetLocationOptions>,
tx: Option<&mut DatabaseTransaction>,
) -> Result<LocationInfo, ServiceError>;
async fn update_location(
&self,
location_id: uuid::Uuid,
update: UpdateLocationInfo,
tx: Option<&mut DatabaseTransaction>,
) -> Result<LocationInfo, ServiceError>;
async fn delete_location(
&self,
location_id: uuid::Uuid,
tx: Option<&mut DatabaseTransaction>,
) -> Result<(), ServiceError>;
}
pub struct LocationServiceImpl {
connection: Arc<DatabaseConnection>,
}
#[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<DatabaseConnection>) -> Self {
Self { connection }
}
}
#[async_trait::async_trait]
impl LocationService for LocationServiceImpl {
async fn create_location(
&self,
create_info: CreateLocationInfo,
tx: Option<&mut DatabaseTransaction>,
) -> Result<LocationInfo, ServiceError> {
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<PaginationFilter>,
options: Option<GetLocationOptions>,
tx: Option<&mut DatabaseTransaction>,
) -> Result<Vec<LocationInfo>, 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<LocationInfo> = 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<GetLocationOptions>,
tx: Option<&mut DatabaseTransaction>,
) -> Result<LocationInfo, ServiceError> {
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<LocationInfo> = 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<LocationInfo, ServiceError> {
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::<proxy_host::Model>),
(l2.clone(), None::<proxy_host::Model>),
]])
.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::<proxy_host::Model>)]])
.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::<sea_orm::MockRow>::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::<sea_orm::MockRow>::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::<sea_orm::MockRow>::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(_))));
}
}

View File

@@ -0,0 +1,622 @@
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::{
builder::NginxConfigBuilder,
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<ProxyHostInfo, ServiceError>;
async fn get_total_proxies(
&self,
options: Option<ProxyTotalCountOptions>,
tx: &Option<&mut DatabaseTransaction>,
) -> Result<u64, ServiceError>;
async fn get_proxies(
&self,
pagination: Option<PaginationFilter>,
options: Option<ProxyHostListOptions>,
tx: &Option<&mut DatabaseTransaction>,
) -> Result<Vec<ProxyHostInfo>, ServiceError>;
async fn get_proxy(
&self,
proxy_id: uuid::Uuid,
options: Option<ProxyHostGetOptions>,
tx: &Option<&mut DatabaseTransaction>,
) -> Result<ProxyHostInfo, ServiceError>;
async fn update_proxy(
&self,
proxy_id: uuid::Uuid,
update: UpdateProxyHostInfo,
tx: &Option<&mut DatabaseTransaction>,
) -> Result<ProxyHostInfo, ServiceError>;
async fn delete_proxy(
&self,
proxy_id: uuid::Uuid,
tx: &Option<&mut DatabaseTransaction>,
) -> Result<(), ServiceError>;
async fn generate_config(
&self,
builder: &mut NginxConfigBuilder,
tx: &Option<&mut DatabaseTransaction>,
) -> Result<(), ServiceError>;
}
pub struct ProxyServiceImpl {
connection: Arc<DatabaseConnection>,
}
#[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<DatabaseConnection>) -> Self {
Self { connection }
}
}
#[async_trait::async_trait]
impl ProxyService for ProxyServiceImpl {
async fn create_proxy(
&self,
create_info: ProxyHostCreateInfo,
tx: &Option<&mut DatabaseTransaction>,
) -> Result<ProxyHostInfo, ServiceError> {
let (proxy_host, location_models): (proxy_host::ActiveModel, Vec<location::ActiveModel>) =
create_info.into();
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<location::Model> =
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)
});
Ok(r.into())
}
async fn get_total_proxies(
&self,
_options: Option<ProxyTotalCountOptions>,
tx: &Option<&mut DatabaseTransaction>,
) -> Result<u64, ServiceError> {
#[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::<CountResult>()
.one(*conn)
.await?
});
Ok(count_info.map_or(0, |c| c.count) as u64)
}
async fn get_proxies(
&self,
pagination: Option<PaginationFilter>,
options: Option<ProxyHostListOptions>,
tx: &Option<&mut DatabaseTransaction>,
) -> Result<Vec<ProxyHostInfo>, 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<ProxyHostInfo> = 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<ProxyHostGetOptions>,
tx: &Option<&mut DatabaseTransaction>,
) -> Result<ProxyHostInfo, ServiceError> {
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<ProxyHostInfo> = 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<ProxyHostInfo, ServiceError> {
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(())
})
}
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)]
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::<sea_orm::MockRow>::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::<location::Model>),
(p2.clone(), None::<location::Model>),
]])
.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::<location::Model>)]])
.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::<sea_orm::MockRow>::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::<sea_orm::MockRow>::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::<sea_orm::MockRow>::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(_))));
}
}

View File

@@ -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>;
}
@@ -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::<CountResult>()
.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)
@@ -456,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, {

View File

@@ -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,213 @@
}
}
},
"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": {
"oneOf": [
{
"$ref": "#/components/schemas/CreateLocationRequestBodyByUpstreamId"
},
{
"$ref": "#/components/schemas/CreateLocationRequestBodyByProxyPass"
}
]
},
"CreateLocationRequestBodyByProxyPass": {
"type": "object",
"required": [
"path",
"match_type",
"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"
},
"order": {
"type": "integer",
"format": "int64"
},
"path": {
"type": "string"
},
"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"
}
}
},
"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 +1209,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 +1312,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": [

View File

@@ -1,6 +1,48 @@
export namespace Schemas {
// <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<string> | 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<string> | 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<CreateLocationReq>;
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<LocationInfoResponse>;
name?: (string | null) | undefined;
preserve_host_header: boolean;
scheme: string;
updated_at: string;
upstream?: (null | ProxyHostUpstreamBasic) | undefined;
};
export type ProxyListResponse = { items: Array<ProxyHostInfoResponse>; 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;
};

View File

@@ -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<Uuid>,
pub proxy_pass_protocol: Option<String>,
pub proxy_pass_host: Option<String>,
pub proxy_pass_port: Option<i64>,
pub preserve_host_header: Option<bool>,
#[sea_orm(column_type = "JsonBinary", nullable)]
pub allowed_methods: Option<Json>,
#[sea_orm(column_type = "Text", nullable)]
pub custom_config: Option<String>,
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<super::proxy_host::Entity>,
#[sea_orm(
belongs_to,
from = "upstream_id",
to = "id",
on_update = "Cascade",
on_delete = "SetNull"
)]
pub upstream: HasOne<super::upstream::Entity>,
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<String>,
pub domain: String,
pub scheme: String,
pub listen_port: i64,
pub forward_scheme: String,
pub forward_host: Option<String>,
pub forward_port: Option<i64>,
pub preserve_host_header: bool,
pub enable_websocket: bool,
pub enabled: bool,
#[sea_orm(column_type = "JsonBinary", nullable)]
pub meta: Option<Json>,
pub default_upstream_id: Option<Uuid>,
pub created_by: Option<Uuid>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(has_many)]
pub locations: HasMany<super::location::Entity>,
#[sea_orm(
belongs_to,
from = "default_upstream_id",
to = "id",
on_update = "Cascade",
on_delete = "SetNull"
)]
pub upstream: HasOne<super::upstream::Entity>,
#[sea_orm(
belongs_to,
from = "created_by",
to = "id",
on_update = "Cascade",
on_delete = "SetNull"
)]
pub user: HasOne<super::user::Entity>,
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -17,6 +17,10 @@ pub struct Model {
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(has_many)]
pub locations: HasMany<super::location::Entity>,
#[sea_orm(has_many)]
pub proxy_hosts: HasMany<super::proxy_host::Entity>,
#[sea_orm(has_many)]
pub upstream_targets: HasMany<super::upstream_target::Entity>,
}

View File

@@ -18,6 +18,8 @@ pub struct Model {
pub last_login_at: Option<DateTimeUtc>,
pub deleted_at: Option<DateTimeUtc>,
#[sea_orm(has_many)]
pub proxy_hosts: HasMany<super::proxy_host::Entity>,
#[sea_orm(has_many)]
pub user_identities: HasMany<super::user_identity::Entity>,
}

View File

@@ -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),
]
}
}

View File

@@ -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;

View File

@@ -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
}
}

View File

@@ -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
}
}