feat: Implement NGINX proxy host and location management endpoints

- Add `get_location` endpoint to retrieve location information with optional upstream inclusion.
- Introduce `get_proxy_list` and `get_proxy` endpoints for listing and retrieving proxy hosts.
- Implement `remove_location` and `remove_proxy` endpoints for deleting locations and proxy hosts respectively.
- Add `update_location` and `update_proxy` endpoints for modifying existing locations and proxy hosts.
- Create response structures for location and proxy host information.
- Implement tests for all new endpoints to ensure correct functionality and error handling.
This commit is contained in:
GW_MC
2026-01-07 15:58:21 +08:00
parent 83e02acb22
commit 9b8232d94d
14 changed files with 2606 additions and 1 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,221 @@
use std::sync::Arc;
use axum::{Json, extract::State, response::Result as AxumResult};
use sea_orm::TransactionTrait;
use crate::{
errors::api_error::ApiError,
middlewares::request_info::AuthenticatedRequestInfo,
routes::{
AppState,
api::{
openapi::tag::NGINX_TAG,
restricted::nginx::proxy_host::info::response::LocationInfoResponse,
},
},
services::nginx::info::location::CreateLocationInfo,
};
#[derive(serde::Deserialize, utoipa::ToSchema, serde::Serialize)]
pub struct CreateLocationRequestBody {
pub path: String,
pub match_type: String,
pub order: i64,
pub upstream_id: Option<uuid::Uuid>,
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use axum::http::StatusCode;
use axum_test::TestServer;
use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase};
use database::generated::entities::{location, upstream, upstream_target};
use crate::{
configs::{FromConfig, ProgramSettings},
middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER,
routes::api::restricted::nginx::proxy_host::create_location::CreateLocationRequestBody,
routes::api::restricted::nginx::proxy_host::get_proxy_router,
services::{agent_client::MockAgentService, get_mock_app_service},
};
fn get_router_with_state(db: DatabaseConnection) -> axum::Router {
let program_settings = ProgramSettings::mock();
let mut mock = MockAgentService::new();
mock.expect_validate().returning(|_cfg| Ok(()));
mock.expect_apply().returning(|_cfg| Ok(()));
let mock_agent = Arc::new(mock);
let app_service =
get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent);
let state = Arc::new(crate::routes::AppState {
database_connection: Arc::new(db),
service: Arc::new(app_service),
config: Arc::new(program_settings),
});
get_proxy_router(state).layer(axum::middleware::from_fn(
crate::middlewares::require_auth::mock::mock_require_auth,
))
}
#[tokio::test]
async fn handler_create_location_succeeds_returns_created() {
let ph_id = uuid::Uuid::new_v4();
let loc_id = uuid::Uuid::new_v4();
let loc_model = location::Model {
id: loc_id,
host_id: ph_id,
path: "/".to_string(),
match_type: "prefix".to_string(),
order: 1,
upstream_id: None,
proxy_pass_protocol: None,
proxy_pass_host: None,
proxy_pass_port: None,
preserve_host_header: None,
allowed_methods: None,
custom_config: None,
enabled: true,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let up_id = uuid::Uuid::new_v4();
let up_model = upstream::Model {
id: up_id,
name: "u1".to_string(),
protocol: "http".to_string(),
algorithm: "rr".to_string(),
sticky_session: false,
created_by: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let target_model = upstream_target::Model {
id: uuid::Uuid::new_v4(),
upstream_id: up_id,
target_host: "127.0.0.1".to_string(),
target_port: 8080,
weight: 1,
is_backup: false,
enabled: true,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![vec![loc_model.clone()]])
// additional query result for regenerate_and_apply_config -> generate_config
.append_query_results(vec![vec![(up_model.clone(), Some(target_model.clone()))]])
.into_connection();
let router = get_router_with_state(db.clone());
let server = TestServer::new(router).expect("failed to create test server");
let payload = CreateLocationRequestBody {
path: "/".to_string(),
match_type: "prefix".to_string(),
order: 1,
upstream_id: None,
};
let res = server
.post(&format!("/proxy_hosts/{}/locations", ph_id))
.json(&payload)
.await;
res.assert_status_ok();
let body = res.json::<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 = CreateLocationRequestBody {
path: "/".to_string(),
match_type: "prefix".to_string(),
order: 1,
upstream_id: None,
};
let res = server
.post(&format!("/proxy_hosts/{}/locations", uuid::Uuid::new_v4()))
.add_header(REQUEST_AUTH_USER_INVALID_HEADER, "true")
.json(&payload)
.await;
res.assert_status(StatusCode::UNAUTHORIZED);
}
}
impl From<(uuid::Uuid, CreateLocationRequestBody)> for CreateLocationInfo {
fn from(val: (uuid::Uuid, CreateLocationRequestBody)) -> Self {
Self {
host_id: val.0,
path: val.1.path,
match_type: val.1.match_type,
order: val.1.order,
upstream_id: val.1.upstream_id,
proxy_pass_protocol: None,
proxy_pass_host: None,
proxy_pass_port: None,
preserve_host_header: None,
allowed_methods: None,
custom_config: None,
enabled: true,
}
}
}
#[axum::debug_handler]
#[utoipa::path(
post,
path = "/api/nginx/proxy_hosts/{proxy_id}/locations",
request_body = CreateLocationRequestBody,
responses(
(status = 200, description = "Location created", body = LocationInfoResponse),
(status = 401, description = "Unauthorized"),
(status = 422, description = "Invalid request"),
(status = 500, description = "Internal server error"),
),
tag = NGINX_TAG,
)]
pub async fn create_location(
_request_info: AuthenticatedRequestInfo,
axum::extract::Path(proxy_id): axum::extract::Path<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()))
}

View File

@@ -0,0 +1,308 @@
use std::sync::Arc;
use axum::{Json, extract::State, response::Result as AxumResult};
use sea_orm::TransactionTrait;
use crate::{
errors::api_error::ApiError,
middlewares::request_info::AuthenticatedRequestInfo,
routes::{
AppState,
api::{
openapi::tag::NGINX_TAG,
restricted::nginx::proxy_host::info::response::ProxyHostInfoResponse,
},
},
services::nginx::info::proxy_host::ProxyHostCreateInfo,
};
#[derive(serde::Deserialize, utoipa::ToSchema, serde::Serialize)]
pub struct CreateLocationReq {
pub path: String,
pub match_type: String,
pub order: i64,
pub upstream_id: Option<uuid::Uuid>,
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use axum::http::StatusCode;
use axum_test::TestServer;
use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase};
use database::generated::entities::{location, proxy_host, upstream, upstream_target};
use crate::{
configs::{FromConfig, ProgramSettings},
middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER,
routes::api::restricted::nginx::proxy_host::create_proxy::CreateLocationReq as ReqLocation,
routes::api::restricted::nginx::proxy_host::create_proxy::CreateProxyRequestBody,
routes::api::restricted::nginx::proxy_host::get_proxy_router,
services::{agent_client::MockAgentService, get_mock_app_service},
};
fn get_router_with_state(db: DatabaseConnection) -> axum::Router {
let program_settings = ProgramSettings::mock();
let mut mock = MockAgentService::new();
mock.expect_validate().returning(|_cfg| Ok(()));
mock.expect_apply().returning(|_cfg| Ok(()));
let mock_agent = Arc::new(mock);
let app_service =
get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent);
let state = Arc::new(crate::routes::AppState {
database_connection: Arc::new(db),
service: Arc::new(app_service),
config: Arc::new(program_settings),
});
get_proxy_router(state).layer(axum::middleware::from_fn(
crate::middlewares::require_auth::mock::mock_require_auth,
))
}
#[tokio::test]
async fn handler_create_proxy_succeeds_returns_created() {
let ph_id = uuid::Uuid::new_v4();
let ph_model = proxy_host::Model {
id: ph_id,
name: Some("myproxy".to_string()),
domain: "example.com".to_string(),
scheme: "http".to_string(),
listen_port: 80,
forward_scheme: "http".to_string(),
forward_host: None,
forward_port: None,
preserve_host_header: false,
enable_websocket: false,
enabled: true,
meta: None,
default_upstream_id: None,
created_by: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let loc_id = uuid::Uuid::new_v4();
let loc_model = location::Model {
id: loc_id,
host_id: ph_id,
path: "/".to_string(),
match_type: "prefix".to_string(),
order: 1,
upstream_id: None,
proxy_pass_protocol: None,
proxy_pass_host: None,
proxy_pass_port: None,
preserve_host_header: None,
allowed_methods: None,
custom_config: None,
enabled: true,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let up_id = uuid::Uuid::new_v4();
let up_model = upstream::Model {
id: up_id,
name: "u1".to_string(),
protocol: "http".to_string(),
algorithm: "rr".to_string(),
sticky_session: false,
created_by: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let target_model = upstream_target::Model {
id: uuid::Uuid::new_v4(),
upstream_id: up_id,
target_host: "127.0.0.1".to_string(),
target_port: 8080,
weight: 1,
is_backup: false,
enabled: true,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![vec![ph_model.clone()]])
.append_query_results(vec![vec![loc_model.clone()]])
// additional query result for regenerate_and_apply_config -> generate_config
.append_query_results(vec![vec![(up_model.clone(), Some(target_model.clone()))]])
.into_connection();
let router = get_router_with_state(db.clone());
let server = TestServer::new(router).expect("failed to create test server");
let payload = CreateProxyRequestBody {
name: Some("myproxy".to_string()),
domain: "example.com".to_string(),
scheme: "http".to_string(),
listen_port: 80,
forward_scheme: "http".to_string(),
forward_host: None,
forward_port: None,
preserve_host_header: false,
enable_websocket: false,
enabled: true,
meta: None,
default_upstream_id: None,
locations: vec![ReqLocation {
path: "/".to_string(),
match_type: "prefix".to_string(),
order: 1,
upstream_id: None,
}],
};
let res = server.post("/proxy_hosts").json(&payload).await;
res.assert_status_ok();
let body: crate::routes::api::restricted::nginx::proxy_host::info::response::ProxyHostInfoResponse =
res.json();
assert_eq!(body.id, ph_id);
assert_eq!(body.domain, "example.com");
assert_eq!(body.locations.len(), 1);
assert_eq!(body.locations[0].id, loc_id);
}
#[tokio::test]
async fn handler_create_proxy_invalid_payload_returns_bad_request() {
let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection();
let router = get_router_with_state(db.clone());
let server = TestServer::new(router).expect("failed to create test server");
let res = server
.post("/proxy_hosts")
.json(&serde_json::json!({}))
.await;
res.assert_status(StatusCode::UNPROCESSABLE_ENTITY);
}
#[tokio::test]
async fn handler_create_proxy_unauthenticated_returns_unauthorized() {
let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection();
let router = get_router_with_state(db.clone());
let server = TestServer::new(router).expect("failed to create test server");
let payload = CreateProxyRequestBody {
name: Some("myproxy".to_string()),
domain: "example.com".to_string(),
scheme: "http".to_string(),
listen_port: 80,
forward_scheme: "http".to_string(),
forward_host: None,
forward_port: None,
preserve_host_header: false,
enable_websocket: false,
enabled: true,
meta: None,
default_upstream_id: None,
locations: vec![],
};
let res = server
.post("/proxy_hosts")
.add_header(REQUEST_AUTH_USER_INVALID_HEADER, "true")
.json(&payload)
.await;
res.assert_status(StatusCode::UNAUTHORIZED);
}
}
#[derive(serde::Deserialize, utoipa::ToSchema, serde::Serialize)]
pub struct CreateProxyRequestBody {
pub name: Option<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,157 @@
use std::sync::Arc;
use axum::{
Json,
extract::{Path, State},
response::Result as AxumResult,
};
use sea_orm::TransactionTrait;
use crate::{
errors::api_error::ApiError,
middlewares::request_info::AuthenticatedRequestInfo,
routes::{AppState, api::openapi::tag::NGINX_TAG},
};
#[utoipa::path(
delete,
path = "/api/nginx/locations/{location_id}",
responses(
(status = 200, description = "Location removed successfully", body = ()),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
(status = 500, description = "Internal server error"),
),
tag = NGINX_TAG,
)]
#[axum::debug_handler]
pub async fn remove_location(
_request_info: AuthenticatedRequestInfo,
Path(location_id): Path<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};
use database::generated::entities::{location, upstream, upstream_target};
use crate::{
configs::{FromConfig, ProgramSettings},
routes::api::restricted::nginx::proxy_host::get_proxy_router,
services::{agent_client::MockAgentService, get_mock_app_service},
};
fn get_router_with_state(db: DatabaseConnection) -> axum::Router {
let program_settings = ProgramSettings::mock();
let mut mock = MockAgentService::new();
mock.expect_validate().returning(|_cfg| Ok(()));
mock.expect_apply().returning(|_cfg| Ok(()));
let mock_agent = Arc::new(mock);
let app_service =
get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent);
let state = Arc::new(crate::routes::AppState {
database_connection: Arc::new(db),
service: Arc::new(app_service),
config: Arc::new(program_settings),
});
get_proxy_router(state).layer(axum::middleware::from_fn(
crate::middlewares::require_auth::mock::mock_require_auth,
))
}
#[tokio::test]
async fn handler_remove_location_succeeds_returns_ok() {
let loc_id = uuid::Uuid::new_v4();
let existing = location::Model {
id: loc_id,
host_id: uuid::Uuid::new_v4(),
path: "/".to_string(),
match_type: "prefix".to_string(),
order: 1,
upstream_id: None,
proxy_pass_protocol: None,
proxy_pass_host: None,
proxy_pass_port: None,
preserve_host_header: None,
allowed_methods: None,
custom_config: None,
enabled: true,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let up_id = uuid::Uuid::new_v4();
let up_model = upstream::Model {
id: up_id,
name: "u1".to_string(),
protocol: "http".to_string(),
algorithm: "rr".to_string(),
sticky_session: false,
created_by: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let target_model = upstream_target::Model {
id: uuid::Uuid::new_v4(),
upstream_id: up_id,
target_host: "127.0.0.1".to_string(),
target_port: 8080,
weight: 1,
is_backup: false,
enabled: true,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![vec![existing.clone()]])
.append_exec_results(vec![sea_orm::MockExecResult {
rows_affected: 1,
last_insert_id: 0,
}])
// additional query result for regenerate_and_apply_config -> generate_config
.append_query_results(vec![vec![(up_model.clone(), Some(target_model.clone()))]])
.into_connection();
let router = get_router_with_state(db.clone());
let server = TestServer::new(router).expect("failed to create test server");
let res = server.delete(&format!("/locations/{}", loc_id)).await;
res.assert_status_ok();
}
#[tokio::test]
async fn handler_remove_location_not_found_returns_not_found() {
let empty_results: Vec<Vec<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,180 @@
use std::sync::Arc;
use axum::{
Json,
extract::{Path, State},
response::Result as AxumResult,
};
use sea_orm::TransactionTrait;
use uuid::Uuid;
use crate::{
errors::api_error::ApiError,
middlewares::request_info::AuthenticatedRequestInfo,
routes::{AppState, api::openapi::tag::NGINX_TAG},
};
#[axum::debug_handler]
#[utoipa::path(
delete,
path = "/api/nginx/proxy_hosts/{proxy_id}",
responses(
(status = 200, description = "Proxy removed successfully", body = ()),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
(status = 500, description = "Internal server error"),
),
tag = NGINX_TAG,
)]
pub async fn remove_proxy(
_request_info: AuthenticatedRequestInfo,
Path(proxy_id): Path<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};
use database::generated::entities::{proxy_host, upstream, upstream_target};
use crate::{
configs::{FromConfig, ProgramSettings},
middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER,
routes::api::restricted::nginx::proxy_host::get_proxy_router,
services::{agent_client::MockAgentService, get_mock_app_service},
};
fn get_router_with_state(db: DatabaseConnection) -> axum::Router {
let program_settings = ProgramSettings::mock();
let mut mock = MockAgentService::new();
mock.expect_validate().returning(|_cfg| Ok(()));
mock.expect_apply().returning(|_cfg| Ok(()));
let mock_agent = Arc::new(mock);
let app_service =
get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent);
let state = Arc::new(crate::routes::AppState {
database_connection: Arc::new(db),
service: Arc::new(app_service),
config: Arc::new(program_settings),
});
get_proxy_router(state).layer(axum::middleware::from_fn(
crate::middlewares::require_auth::mock::mock_require_auth,
))
}
#[tokio::test]
async fn handler_remove_proxy_succeeds_returns_ok() {
let ph_id = uuid::Uuid::new_v4();
let existing = proxy_host::Model {
id: ph_id,
name: Some("todelete".to_string()),
domain: "d.com".to_string(),
scheme: "http".to_string(),
listen_port: 80,
forward_scheme: "http".to_string(),
forward_host: None,
forward_port: None,
preserve_host_header: false,
enable_websocket: false,
enabled: true,
meta: None,
default_upstream_id: None,
created_by: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let up_id = uuid::Uuid::new_v4();
let up_model = upstream::Model {
id: up_id,
name: "u1".to_string(),
protocol: "http".to_string(),
algorithm: "rr".to_string(),
sticky_session: false,
created_by: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let target_model = upstream_target::Model {
id: uuid::Uuid::new_v4(),
upstream_id: up_id,
target_host: "127.0.0.1".to_string(),
target_port: 8080,
weight: 1,
is_backup: false,
enabled: true,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![vec![existing.clone()]])
.append_exec_results(vec![
MockExecResult {
rows_affected: 1,
last_insert_id: 0,
},
MockExecResult {
rows_affected: 1,
last_insert_id: 0,
},
])
// additional query result for regenerate_and_apply_config -> generate_config
.append_query_results(vec![vec![(up_model.clone(), Some(target_model.clone()))]])
.into_connection();
let router = get_router_with_state(db.clone());
let server = TestServer::new(router).expect("failed to create test server");
let res = server.delete(&format!("/proxy_hosts/{}", ph_id)).await;
res.assert_status_ok();
}
#[tokio::test]
async fn handler_remove_proxy_unauthenticated_returns_unauthorized() {
let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection();
let router = get_router_with_state(db.clone());
let server = TestServer::new(router).expect("failed to create test server");
let res = server
.delete(&format!("/proxy_hosts/{}", uuid::Uuid::new_v4()))
.add_header(REQUEST_AUTH_USER_INVALID_HEADER, "true")
.await;
res.assert_status(StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn handler_remove_proxy_not_found_returns_not_found() {
let empty_results: Vec<Vec<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,218 @@
use std::sync::Arc;
use axum::{
Json,
extract::{Path, State},
response::Result as AxumResult,
};
use sea_orm::TransactionTrait;
use crate::{
errors::api_error::ApiError,
middlewares::request_info::AuthenticatedRequestInfo,
routes::{
AppState,
api::{
openapi::tag::NGINX_TAG,
restricted::nginx::proxy_host::info::response::LocationInfoResponse,
},
},
services::nginx::info::location::UpdateLocationInfo,
};
#[derive(serde::Deserialize, utoipa::ToSchema, serde::Serialize)]
pub struct UpdateLocationRequestBody {
pub path: Option<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};
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 db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(first)
.append_query_results(second)
// additional query result for regenerate_and_apply_config -> generate_config
.append_query_results(vec![vec![(up_model.clone(), Some(target_model.clone()))]])
.into_connection();
let router = get_router_with_state(db.clone());
let server = TestServer::new(router).expect("failed to create test server");
let payload = UpdateLocationRequestBody {
path: Some("/new".to_string()),
match_type: None,
order: None,
upstream_id: None,
};
let res = server
.patch(&format!("/locations/{}", loc_id))
.json(&payload)
.await;
res.assert_status_ok();
let body = res.json::<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,225 @@
use std::sync::Arc;
use axum::{
Json,
extract::{Path, State},
response::Result as AxumResult,
};
use sea_orm::TransactionTrait;
use crate::{
errors::api_error::ApiError,
middlewares::request_info::AuthenticatedRequestInfo,
routes::{AppState, api::restricted::nginx::proxy_host::info::response::ProxyHostInfoResponse},
services::nginx::info::proxy_host::UpdateProxyHostInfo,
};
#[derive(serde::Deserialize, utoipa::ToSchema, serde::Serialize)]
pub struct UpdateProxyRequestBody {
pub name: Option<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};
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 db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(first)
.append_query_results(second)
// additional query result for regenerate_and_apply_config -> generate_config
.append_query_results(vec![vec![(up_model.clone(), Some(target_model.clone()))]])
.into_connection();
let router = get_router_with_state(db.clone());
let server = TestServer::new(router).expect("failed to create test server");
let payload = UpdateProxyRequestBody {
name: Some(Some("newname".to_string())),
domain: Some("a.com".to_string()),
};
let res = server
.patch(&format!("/proxy_hosts/{}", ph_id))
.json(&payload)
.await;
res.assert_status_ok();
let body: crate::routes::api::restricted::nginx::proxy_host::info::response::ProxyHostInfoResponse = res.json();
assert_eq!(body.id, ph_id);
}
#[tokio::test]
async fn handler_update_proxy_unauthenticated_returns_unauthorized() {
let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection();
let router = get_router_with_state(db.clone());
let server = TestServer::new(router).expect("failed to create test server");
let payload = UpdateProxyRequestBody {
name: Some(Some("newname".to_string())),
domain: None,
};
let res = server
.patch(&format!("/proxy_hosts/{}", uuid::Uuid::new_v4()))
.add_header(REQUEST_AUTH_USER_INVALID_HEADER, "true")
.json(&payload)
.await;
res.assert_status(StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn handler_update_proxy_not_found_returns_not_found() {
let empty_results: Vec<Vec<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

@@ -106,6 +106,365 @@
}
}
},
"/api/nginx/locations/{location_id}": {
"get": {
"tags": [
"Nginx"
],
"operationId": "get_location",
"parameters": [
{
"name": "location_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"200": {
"description": "Get location info",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LocationInfoResponse"
}
}
}
},
"404": {
"description": "Not found"
},
"500": {
"description": "Internal server error"
}
}
},
"delete": {
"tags": [
"Nginx"
],
"operationId": "remove_location",
"parameters": [
{
"name": "location_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"200": {
"description": "Location removed successfully",
"content": {
"application/json": {
"schema": {
"default": null
}
}
}
},
"401": {
"description": "Unauthorized"
},
"404": {
"description": "Not found"
},
"500": {
"description": "Internal server error"
}
}
},
"patch": {
"tags": [
"Nginx"
],
"operationId": "update_location",
"parameters": [
{
"name": "location_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateLocationRequestBody"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Location updated successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LocationInfoResponse"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"404": {
"description": "Not found"
},
"422": {
"description": "Invalid request"
},
"500": {
"description": "Internal server error"
}
}
}
},
"/api/nginx/proxy_hosts": {
"get": {
"tags": [
"Nginx"
],
"operationId": "get_proxy_list",
"responses": {
"200": {
"description": "List proxies",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProxyListResponse"
}
}
}
},
"500": {
"description": "Internal server error"
}
}
},
"post": {
"tags": [
"Nginx"
],
"operationId": "create_proxy",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateProxyRequestBody"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Proxy created successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProxyHostInfoResponse"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"422": {
"description": "Invalid request"
},
"500": {
"description": "Internal server error"
}
}
}
},
"/api/nginx/proxy_hosts/{proxy_id}": {
"get": {
"tags": [
"Nginx"
],
"operationId": "get_proxy",
"parameters": [
{
"name": "proxy_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"200": {
"description": "Get proxy info",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProxyHostInfoResponse"
}
}
}
},
"404": {
"description": "Not found"
},
"500": {
"description": "Internal server error"
}
}
},
"delete": {
"tags": [
"Nginx"
],
"operationId": "remove_proxy",
"parameters": [
{
"name": "proxy_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"200": {
"description": "Proxy removed successfully",
"content": {
"application/json": {
"schema": {
"default": null
}
}
}
},
"401": {
"description": "Unauthorized"
},
"404": {
"description": "Not found"
},
"500": {
"description": "Internal server error"
}
}
},
"patch": {
"tags": [
"crate::routes::api::restricted::nginx::proxy_host::update_proxy"
],
"operationId": "update_proxy",
"parameters": [
{
"name": "proxy_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateProxyRequestBody"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Proxy updated successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProxyHostInfoResponse"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"422": {
"description": "Invalid request"
},
"500": {
"description": "Internal server error"
}
}
}
},
"/api/nginx/proxy_hosts/{proxy_id}/locations": {
"post": {
"tags": [
"Nginx"
],
"operationId": "create_location",
"parameters": [
{
"name": "proxy_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateLocationRequestBody"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Location created",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LocationInfoResponse"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"422": {
"description": "Invalid request"
},
"500": {
"description": "Internal server error"
}
}
}
},
"/api/nginx/upstream_targets/{upstream_target_id}": {
"get": {
"tags": [
@@ -508,6 +867,130 @@
}
}
},
"CreateLocationReq": {
"type": "object",
"required": [
"path",
"match_type",
"order"
],
"properties": {
"match_type": {
"type": "string"
},
"order": {
"type": "integer",
"format": "int64"
},
"path": {
"type": "string"
},
"upstream_id": {
"type": [
"string",
"null"
],
"format": "uuid"
}
}
},
"CreateLocationRequestBody": {
"type": "object",
"required": [
"path",
"match_type",
"order"
],
"properties": {
"match_type": {
"type": "string"
},
"order": {
"type": "integer",
"format": "int64"
},
"path": {
"type": "string"
},
"upstream_id": {
"type": [
"string",
"null"
],
"format": "uuid"
}
}
},
"CreateProxyRequestBody": {
"type": "object",
"required": [
"domain",
"scheme",
"listen_port",
"forward_scheme",
"preserve_host_header",
"enable_websocket",
"enabled",
"locations"
],
"properties": {
"default_upstream_id": {
"type": [
"string",
"null"
],
"format": "uuid"
},
"domain": {
"type": "string"
},
"enable_websocket": {
"type": "boolean"
},
"enabled": {
"type": "boolean"
},
"forward_host": {
"type": [
"string",
"null"
]
},
"forward_port": {
"type": [
"integer",
"null"
],
"format": "int64"
},
"forward_scheme": {
"type": "string"
},
"listen_port": {
"type": "integer",
"format": "int64"
},
"locations": {
"type": "array",
"items": {
"$ref": "#/components/schemas/CreateLocationReq"
}
},
"meta": {},
"name": {
"type": [
"string",
"null"
]
},
"preserve_host_header": {
"type": "boolean"
},
"scheme": {
"type": "string"
}
}
},
"CreateUpstreamRequestBody": {
"type": "object",
"required": [
@@ -643,6 +1126,57 @@
}
}
},
"LocationInfoResponse": {
"type": "object",
"required": [
"id",
"host_id",
"path",
"match_type",
"order",
"enabled",
"created_at",
"updated_at"
],
"properties": {
"created_at": {
"type": "string",
"format": "date-time"
},
"enabled": {
"type": "boolean"
},
"host_id": {
"type": "string",
"format": "uuid"
},
"id": {
"type": "string",
"format": "uuid"
},
"match_type": {
"type": "string"
},
"order": {
"type": "integer",
"format": "int64"
},
"path": {
"type": "string"
},
"updated_at": {
"type": "string",
"format": "date-time"
},
"upstream_id": {
"type": [
"string",
"null"
],
"format": "uuid"
}
}
},
"LoginRequest": {
"type": "object",
"description": "Login request payload",
@@ -695,6 +1229,179 @@
}
}
},
"ProxyHostInfoResponse": {
"type": "object",
"required": [
"id",
"domain",
"scheme",
"listen_port",
"forward_scheme",
"preserve_host_header",
"enable_websocket",
"enabled",
"created_at",
"updated_at",
"locations"
],
"properties": {
"created_at": {
"type": "string",
"format": "date-time"
},
"domain": {
"type": "string"
},
"enable_websocket": {
"type": "boolean"
},
"enabled": {
"type": "boolean"
},
"forward_host": {
"type": [
"string",
"null"
]
},
"forward_port": {
"type": [
"integer",
"null"
],
"format": "int64"
},
"forward_scheme": {
"type": "string"
},
"id": {
"type": "string",
"format": "uuid"
},
"listen_port": {
"type": "integer",
"format": "int64"
},
"locations": {
"type": "array",
"items": {
"$ref": "#/components/schemas/LocationInfoResponse"
}
},
"name": {
"type": [
"string",
"null"
]
},
"preserve_host_header": {
"type": "boolean"
},
"scheme": {
"type": "string"
},
"updated_at": {
"type": "string",
"format": "date-time"
},
"upstream": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/ProxyHostUpstreamBasic"
}
]
}
}
},
"ProxyHostUpstreamBasic": {
"type": "object",
"required": [
"id",
"name",
"protocol"
],
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"protocol": {
"type": "string"
}
}
},
"ProxyListResponse": {
"type": "object",
"required": [
"items",
"pagination"
],
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ProxyHostInfoResponse"
}
},
"pagination": {
"$ref": "#/components/schemas/PaginationInfo"
}
}
},
"UpdateLocationRequestBody": {
"type": "object",
"properties": {
"match_type": {
"type": [
"string",
"null"
]
},
"order": {
"type": [
"integer",
"null"
],
"format": "int64"
},
"path": {
"type": [
"string",
"null"
]
},
"upstream_id": {
"type": [
"string",
"null"
],
"format": "uuid"
}
}
},
"UpdateProxyRequestBody": {
"type": "object",
"properties": {
"domain": {
"type": [
"string",
"null"
]
},
"name": {
"type": [
"string",
"null"
]
}
}
},
"UpdateUpstreamInfoResponse": {
"type": "object",
"required": [