From 9b8232d94d792ca90b398ede2ba4e6d6855ca007 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Wed, 7 Jan 2026 15:58:21 +0800 Subject: [PATCH] feat: Implement NGINX proxy host and location management endpoints - Add `get_location` endpoint to retrieve location information with optional upstream inclusion. - Introduce `get_proxy_list` and `get_proxy` endpoints for listing and retrieving proxy hosts. - Implement `remove_location` and `remove_proxy` endpoints for deleting locations and proxy hosts respectively. - Add `update_location` and `update_proxy` endpoints for modifying existing locations and proxy hosts. - Create response structures for location and proxy host information. - Implement tests for all new endpoints to ensure correct functionality and error handling. --- apps/api/src/routes/api/openapi.rs | 20 + apps/api/src/routes/api/restricted/nginx.rs | 5 +- .../routes/api/restricted/nginx/proxy_host.rs | 43 ++ .../nginx/proxy_host/create_location.rs | 221 ++++++ .../nginx/proxy_host/create_proxy.rs | 308 ++++++++ .../nginx/proxy_host/get_location.rs | 150 ++++ .../restricted/nginx/proxy_host/get_proxy.rs | 281 +++++++ .../restricted/nginx/proxy_host/info/mod.rs | 1 + .../nginx/proxy_host/info/response.rs | 91 +++ .../nginx/proxy_host/remove_location.rs | 157 ++++ .../nginx/proxy_host/remove_proxy.rs | 180 +++++ .../nginx/proxy_host/update_location.rs | 218 ++++++ .../nginx/proxy_host/update_proxy.rs | 225 ++++++ apps/api/swagger.json | 707 ++++++++++++++++++ 14 files changed, 2606 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/routes/api/restricted/nginx/proxy_host.rs create mode 100644 apps/api/src/routes/api/restricted/nginx/proxy_host/create_location.rs create mode 100644 apps/api/src/routes/api/restricted/nginx/proxy_host/create_proxy.rs create mode 100644 apps/api/src/routes/api/restricted/nginx/proxy_host/get_location.rs create mode 100644 apps/api/src/routes/api/restricted/nginx/proxy_host/get_proxy.rs create mode 100644 apps/api/src/routes/api/restricted/nginx/proxy_host/info/mod.rs create mode 100644 apps/api/src/routes/api/restricted/nginx/proxy_host/info/response.rs create mode 100644 apps/api/src/routes/api/restricted/nginx/proxy_host/remove_location.rs create mode 100644 apps/api/src/routes/api/restricted/nginx/proxy_host/remove_proxy.rs create mode 100644 apps/api/src/routes/api/restricted/nginx/proxy_host/update_location.rs create mode 100644 apps/api/src/routes/api/restricted/nginx/proxy_host/update_proxy.rs diff --git a/apps/api/src/routes/api/openapi.rs b/apps/api/src/routes/api/openapi.rs index cc855e3..8bf5873 100644 --- a/apps/api/src/routes/api/openapi.rs +++ b/apps/api/src/routes/api/openapi.rs @@ -25,6 +25,17 @@ pub mod tag { crate::routes::api::restricted::nginx::upstream::update_upstream_target::update_upstream_target, crate::routes::api::restricted::nginx::upstream::remove_upstream::remove_upstream, crate::routes::api::restricted::nginx::upstream::remove_upstream_target::remove_upstream_target, + // Nginx proxy host management + crate::routes::api::restricted::nginx::proxy_host::get_proxy::get_proxy_list, + crate::routes::api::restricted::nginx::proxy_host::get_proxy::get_proxy, + crate::routes::api::restricted::nginx::proxy_host::create_proxy::create_proxy, + crate::routes::api::restricted::nginx::proxy_host::update_proxy::update_proxy, + crate::routes::api::restricted::nginx::proxy_host::remove_proxy::remove_proxy, + // Proxy host locations + crate::routes::api::restricted::nginx::proxy_host::create_location::create_location, + crate::routes::api::restricted::nginx::proxy_host::get_location::get_location, + crate::routes::api::restricted::nginx::proxy_host::update_location::update_location, + crate::routes::api::restricted::nginx::proxy_host::remove_location::remove_location, ), components( schemas(crate::routes::api::health::info::HealthInfo), @@ -46,6 +57,15 @@ pub mod tag { schemas(crate::routes::api::restricted::nginx::upstream::update_upstream_target::UpdateUpstreamTargetRequestBody), schemas(crate::routes::api::restricted::nginx::upstream::info::response::UpdateUpstreamInfoResponse), schemas(crate::routes::api::restricted::nginx::upstream::info::response::UpdateUpstreamTargetInfoResponse), + // Nginx proxy host schemas + schemas(crate::routes::api::restricted::nginx::proxy_host::create_proxy::CreateLocationReq), + schemas(crate::routes::api::restricted::nginx::proxy_host::create_proxy::CreateProxyRequestBody), + schemas(crate::routes::api::restricted::nginx::proxy_host::create_location::CreateLocationRequestBody), + schemas(crate::routes::api::restricted::nginx::proxy_host::update_proxy::UpdateProxyRequestBody), + schemas(crate::routes::api::restricted::nginx::proxy_host::update_location::UpdateLocationRequestBody), + schemas(crate::routes::api::restricted::nginx::proxy_host::info::response::LocationInfoResponse), + schemas(crate::routes::api::restricted::nginx::proxy_host::info::response::ProxyHostInfoResponse), + schemas(crate::routes::api::restricted::nginx::proxy_host::info::response::ProxyListResponse), ), tags( (name = tag::HEALTH_TAG, description = "Health information API"), diff --git a/apps/api/src/routes/api/restricted/nginx.rs b/apps/api/src/routes/api/restricted/nginx.rs index 50803cf..613732f 100644 --- a/apps/api/src/routes/api/restricted/nginx.rs +++ b/apps/api/src/routes/api/restricted/nginx.rs @@ -1,3 +1,4 @@ +pub mod proxy_host; pub mod upstream; use std::sync::Arc; @@ -7,5 +8,7 @@ use axum::Router; use crate::routes::AppState; pub fn get_nginx_router(state: Arc) -> Router { - Router::new().merge(upstream::get_upstream_router(state.clone())) + Router::new() + .merge(proxy_host::get_proxy_router(state.clone())) + .merge(upstream::get_upstream_router(state.clone())) } diff --git a/apps/api/src/routes/api/restricted/nginx/proxy_host.rs b/apps/api/src/routes/api/restricted/nginx/proxy_host.rs new file mode 100644 index 0000000..f8a42f6 --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/proxy_host.rs @@ -0,0 +1,43 @@ +pub mod create_location; +pub mod create_proxy; +pub mod get_location; +pub mod get_proxy; +pub mod info; +pub mod remove_location; +pub mod remove_proxy; +pub mod update_location; +pub mod update_proxy; + +use std::sync::Arc; + +use axum::{ + Router, + routing::{get, post}, +}; + +use crate::routes::AppState; + +pub fn get_proxy_router(state: Arc) -> Router { + Router::new() + .route( + "/proxy_hosts", + get(get_proxy::get_proxy_list).post(create_proxy::create_proxy), + ) + .route( + "/proxy_hosts/{proxy_id}", + get(get_proxy::get_proxy) + .patch(update_proxy::update_proxy) + .delete(remove_proxy::remove_proxy), + ) + .route( + "/proxy_hosts/{proxy_id}/locations", + post(create_location::create_location), + ) + .route( + "/locations/{location_id}", + get(get_location::get_location) + .patch(update_location::update_location) + .delete(remove_location::remove_location), + ) + .with_state(state) +} diff --git a/apps/api/src/routes/api/restricted/nginx/proxy_host/create_location.rs b/apps/api/src/routes/api/restricted/nginx/proxy_host/create_location.rs new file mode 100644 index 0000000..f184c27 --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/proxy_host/create_location.rs @@ -0,0 +1,221 @@ +use std::sync::Arc; + +use axum::{Json, extract::State, response::Result as AxumResult}; +use sea_orm::TransactionTrait; + +use crate::{ + errors::api_error::ApiError, + middlewares::request_info::AuthenticatedRequestInfo, + routes::{ + AppState, + api::{ + openapi::tag::NGINX_TAG, + restricted::nginx::proxy_host::info::response::LocationInfoResponse, + }, + }, + services::nginx::info::location::CreateLocationInfo, +}; + +#[derive(serde::Deserialize, utoipa::ToSchema, serde::Serialize)] +pub struct CreateLocationRequestBody { + pub path: String, + pub match_type: String, + pub order: i64, + pub upstream_id: Option, +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use axum::http::StatusCode; + use axum_test::TestServer; + use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase}; + + use database::generated::entities::{location, upstream, upstream_target}; + + use crate::{ + configs::{FromConfig, ProgramSettings}, + middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER, + routes::api::restricted::nginx::proxy_host::create_location::CreateLocationRequestBody, + routes::api::restricted::nginx::proxy_host::get_proxy_router, + services::{agent_client::MockAgentService, get_mock_app_service}, + }; + + fn get_router_with_state(db: DatabaseConnection) -> axum::Router { + let program_settings = ProgramSettings::mock(); + let mut mock = MockAgentService::new(); + mock.expect_validate().returning(|_cfg| Ok(())); + mock.expect_apply().returning(|_cfg| Ok(())); + let mock_agent = Arc::new(mock); + let app_service = + get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent); + let state = Arc::new(crate::routes::AppState { + database_connection: Arc::new(db), + service: Arc::new(app_service), + config: Arc::new(program_settings), + }); + get_proxy_router(state).layer(axum::middleware::from_fn( + crate::middlewares::require_auth::mock::mock_require_auth, + )) + } + + #[tokio::test] + async fn handler_create_location_succeeds_returns_created() { + let ph_id = uuid::Uuid::new_v4(); + let loc_id = uuid::Uuid::new_v4(); + + let loc_model = location::Model { + id: loc_id, + host_id: ph_id, + path: "/".to_string(), + match_type: "prefix".to_string(), + order: 1, + upstream_id: None, + proxy_pass_protocol: None, + proxy_pass_host: None, + proxy_pass_port: None, + preserve_host_header: None, + allowed_methods: None, + custom_config: None, + enabled: true, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let up_id = uuid::Uuid::new_v4(); + let up_model = upstream::Model { + id: up_id, + name: "u1".to_string(), + protocol: "http".to_string(), + algorithm: "rr".to_string(), + sticky_session: false, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + let target_model = upstream_target::Model { + id: uuid::Uuid::new_v4(), + upstream_id: up_id, + target_host: "127.0.0.1".to_string(), + target_port: 8080, + weight: 1, + is_backup: false, + enabled: true, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![vec![loc_model.clone()]]) + // additional query result for regenerate_and_apply_config -> generate_config + .append_query_results(vec![vec![(up_model.clone(), Some(target_model.clone()))]]) + .into_connection(); + + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let payload = CreateLocationRequestBody { + path: "/".to_string(), + match_type: "prefix".to_string(), + order: 1, + upstream_id: None, + }; + + let res = server + .post(&format!("/proxy_hosts/{}/locations", ph_id)) + .json(&payload) + .await; + res.assert_status_ok(); + let body = res.json::(); + assert_eq!(body.id, loc_id); + } + + #[tokio::test] + async fn handler_create_location_invalid_payload_returns_bad_request() { + let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection(); + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let res = server + .post(&format!("/proxy_hosts/{}/locations", uuid::Uuid::new_v4())) + .json(&serde_json::json!({})) + .await; + res.assert_status(StatusCode::UNPROCESSABLE_ENTITY); + } + + #[tokio::test] + async fn handler_create_location_unauthenticated_returns_unauthorized() { + let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection(); + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let payload = CreateLocationRequestBody { + path: "/".to_string(), + match_type: "prefix".to_string(), + order: 1, + upstream_id: None, + }; + + let res = server + .post(&format!("/proxy_hosts/{}/locations", uuid::Uuid::new_v4())) + .add_header(REQUEST_AUTH_USER_INVALID_HEADER, "true") + .json(&payload) + .await; + + res.assert_status(StatusCode::UNAUTHORIZED); + } +} + +impl From<(uuid::Uuid, CreateLocationRequestBody)> for CreateLocationInfo { + fn from(val: (uuid::Uuid, CreateLocationRequestBody)) -> Self { + Self { + host_id: val.0, + path: val.1.path, + match_type: val.1.match_type, + order: val.1.order, + upstream_id: val.1.upstream_id, + proxy_pass_protocol: None, + proxy_pass_host: None, + proxy_pass_port: None, + preserve_host_header: None, + allowed_methods: None, + custom_config: None, + enabled: true, + } + } +} + +#[axum::debug_handler] +#[utoipa::path( + post, + path = "/api/nginx/proxy_hosts/{proxy_id}/locations", + request_body = CreateLocationRequestBody, + responses( + (status = 200, description = "Location created", body = LocationInfoResponse), + (status = 401, description = "Unauthorized"), + (status = 422, description = "Invalid request"), + (status = 500, description = "Internal server error"), + ), + tag = NGINX_TAG, +)] +pub async fn create_location( + _request_info: AuthenticatedRequestInfo, + axum::extract::Path(proxy_id): axum::extract::Path, + State(state): State>, + Json(payload): Json, +) -> AxumResult, ApiError> { + let svc = &state.service.nginx.get_location_service(); + let create_info: CreateLocationInfo = (proxy_id, payload).into(); + + let mut tx = state.database_connection.begin().await?; + let info = svc.create_location(create_info, Some(&mut tx)).await?; + state + .service + .nginx + .regenerate_and_apply_config(state.service.agent_client.clone(), Some(&mut tx)) + .await?; + tx.commit().await?; + + Ok(Json(info.into())) +} diff --git a/apps/api/src/routes/api/restricted/nginx/proxy_host/create_proxy.rs b/apps/api/src/routes/api/restricted/nginx/proxy_host/create_proxy.rs new file mode 100644 index 0000000..da1ad9e --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/proxy_host/create_proxy.rs @@ -0,0 +1,308 @@ +use std::sync::Arc; + +use axum::{Json, extract::State, response::Result as AxumResult}; +use sea_orm::TransactionTrait; + +use crate::{ + errors::api_error::ApiError, + middlewares::request_info::AuthenticatedRequestInfo, + routes::{ + AppState, + api::{ + openapi::tag::NGINX_TAG, + restricted::nginx::proxy_host::info::response::ProxyHostInfoResponse, + }, + }, + services::nginx::info::proxy_host::ProxyHostCreateInfo, +}; + +#[derive(serde::Deserialize, utoipa::ToSchema, serde::Serialize)] +pub struct CreateLocationReq { + pub path: String, + pub match_type: String, + pub order: i64, + pub upstream_id: Option, +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use axum::http::StatusCode; + use axum_test::TestServer; + use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase}; + + use database::generated::entities::{location, proxy_host, upstream, upstream_target}; + + use crate::{ + configs::{FromConfig, ProgramSettings}, + middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER, + routes::api::restricted::nginx::proxy_host::create_proxy::CreateLocationReq as ReqLocation, + routes::api::restricted::nginx::proxy_host::create_proxy::CreateProxyRequestBody, + routes::api::restricted::nginx::proxy_host::get_proxy_router, + services::{agent_client::MockAgentService, get_mock_app_service}, + }; + + fn get_router_with_state(db: DatabaseConnection) -> axum::Router { + let program_settings = ProgramSettings::mock(); + let mut mock = MockAgentService::new(); + mock.expect_validate().returning(|_cfg| Ok(())); + mock.expect_apply().returning(|_cfg| Ok(())); + let mock_agent = Arc::new(mock); + let app_service = + get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent); + let state = Arc::new(crate::routes::AppState { + database_connection: Arc::new(db), + service: Arc::new(app_service), + config: Arc::new(program_settings), + }); + get_proxy_router(state).layer(axum::middleware::from_fn( + crate::middlewares::require_auth::mock::mock_require_auth, + )) + } + + #[tokio::test] + async fn handler_create_proxy_succeeds_returns_created() { + let ph_id = uuid::Uuid::new_v4(); + + let ph_model = proxy_host::Model { + id: ph_id, + name: Some("myproxy".to_string()), + domain: "example.com".to_string(), + scheme: "http".to_string(), + listen_port: 80, + forward_scheme: "http".to_string(), + forward_host: None, + forward_port: None, + preserve_host_header: false, + enable_websocket: false, + enabled: true, + meta: None, + default_upstream_id: None, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let loc_id = uuid::Uuid::new_v4(); + let loc_model = location::Model { + id: loc_id, + host_id: ph_id, + path: "/".to_string(), + match_type: "prefix".to_string(), + order: 1, + upstream_id: None, + proxy_pass_protocol: None, + proxy_pass_host: None, + proxy_pass_port: None, + preserve_host_header: None, + allowed_methods: None, + custom_config: None, + enabled: true, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let up_id = uuid::Uuid::new_v4(); + let up_model = upstream::Model { + id: up_id, + name: "u1".to_string(), + protocol: "http".to_string(), + algorithm: "rr".to_string(), + sticky_session: false, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + let target_model = upstream_target::Model { + id: uuid::Uuid::new_v4(), + upstream_id: up_id, + target_host: "127.0.0.1".to_string(), + target_port: 8080, + weight: 1, + is_backup: false, + enabled: true, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![vec![ph_model.clone()]]) + .append_query_results(vec![vec![loc_model.clone()]]) + // additional query result for regenerate_and_apply_config -> generate_config + .append_query_results(vec![vec![(up_model.clone(), Some(target_model.clone()))]]) + .into_connection(); + + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let payload = CreateProxyRequestBody { + name: Some("myproxy".to_string()), + domain: "example.com".to_string(), + scheme: "http".to_string(), + listen_port: 80, + forward_scheme: "http".to_string(), + forward_host: None, + forward_port: None, + preserve_host_header: false, + enable_websocket: false, + enabled: true, + meta: None, + default_upstream_id: None, + locations: vec![ReqLocation { + path: "/".to_string(), + match_type: "prefix".to_string(), + order: 1, + upstream_id: None, + }], + }; + + let res = server.post("/proxy_hosts").json(&payload).await; + res.assert_status_ok(); + let body: crate::routes::api::restricted::nginx::proxy_host::info::response::ProxyHostInfoResponse = + res.json(); + assert_eq!(body.id, ph_id); + assert_eq!(body.domain, "example.com"); + assert_eq!(body.locations.len(), 1); + assert_eq!(body.locations[0].id, loc_id); + } + + #[tokio::test] + async fn handler_create_proxy_invalid_payload_returns_bad_request() { + let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection(); + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let res = server + .post("/proxy_hosts") + .json(&serde_json::json!({})) + .await; + res.assert_status(StatusCode::UNPROCESSABLE_ENTITY); + } + + #[tokio::test] + async fn handler_create_proxy_unauthenticated_returns_unauthorized() { + let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection(); + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let payload = CreateProxyRequestBody { + name: Some("myproxy".to_string()), + domain: "example.com".to_string(), + scheme: "http".to_string(), + listen_port: 80, + forward_scheme: "http".to_string(), + forward_host: None, + forward_port: None, + preserve_host_header: false, + enable_websocket: false, + enabled: true, + meta: None, + default_upstream_id: None, + locations: vec![], + }; + + let res = server + .post("/proxy_hosts") + .add_header(REQUEST_AUTH_USER_INVALID_HEADER, "true") + .json(&payload) + .await; + + res.assert_status(StatusCode::UNAUTHORIZED); + } +} + +#[derive(serde::Deserialize, utoipa::ToSchema, serde::Serialize)] +pub struct CreateProxyRequestBody { + pub name: Option, + pub domain: String, + pub scheme: String, + pub listen_port: i64, + pub forward_scheme: String, + pub forward_host: Option, + pub forward_port: Option, + pub preserve_host_header: bool, + pub enable_websocket: bool, + pub enabled: bool, + pub meta: Option, + pub default_upstream_id: Option, + pub locations: Vec, +} + +impl From for ProxyHostCreateInfo { + fn from(val: CreateProxyRequestBody) -> Self { + Self { + name: val.name, + domain: val.domain, + scheme: val.scheme, + listen_port: val.listen_port, + forward_scheme: val.forward_scheme, + forward_host: val.forward_host, + forward_port: val.forward_port, + preserve_host_header: val.preserve_host_header, + enable_websocket: val.enable_websocket, + enabled: val.enabled, + meta: val.meta, + default_upstream_id: val.default_upstream_id, + created_by: None, + locations: val + .locations + .into_iter() + .map( + |l| crate::services::nginx::info::location::CreateLocationInfo { + host_id: uuid::Uuid::nil(), + path: l.path, + match_type: l.match_type, + order: l.order, + upstream_id: l.upstream_id, + proxy_pass_protocol: None, + proxy_pass_host: None, + proxy_pass_port: None, + preserve_host_header: None, + allowed_methods: None, + custom_config: None, + enabled: true, + }, + ) + .collect(), + } + } +} + +#[axum::debug_handler] +#[utoipa::path( + post, + path = "/api/nginx/proxy_hosts", + request_body = CreateProxyRequestBody, + responses( + (status = 200, description = "Proxy created successfully", body = ProxyHostInfoResponse), + (status = 401, description = "Unauthorized"), + (status = 422, description = "Invalid request"), + (status = 500, description = "Internal server error"), + ), + tag = NGINX_TAG, +)] +pub async fn create_proxy( + request_info: AuthenticatedRequestInfo, + State(state): State>, + Json(payload): Json, +) -> AxumResult, ApiError> { + let proxy_service = &state.service.nginx.get_proxy_service(); + let mut create_info: ProxyHostCreateInfo = payload.into(); + create_info.created_by = Some(request_info.user_id); + + let mut tx = state.database_connection.begin().await?; + let info = proxy_service + .create_proxy(create_info, Some(&mut tx)) + .await?; + + state + .service + .nginx + .regenerate_and_apply_config(state.service.agent_client.clone(), Some(&mut tx)) + .await?; + + tx.commit().await?; + + Ok(Json(info.into())) +} diff --git a/apps/api/src/routes/api/restricted/nginx/proxy_host/get_location.rs b/apps/api/src/routes/api/restricted/nginx/proxy_host/get_location.rs new file mode 100644 index 0000000..392a333 --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/proxy_host/get_location.rs @@ -0,0 +1,150 @@ +use std::sync::Arc; + +use crate::services::nginx::location::GetLocationOptions; +use axum::{ + Json, + extract::{Path, Query, State}, + response::Result as AxumResult, +}; +use serde::{Deserialize, Serialize}; + +use crate::{ + errors::api_error::ApiError, + routes::{ + AppState, + api::{ + openapi::tag::NGINX_TAG, + restricted::nginx::proxy_host::info::response::LocationInfoResponse, + }, + }, +}; + +#[derive(Serialize, Deserialize, utoipa::ToSchema)] +pub struct GetLocationParams { + pub include_upstream: Option, +} + +pub struct ConcreteGetLocationParams { + pub include_upstream: bool, +} + +impl From for ConcreteGetLocationParams { + fn from(params: GetLocationParams) -> Self { + Self { + include_upstream: params.include_upstream.unwrap_or(false), + } + } +} + +#[utoipa::path( + get, + path = "/api/nginx/locations/{location_id}", + responses( + (status = 200, description = "Get location info", body = LocationInfoResponse), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + tag = NGINX_TAG, +)] +#[axum::debug_handler] +pub async fn get_location( + Path(location_id): Path, + Query(params): Query, + State(state): State>, +) -> AxumResult, ApiError> { + let concrete_params: ConcreteGetLocationParams = params.into(); + let svc = &state.service.nginx.get_location_service(); + let info = if concrete_params.include_upstream { + svc.get_location( + location_id, + Some(GetLocationOptions { + include_upstream: true, + filter_by_enabled: false, + }), + None, + ) + .await? + } else { + svc.get_location(location_id, None, None).await? + }; + Ok(Json(info.into())) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use axum::http::StatusCode; + use axum_test::TestServer; + use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase}; + + use database::generated::entities::{location, proxy_host}; + + use crate::{ + configs::{FromConfig, ProgramSettings}, + routes::api::restricted::nginx::proxy_host::get_proxy_router, + services::get_app_service, + }; + + fn get_router_with_state(db: DatabaseConnection) -> axum::Router { + let program_settings = ProgramSettings::mock(); + let app_service = get_app_service(&Arc::new(db.clone()), &program_settings); + let state = Arc::new(crate::routes::AppState { + database_connection: Arc::new(db), + service: Arc::new(app_service), + config: Arc::new(program_settings), + }); + get_proxy_router(state) + } + + #[tokio::test] + async fn handler_get_location_returns_info() { + let loc_id = uuid::Uuid::new_v4(); + let loc_model = location::Model { + id: loc_id, + host_id: uuid::Uuid::new_v4(), + path: "/".to_string(), + match_type: "prefix".to_string(), + order: 1, + upstream_id: None, + proxy_pass_protocol: None, + proxy_pass_host: None, + proxy_pass_port: None, + preserve_host_header: None, + allowed_methods: None, + custom_config: None, + enabled: true, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![vec![( + loc_model.clone(), + Option::::None, + )]]) + .into_connection(); + + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let res = server.get(&format!("/locations/{}", loc_id)).await; + res.assert_status_ok(); + let body = res.json::(); + assert_eq!(body.id, loc_id); + } + + #[tokio::test] + async fn handler_get_location_not_found_returns_not_found() { + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![Vec::::new()]) + .into_connection(); + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let res = server + .get(&format!("/locations/{}", uuid::Uuid::new_v4())) + .await; + res.assert_status(StatusCode::NOT_FOUND); + } +} diff --git a/apps/api/src/routes/api/restricted/nginx/proxy_host/get_proxy.rs b/apps/api/src/routes/api/restricted/nginx/proxy_host/get_proxy.rs new file mode 100644 index 0000000..f57fa06 --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/proxy_host/get_proxy.rs @@ -0,0 +1,281 @@ +use std::sync::Arc; + +use axum::{ + Json, + extract::{Path, Query, State}, + response::Result as AxumResult, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::services::nginx::proxy_host::{ProxyHostGetOptions, ProxyHostListOptions}; +use crate::{ + errors::{api_error::ApiError, service_error::ServiceError}, + routes::{ + AppState, + api::restricted::nginx::proxy_host::info::response::{ + ProxyHostInfoResponse, ProxyListResponse, + }, + api::{ + helper::pagination::{ExtractPagination, PaginationInfo}, + openapi::tag::NGINX_TAG, + }, + }, +}; + +#[utoipa::path( + get, + path = "/api/nginx/proxy_hosts", + responses( + (status = 200, description = "List proxies", body = ProxyListResponse), + (status = 500, description = "Internal server error"), + ), + tag = NGINX_TAG, +)] +#[axum::debug_handler] +pub async fn get_proxy_list( + ExtractPagination(pagination): ExtractPagination, + State(state): State>, +) -> AxumResult, ServiceError> { + let svc = &state.service.nginx.get_proxy_service(); + + let (proxies_res, proxies_count_res) = tokio::join!( + svc.get_proxies( + Some(pagination.clone().into()), + Some(ProxyHostListOptions { + include_upstream: true, + filter_by_enabled: false, + }), + None, + ), + svc.get_total_proxies(None, None), + ); + + let proxies = proxies_res?; + let proxies_count = proxies_count_res?; + + let items: Vec = proxies.into_iter().map(|i| i.into()).collect(); + + Ok(Json(ProxyListResponse { + items, + pagination: PaginationInfo { + total_items: proxies_count, + total_pages: if proxies_count == 0 { + 0 + } else { + (proxies_count as f32 / pagination.per_page as f32).ceil() as u32 + }, + current_page: pagination.page, + per_page: pagination.per_page, + }, + })) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use axum::http::StatusCode; + use axum_test::TestServer; + use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, Value}; + + use database::generated::entities::{location, proxy_host}; + + use crate::{ + configs::{FromConfig, ProgramSettings}, + routes::api::restricted::nginx::proxy_host::get_proxy_router, + services::get_app_service, + }; + + fn get_router_with_state(db: DatabaseConnection) -> axum::Router { + let program_settings = ProgramSettings::mock(); + let app_service = get_app_service(&Arc::new(db.clone()), &program_settings); + let state = Arc::new(crate::routes::AppState { + database_connection: Arc::new(db), + service: Arc::new(app_service), + config: Arc::new(program_settings), + }); + get_proxy_router(state) + } + + #[tokio::test] + async fn handler_get_proxy_list_returns_list() { + let p1 = proxy_host::Model { + id: uuid::Uuid::new_v4(), + name: Some("p1".to_string()), + domain: "a.com".to_string(), + scheme: "http".to_string(), + listen_port: 80, + forward_scheme: "http".to_string(), + forward_host: None, + forward_port: None, + preserve_host_header: false, + enable_websocket: false, + enabled: true, + meta: None, + default_upstream_id: None, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + let p2 = proxy_host::Model { + id: uuid::Uuid::new_v4(), + name: Some("p2".to_string()), + domain: "b.com".to_string(), + scheme: "http".to_string(), + listen_port: 80, + forward_scheme: "http".to_string(), + forward_host: None, + forward_port: None, + preserve_host_header: false, + enable_websocket: false, + enabled: true, + meta: None, + default_upstream_id: None, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![vec![ + (p1.clone(), None::), + (p2.clone(), None::), + ]]) + .append_query_results(vec![vec![std::collections::BTreeMap::from([( + "count".to_string(), + Value::BigInt(Some(2)), + )])]]) + .into_connection(); + + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let res = server.get("/proxy_hosts").await; + res.assert_status_ok(); + let body = res.json::(); + assert_eq!(body.items.len(), 2); + assert_eq!(body.pagination.current_page, 1u32); + assert_eq!(body.pagination.total_pages, 1u32); + } + + #[tokio::test] + async fn handler_get_proxy_with_locations_returns_locations() { + let ph_id = uuid::Uuid::new_v4(); + let ph_model = proxy_host::Model { + id: ph_id, + name: Some("with_locations".to_string()), + domain: "with.com".to_string(), + scheme: "http".to_string(), + listen_port: 80, + forward_scheme: "http".to_string(), + forward_host: None, + forward_port: None, + preserve_host_header: false, + enable_websocket: false, + enabled: true, + meta: None, + default_upstream_id: None, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let loc_model = location::Model { + id: uuid::Uuid::new_v4(), + host_id: ph_id, + path: "/path".to_string(), + match_type: "prefix".to_string(), + order: 1, + upstream_id: None, + proxy_pass_protocol: None, + proxy_pass_host: None, + proxy_pass_port: None, + preserve_host_header: None, + allowed_methods: None, + custom_config: None, + enabled: true, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![vec![(ph_model.clone(), Some(loc_model.clone()))]]) + .into_connection(); + + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let url = format!("/proxy_hosts/{}", ph_id); + let res = server.get(&url).await; + res.assert_status_ok(); + let body = res.json::(); + assert_eq!(body.id, ph_id); + assert_eq!(body.locations.len(), 1); + assert_eq!(body.locations[0].path, "/path"); + } + + #[tokio::test] + async fn handler_get_proxy_not_found_returns_not_found() { + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![Vec::::new()]) + .into_connection(); + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let res = server + .get(&format!("/proxy_hosts/{}", uuid::Uuid::new_v4())) + .await; + res.assert_status(StatusCode::NOT_FOUND); + } +} + +#[derive(Serialize, Deserialize, utoipa::ToSchema)] +pub struct GetProxyParams { + pub include_upstream: Option, +} + +pub struct ConcreteGetProxyParams { + pub include_upstream: bool, +} + +impl From for ConcreteGetProxyParams { + fn from(params: GetProxyParams) -> Self { + Self { + include_upstream: params.include_upstream.unwrap_or(false), + } + } +} + +#[utoipa::path( + get, + path = "/api/nginx/proxy_hosts/{proxy_id}", + responses( + (status = 200, description = "Get proxy info", body = ProxyHostInfoResponse), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + tag = NGINX_TAG, +)] +pub async fn get_proxy( + Path(proxy_id): Path, + Query(params): Query, + State(state): State>, +) -> AxumResult, ApiError> { + let concrete_params: ConcreteGetProxyParams = params.into(); + let svc = &state.service.nginx.get_proxy_service(); + let info = if concrete_params.include_upstream { + svc.get_proxy( + proxy_id, + Some(ProxyHostGetOptions { + include_upstream: true, + filter_by_enabled: false, + }), + None, + ) + .await? + } else { + svc.get_proxy(proxy_id, None, None).await? + }; + Ok(Json(info.into())) +} diff --git a/apps/api/src/routes/api/restricted/nginx/proxy_host/info/mod.rs b/apps/api/src/routes/api/restricted/nginx/proxy_host/info/mod.rs new file mode 100644 index 0000000..4c6f2cd --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/proxy_host/info/mod.rs @@ -0,0 +1 @@ +pub mod response; diff --git a/apps/api/src/routes/api/restricted/nginx/proxy_host/info/response.rs b/apps/api/src/routes/api/restricted/nginx/proxy_host/info/response.rs new file mode 100644 index 0000000..85622de --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/proxy_host/info/response.rs @@ -0,0 +1,91 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::routes::api::helper::pagination::PaginationInfo; + +#[derive(Serialize, Deserialize, utoipa::ToSchema)] +pub struct LocationInfoResponse { + pub id: uuid::Uuid, + pub host_id: uuid::Uuid, + pub path: String, + pub match_type: String, + pub order: i64, + pub upstream_id: Option, + pub enabled: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl From for LocationInfoResponse { + fn from(info: crate::services::nginx::info::location::LocationInfo) -> Self { + Self { + id: info.id, + host_id: info.host_id, + path: info.path, + match_type: info.match_type, + order: info.order, + upstream_id: info.upstream_id, + enabled: info.enabled, + created_at: info.created_at, + updated_at: info.updated_at, + } + } +} + +#[derive(Serialize, Deserialize, utoipa::ToSchema)] +pub struct ProxyHostInfoResponse { + pub id: uuid::Uuid, + pub name: Option, + pub domain: String, + pub scheme: String, + pub listen_port: i64, + pub forward_scheme: String, + pub forward_host: Option, + pub forward_port: Option, + pub preserve_host_header: bool, + pub enable_websocket: bool, + pub enabled: bool, + pub created_at: DateTime, + pub updated_at: DateTime, + pub upstream: Option, + pub locations: Vec, +} + +#[derive(Serialize, Deserialize, utoipa::ToSchema)] +pub struct ProxyHostUpstreamBasic { + pub id: uuid::Uuid, + pub name: String, + pub protocol: String, +} + +impl From for ProxyHostInfoResponse { + fn from(info: crate::services::nginx::info::proxy_host::ProxyHostInfo) -> Self { + Self { + id: info.id, + name: info.name, + domain: info.domain, + scheme: info.scheme, + listen_port: info.listen_port, + forward_scheme: info.forward_scheme, + forward_host: info.forward_host, + forward_port: info.forward_port, + preserve_host_header: info.preserve_host_header, + enable_websocket: info.enable_websocket, + enabled: info.enabled, + created_at: info.created_at, + updated_at: info.updated_at, + upstream: info.upstream.map(|u| ProxyHostUpstreamBasic { + id: u.id, + name: u.name, + protocol: u.protocol, + }), + locations: info.locations.into_iter().map(|l| l.into()).collect(), + } + } +} + +#[derive(Serialize, Deserialize, utoipa::ToSchema)] +pub struct ProxyListResponse { + pub items: Vec, + pub pagination: PaginationInfo, +} diff --git a/apps/api/src/routes/api/restricted/nginx/proxy_host/remove_location.rs b/apps/api/src/routes/api/restricted/nginx/proxy_host/remove_location.rs new file mode 100644 index 0000000..8035c8e --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/proxy_host/remove_location.rs @@ -0,0 +1,157 @@ +use std::sync::Arc; + +use axum::{ + Json, + extract::{Path, State}, + response::Result as AxumResult, +}; +use sea_orm::TransactionTrait; + +use crate::{ + errors::api_error::ApiError, + middlewares::request_info::AuthenticatedRequestInfo, + routes::{AppState, api::openapi::tag::NGINX_TAG}, +}; + +#[utoipa::path( + delete, + path = "/api/nginx/locations/{location_id}", + responses( + (status = 200, description = "Location removed successfully", body = ()), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + tag = NGINX_TAG, +)] +#[axum::debug_handler] +pub async fn remove_location( + _request_info: AuthenticatedRequestInfo, + Path(location_id): Path, + State(state): State>, +) -> AxumResult, ApiError> { + let svc = &state.service.nginx.get_location_service(); + + let mut tx = state.database_connection.begin().await?; + svc.delete_location(location_id, Some(&mut tx)).await?; + state + .service + .nginx + .regenerate_and_apply_config(state.service.agent_client.clone(), Some(&mut tx)) + .await?; + tx.commit().await?; + + Ok(Json(())) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use axum::http::StatusCode; + use axum_test::TestServer; + use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase}; + + use database::generated::entities::{location, upstream, upstream_target}; + + use crate::{ + configs::{FromConfig, ProgramSettings}, + routes::api::restricted::nginx::proxy_host::get_proxy_router, + services::{agent_client::MockAgentService, get_mock_app_service}, + }; + + fn get_router_with_state(db: DatabaseConnection) -> axum::Router { + let program_settings = ProgramSettings::mock(); + let mut mock = MockAgentService::new(); + mock.expect_validate().returning(|_cfg| Ok(())); + mock.expect_apply().returning(|_cfg| Ok(())); + let mock_agent = Arc::new(mock); + let app_service = + get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent); + let state = Arc::new(crate::routes::AppState { + database_connection: Arc::new(db), + service: Arc::new(app_service), + config: Arc::new(program_settings), + }); + get_proxy_router(state).layer(axum::middleware::from_fn( + crate::middlewares::require_auth::mock::mock_require_auth, + )) + } + + #[tokio::test] + async fn handler_remove_location_succeeds_returns_ok() { + let loc_id = uuid::Uuid::new_v4(); + + let existing = location::Model { + id: loc_id, + host_id: uuid::Uuid::new_v4(), + path: "/".to_string(), + match_type: "prefix".to_string(), + order: 1, + upstream_id: None, + proxy_pass_protocol: None, + proxy_pass_host: None, + proxy_pass_port: None, + preserve_host_header: None, + allowed_methods: None, + custom_config: None, + enabled: true, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let up_id = uuid::Uuid::new_v4(); + let up_model = upstream::Model { + id: up_id, + name: "u1".to_string(), + protocol: "http".to_string(), + algorithm: "rr".to_string(), + sticky_session: false, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + let target_model = upstream_target::Model { + id: uuid::Uuid::new_v4(), + upstream_id: up_id, + target_host: "127.0.0.1".to_string(), + target_port: 8080, + weight: 1, + is_backup: false, + enabled: true, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![vec![existing.clone()]]) + .append_exec_results(vec![sea_orm::MockExecResult { + rows_affected: 1, + last_insert_id: 0, + }]) + // additional query result for regenerate_and_apply_config -> generate_config + .append_query_results(vec![vec![(up_model.clone(), Some(target_model.clone()))]]) + .into_connection(); + + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let res = server.delete(&format!("/locations/{}", loc_id)).await; + res.assert_status_ok(); + } + + #[tokio::test] + async fn handler_remove_location_not_found_returns_not_found() { + let empty_results: Vec> = vec![Vec::::new()]; + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(empty_results) + .into_connection(); + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let res = server + .delete(&format!("/locations/{}", uuid::Uuid::new_v4())) + .await; + res.assert_status(StatusCode::NOT_FOUND); + } +} diff --git a/apps/api/src/routes/api/restricted/nginx/proxy_host/remove_proxy.rs b/apps/api/src/routes/api/restricted/nginx/proxy_host/remove_proxy.rs new file mode 100644 index 0000000..1ab462e --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/proxy_host/remove_proxy.rs @@ -0,0 +1,180 @@ +use std::sync::Arc; + +use axum::{ + Json, + extract::{Path, State}, + response::Result as AxumResult, +}; +use sea_orm::TransactionTrait; +use uuid::Uuid; + +use crate::{ + errors::api_error::ApiError, + middlewares::request_info::AuthenticatedRequestInfo, + routes::{AppState, api::openapi::tag::NGINX_TAG}, +}; + +#[axum::debug_handler] +#[utoipa::path( + delete, + path = "/api/nginx/proxy_hosts/{proxy_id}", + responses( + (status = 200, description = "Proxy removed successfully", body = ()), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + (status = 500, description = "Internal server error"), + ), + tag = NGINX_TAG, +)] +pub async fn remove_proxy( + _request_info: AuthenticatedRequestInfo, + Path(proxy_id): Path, + State(state): State>, +) -> AxumResult, ApiError> { + let svc = &state.service.nginx.get_proxy_service(); + + let mut tx = state.database_connection.begin().await?; + svc.delete_proxy(proxy_id, Some(&mut tx)).await?; + state + .service + .nginx + .regenerate_and_apply_config(state.service.agent_client.clone(), Some(&mut tx)) + .await?; + tx.commit().await?; + + Ok(Json(())) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use axum::http::StatusCode; + use axum_test::TestServer; + use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, MockExecResult}; + + use database::generated::entities::{proxy_host, upstream, upstream_target}; + + use crate::{ + configs::{FromConfig, ProgramSettings}, + middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER, + routes::api::restricted::nginx::proxy_host::get_proxy_router, + services::{agent_client::MockAgentService, get_mock_app_service}, + }; + + fn get_router_with_state(db: DatabaseConnection) -> axum::Router { + let program_settings = ProgramSettings::mock(); + let mut mock = MockAgentService::new(); + mock.expect_validate().returning(|_cfg| Ok(())); + mock.expect_apply().returning(|_cfg| Ok(())); + let mock_agent = Arc::new(mock); + let app_service = + get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent); + let state = Arc::new(crate::routes::AppState { + database_connection: Arc::new(db), + service: Arc::new(app_service), + config: Arc::new(program_settings), + }); + get_proxy_router(state).layer(axum::middleware::from_fn( + crate::middlewares::require_auth::mock::mock_require_auth, + )) + } + + #[tokio::test] + async fn handler_remove_proxy_succeeds_returns_ok() { + let ph_id = uuid::Uuid::new_v4(); + + let existing = proxy_host::Model { + id: ph_id, + name: Some("todelete".to_string()), + domain: "d.com".to_string(), + scheme: "http".to_string(), + listen_port: 80, + forward_scheme: "http".to_string(), + forward_host: None, + forward_port: None, + preserve_host_header: false, + enable_websocket: false, + enabled: true, + meta: None, + default_upstream_id: None, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let up_id = uuid::Uuid::new_v4(); + let up_model = upstream::Model { + id: up_id, + name: "u1".to_string(), + protocol: "http".to_string(), + algorithm: "rr".to_string(), + sticky_session: false, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + let target_model = upstream_target::Model { + id: uuid::Uuid::new_v4(), + upstream_id: up_id, + target_host: "127.0.0.1".to_string(), + target_port: 8080, + weight: 1, + is_backup: false, + enabled: true, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![vec![existing.clone()]]) + .append_exec_results(vec![ + MockExecResult { + rows_affected: 1, + last_insert_id: 0, + }, + MockExecResult { + rows_affected: 1, + last_insert_id: 0, + }, + ]) + // additional query result for regenerate_and_apply_config -> generate_config + .append_query_results(vec![vec![(up_model.clone(), Some(target_model.clone()))]]) + .into_connection(); + + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let res = server.delete(&format!("/proxy_hosts/{}", ph_id)).await; + res.assert_status_ok(); + } + + #[tokio::test] + async fn handler_remove_proxy_unauthenticated_returns_unauthorized() { + let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection(); + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let res = server + .delete(&format!("/proxy_hosts/{}", uuid::Uuid::new_v4())) + .add_header(REQUEST_AUTH_USER_INVALID_HEADER, "true") + .await; + + res.assert_status(StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn handler_remove_proxy_not_found_returns_not_found() { + let empty_results: Vec> = vec![Vec::::new()]; + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(empty_results) + .into_connection(); + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let res = server + .delete(&format!("/proxy_hosts/{}", uuid::Uuid::new_v4())) + .await; + res.assert_status(StatusCode::NOT_FOUND); + } +} diff --git a/apps/api/src/routes/api/restricted/nginx/proxy_host/update_location.rs b/apps/api/src/routes/api/restricted/nginx/proxy_host/update_location.rs new file mode 100644 index 0000000..2f67ce5 --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/proxy_host/update_location.rs @@ -0,0 +1,218 @@ +use std::sync::Arc; + +use axum::{ + Json, + extract::{Path, State}, + response::Result as AxumResult, +}; +use sea_orm::TransactionTrait; + +use crate::{ + errors::api_error::ApiError, + middlewares::request_info::AuthenticatedRequestInfo, + routes::{ + AppState, + api::{ + openapi::tag::NGINX_TAG, + restricted::nginx::proxy_host::info::response::LocationInfoResponse, + }, + }, + services::nginx::info::location::UpdateLocationInfo, +}; + +#[derive(serde::Deserialize, utoipa::ToSchema, serde::Serialize)] +pub struct UpdateLocationRequestBody { + pub path: Option, + pub match_type: Option, + pub order: Option, + pub upstream_id: Option>, +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use axum::http::StatusCode; + use axum_test::TestServer; + use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase}; + + use database::generated::entities::{location, upstream, upstream_target}; + + use super::UpdateLocationRequestBody; + use crate::{ + configs::{FromConfig, ProgramSettings}, + routes::api::restricted::nginx::proxy_host::get_proxy_router, + services::{agent_client::MockAgentService, get_mock_app_service}, + }; + + fn get_router_with_state(db: DatabaseConnection) -> axum::Router { + let program_settings = ProgramSettings::mock(); + let mut mock = MockAgentService::new(); + mock.expect_validate().returning(|_cfg| Ok(())); + mock.expect_apply().returning(|_cfg| Ok(())); + let mock_agent = Arc::new(mock); + let app_service = + get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent); + let state = Arc::new(crate::routes::AppState { + database_connection: Arc::new(db), + service: Arc::new(app_service), + config: Arc::new(program_settings), + }); + get_proxy_router(state).layer(axum::middleware::from_fn( + crate::middlewares::require_auth::mock::mock_require_auth, + )) + } + + #[tokio::test] + async fn handler_update_location_succeeds_returns_ok() { + let loc_id = uuid::Uuid::new_v4(); + + let current = location::Model { + id: loc_id, + host_id: uuid::Uuid::new_v4(), + path: "/old".to_string(), + match_type: "prefix".to_string(), + order: 1, + upstream_id: None, + proxy_pass_protocol: None, + proxy_pass_host: None, + proxy_pass_port: None, + preserve_host_header: None, + allowed_methods: None, + custom_config: None, + enabled: true, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let updated = location::Model { ..current.clone() }; + + let first: Vec> = vec![vec![current.clone()]]; + let second: Vec> = vec![vec![updated.clone()]]; + let up_id = uuid::Uuid::new_v4(); + let up_model = upstream::Model { + id: up_id, + name: "u1".to_string(), + protocol: "http".to_string(), + algorithm: "rr".to_string(), + sticky_session: false, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + let target_model = upstream_target::Model { + id: uuid::Uuid::new_v4(), + upstream_id: up_id, + target_host: "127.0.0.1".to_string(), + target_port: 8080, + weight: 1, + is_backup: false, + enabled: true, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(first) + .append_query_results(second) + // additional query result for regenerate_and_apply_config -> generate_config + .append_query_results(vec![vec![(up_model.clone(), Some(target_model.clone()))]]) + .into_connection(); + + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let payload = UpdateLocationRequestBody { + path: Some("/new".to_string()), + match_type: None, + order: None, + upstream_id: None, + }; + + let res = server + .patch(&format!("/locations/{}", loc_id)) + .json(&payload) + .await; + res.assert_status_ok(); + let body = res.json::(); + assert_eq!(body.id, loc_id); + } + + #[tokio::test] + async fn handler_update_location_not_found_returns_not_found() { + let empty_results: Vec> = vec![Vec::::new()]; + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(empty_results) + .into_connection(); + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let payload = UpdateLocationRequestBody { + path: Some("/new".to_string()), + match_type: None, + order: None, + upstream_id: None, + }; + + let res = server + .patch(&format!("/locations/{}", uuid::Uuid::new_v4())) + .json(&payload) + .await; + res.assert_status(StatusCode::NOT_FOUND); + } +} + +impl From for UpdateLocationInfo { + fn from(val: UpdateLocationRequestBody) -> Self { + Self { + path: val.path, + match_type: val.match_type, + order: val.order, + upstream_id: val.upstream_id, + proxy_pass_protocol: None, + proxy_pass_host: None, + proxy_pass_port: None, + preserve_host_header: None, + allowed_methods: None, + custom_config: None, + enabled: None, + } + } +} + +#[axum::debug_handler] +#[utoipa::path( + patch, + path = "/api/nginx/locations/{location_id}", + request_body = UpdateLocationRequestBody, + responses( + (status = 200, description = "Location updated successfully", body = LocationInfoResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + (status = 422, description = "Invalid request"), + (status = 500, description = "Internal server error"), + ), + tag = NGINX_TAG, +)] +pub async fn update_location( + _request_info: AuthenticatedRequestInfo, + Path(location_id): Path, + State(state): State>, + Json(payload): Json, +) -> AxumResult, ApiError> { + let svc = &state.service.nginx.get_location_service(); + let update: UpdateLocationInfo = payload.into(); + + let mut tx = state.database_connection.begin().await?; + let info = svc + .update_location(location_id, update, Some(&mut tx)) + .await?; + state + .service + .nginx + .regenerate_and_apply_config(state.service.agent_client.clone(), Some(&mut tx)) + .await?; + tx.commit().await?; + + Ok(Json(info.into())) +} diff --git a/apps/api/src/routes/api/restricted/nginx/proxy_host/update_proxy.rs b/apps/api/src/routes/api/restricted/nginx/proxy_host/update_proxy.rs new file mode 100644 index 0000000..64fbfe5 --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/proxy_host/update_proxy.rs @@ -0,0 +1,225 @@ +use std::sync::Arc; + +use axum::{ + Json, + extract::{Path, State}, + response::Result as AxumResult, +}; +use sea_orm::TransactionTrait; + +use crate::{ + errors::api_error::ApiError, + middlewares::request_info::AuthenticatedRequestInfo, + routes::{AppState, api::restricted::nginx::proxy_host::info::response::ProxyHostInfoResponse}, + services::nginx::info::proxy_host::UpdateProxyHostInfo, +}; + +#[derive(serde::Deserialize, utoipa::ToSchema, serde::Serialize)] +pub struct UpdateProxyRequestBody { + pub name: Option>, + pub domain: Option, +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use axum::http::StatusCode; + use axum_test::TestServer; + use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase}; + + use database::generated::entities::{proxy_host, upstream, upstream_target}; + + use super::UpdateProxyRequestBody; + use crate::{ + configs::{FromConfig, ProgramSettings}, + middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER, + routes::api::restricted::nginx::proxy_host::get_proxy_router, + services::{agent_client::MockAgentService, get_mock_app_service}, + }; + + fn get_router_with_state(db: DatabaseConnection) -> axum::Router { + let program_settings = ProgramSettings::mock(); + let mut mock = MockAgentService::new(); + mock.expect_validate().returning(|_cfg| Ok(())); + mock.expect_apply().returning(|_cfg| Ok(())); + let mock_agent = Arc::new(mock); + let app_service = + get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent); + let state = Arc::new(crate::routes::AppState { + database_connection: Arc::new(db), + service: Arc::new(app_service), + config: Arc::new(program_settings), + }); + get_proxy_router(state).layer(axum::middleware::from_fn( + crate::middlewares::require_auth::mock::mock_require_auth, + )) + } + + #[tokio::test] + async fn handler_update_proxy_succeeds_returns_ok() { + let ph_id = uuid::Uuid::new_v4(); + + let current = proxy_host::Model { + id: ph_id, + name: Some("oldname".to_string()), + domain: "a.com".to_string(), + scheme: "http".to_string(), + listen_port: 80, + forward_scheme: "http".to_string(), + forward_host: None, + forward_port: None, + preserve_host_header: false, + enable_websocket: false, + enabled: true, + meta: None, + default_upstream_id: None, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let updated = proxy_host::Model { ..current.clone() }; + + let first: Vec> = vec![vec![current.clone()]]; + let second: Vec> = vec![vec![updated.clone()]]; + let up_id = uuid::Uuid::new_v4(); + let up_model = upstream::Model { + id: up_id, + name: "u1".to_string(), + protocol: "http".to_string(), + algorithm: "rr".to_string(), + sticky_session: false, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + let target_model = upstream_target::Model { + id: uuid::Uuid::new_v4(), + upstream_id: up_id, + target_host: "127.0.0.1".to_string(), + target_port: 8080, + weight: 1, + is_backup: false, + enabled: true, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(first) + .append_query_results(second) + // additional query result for regenerate_and_apply_config -> generate_config + .append_query_results(vec![vec![(up_model.clone(), Some(target_model.clone()))]]) + .into_connection(); + + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let payload = UpdateProxyRequestBody { + name: Some(Some("newname".to_string())), + domain: Some("a.com".to_string()), + }; + + let res = server + .patch(&format!("/proxy_hosts/{}", ph_id)) + .json(&payload) + .await; + res.assert_status_ok(); + let body: crate::routes::api::restricted::nginx::proxy_host::info::response::ProxyHostInfoResponse = res.json(); + assert_eq!(body.id, ph_id); + } + + #[tokio::test] + async fn handler_update_proxy_unauthenticated_returns_unauthorized() { + let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection(); + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let payload = UpdateProxyRequestBody { + name: Some(Some("newname".to_string())), + domain: None, + }; + + let res = server + .patch(&format!("/proxy_hosts/{}", uuid::Uuid::new_v4())) + .add_header(REQUEST_AUTH_USER_INVALID_HEADER, "true") + .json(&payload) + .await; + + res.assert_status(StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn handler_update_proxy_not_found_returns_not_found() { + let empty_results: Vec> = vec![Vec::::new()]; + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(empty_results) + .into_connection(); + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let payload = UpdateProxyRequestBody { + name: Some(Some("newname".to_string())), + domain: None, + }; + + let res = server + .patch(&format!("/proxy_hosts/{}", uuid::Uuid::new_v4())) + .json(&payload) + .await; + res.assert_status(StatusCode::NOT_FOUND); + } +} + +impl From for UpdateProxyHostInfo { + fn from(val: UpdateProxyRequestBody) -> Self { + Self { + name: val.name, + domain: val.domain, + scheme: None, + listen_port: None, + forward_scheme: None, + forward_host: None, + forward_port: None, + preserve_host_header: None, + enable_websocket: None, + enabled: None, + meta: None, + default_upstream_id: None, + } + } +} + +#[axum::debug_handler] +#[utoipa::path( + patch, + path = "/api/nginx/proxy_hosts/{proxy_id}", + request_body = UpdateProxyRequestBody, + responses( + (status = 200, description = "Proxy updated successfully", body = ProxyHostInfoResponse), + (status = 401, description = "Unauthorized"), + (status = 422, description = "Invalid request"), + (status = 500, description = "Internal server error"), + ), +)] +pub async fn update_proxy( + _request_info: AuthenticatedRequestInfo, + Path(proxy_id): Path, + State(state): State>, + Json(payload): Json, +) -> AxumResult, ApiError> { + let svc = &state.service.nginx.get_proxy_service(); + let update: UpdateProxyHostInfo = payload.into(); + + let mut tx = state.database_connection.begin().await?; + let info = svc.update_proxy(proxy_id, update, Some(&mut tx)).await?; + state + .service + .nginx + .regenerate_and_apply_config(state.service.agent_client.clone(), Some(&mut tx)) + .await?; + tx.commit().await?; + + Ok(Json(info.into())) +} diff --git a/apps/api/swagger.json b/apps/api/swagger.json index ae006db..fc81dd5 100644 --- a/apps/api/swagger.json +++ b/apps/api/swagger.json @@ -106,6 +106,365 @@ } } }, + "/api/nginx/locations/{location_id}": { + "get": { + "tags": [ + "Nginx" + ], + "operationId": "get_location", + "parameters": [ + { + "name": "location_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Get location info", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LocationInfoResponse" + } + } + } + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + } + }, + "delete": { + "tags": [ + "Nginx" + ], + "operationId": "remove_location", + "parameters": [ + { + "name": "location_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Location removed successfully", + "content": { + "application/json": { + "schema": { + "default": null + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + } + }, + "patch": { + "tags": [ + "Nginx" + ], + "operationId": "update_location", + "parameters": [ + { + "name": "location_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateLocationRequestBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Location updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LocationInfoResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Invalid request" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/api/nginx/proxy_hosts": { + "get": { + "tags": [ + "Nginx" + ], + "operationId": "get_proxy_list", + "responses": { + "200": { + "description": "List proxies", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProxyListResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + }, + "post": { + "tags": [ + "Nginx" + ], + "operationId": "create_proxy", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateProxyRequestBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Proxy created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProxyHostInfoResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "422": { + "description": "Invalid request" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/api/nginx/proxy_hosts/{proxy_id}": { + "get": { + "tags": [ + "Nginx" + ], + "operationId": "get_proxy", + "parameters": [ + { + "name": "proxy_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Get proxy info", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProxyHostInfoResponse" + } + } + } + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + } + }, + "delete": { + "tags": [ + "Nginx" + ], + "operationId": "remove_proxy", + "parameters": [ + { + "name": "proxy_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Proxy removed successfully", + "content": { + "application/json": { + "schema": { + "default": null + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + } + }, + "patch": { + "tags": [ + "crate::routes::api::restricted::nginx::proxy_host::update_proxy" + ], + "operationId": "update_proxy", + "parameters": [ + { + "name": "proxy_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateProxyRequestBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Proxy updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProxyHostInfoResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "422": { + "description": "Invalid request" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/api/nginx/proxy_hosts/{proxy_id}/locations": { + "post": { + "tags": [ + "Nginx" + ], + "operationId": "create_location", + "parameters": [ + { + "name": "proxy_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateLocationRequestBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Location created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LocationInfoResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "422": { + "description": "Invalid request" + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/api/nginx/upstream_targets/{upstream_target_id}": { "get": { "tags": [ @@ -508,6 +867,130 @@ } } }, + "CreateLocationReq": { + "type": "object", + "required": [ + "path", + "match_type", + "order" + ], + "properties": { + "match_type": { + "type": "string" + }, + "order": { + "type": "integer", + "format": "int64" + }, + "path": { + "type": "string" + }, + "upstream_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } + } + }, + "CreateLocationRequestBody": { + "type": "object", + "required": [ + "path", + "match_type", + "order" + ], + "properties": { + "match_type": { + "type": "string" + }, + "order": { + "type": "integer", + "format": "int64" + }, + "path": { + "type": "string" + }, + "upstream_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } + } + }, + "CreateProxyRequestBody": { + "type": "object", + "required": [ + "domain", + "scheme", + "listen_port", + "forward_scheme", + "preserve_host_header", + "enable_websocket", + "enabled", + "locations" + ], + "properties": { + "default_upstream_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "domain": { + "type": "string" + }, + "enable_websocket": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "forward_host": { + "type": [ + "string", + "null" + ] + }, + "forward_port": { + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "forward_scheme": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "format": "int64" + }, + "locations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CreateLocationReq" + } + }, + "meta": {}, + "name": { + "type": [ + "string", + "null" + ] + }, + "preserve_host_header": { + "type": "boolean" + }, + "scheme": { + "type": "string" + } + } + }, "CreateUpstreamRequestBody": { "type": "object", "required": [ @@ -643,6 +1126,57 @@ } } }, + "LocationInfoResponse": { + "type": "object", + "required": [ + "id", + "host_id", + "path", + "match_type", + "order", + "enabled", + "created_at", + "updated_at" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "enabled": { + "type": "boolean" + }, + "host_id": { + "type": "string", + "format": "uuid" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "match_type": { + "type": "string" + }, + "order": { + "type": "integer", + "format": "int64" + }, + "path": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "upstream_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } + } + }, "LoginRequest": { "type": "object", "description": "Login request payload", @@ -695,6 +1229,179 @@ } } }, + "ProxyHostInfoResponse": { + "type": "object", + "required": [ + "id", + "domain", + "scheme", + "listen_port", + "forward_scheme", + "preserve_host_header", + "enable_websocket", + "enabled", + "created_at", + "updated_at", + "locations" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "domain": { + "type": "string" + }, + "enable_websocket": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "forward_host": { + "type": [ + "string", + "null" + ] + }, + "forward_port": { + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "forward_scheme": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "listen_port": { + "type": "integer", + "format": "int64" + }, + "locations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LocationInfoResponse" + } + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "preserve_host_header": { + "type": "boolean" + }, + "scheme": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "upstream": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ProxyHostUpstreamBasic" + } + ] + } + } + }, + "ProxyHostUpstreamBasic": { + "type": "object", + "required": [ + "id", + "name", + "protocol" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "protocol": { + "type": "string" + } + } + }, + "ProxyListResponse": { + "type": "object", + "required": [ + "items", + "pagination" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProxyHostInfoResponse" + } + }, + "pagination": { + "$ref": "#/components/schemas/PaginationInfo" + } + } + }, + "UpdateLocationRequestBody": { + "type": "object", + "properties": { + "match_type": { + "type": [ + "string", + "null" + ] + }, + "order": { + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "upstream_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } + } + }, + "UpdateProxyRequestBody": { + "type": "object", + "properties": { + "domain": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": [ + "string", + "null" + ] + } + } + }, "UpdateUpstreamInfoResponse": { "type": "object", "required": [