21 Commits

Author SHA1 Message Date
GW_MC
d21459802c Add total upstream count retrieval to UpstreamService
All checks were successful
Test / lint-frontend (pull_request) Successful in 28s
Test / test-frontend (pull_request) Successful in 27s
Test / frontend-build (pull_request) Successful in 30s
Verify / verify-generated-database-code (pull_request) Successful in 1m7s
Verify / verify-generated-agent-code (pull_request) Successful in 1m9s
Verify / verify-openapi-spec (pull_request) Successful in 1m8s
Verify / verify-frontend-api-client (pull_request) Successful in 20s
Test / test-crates (pull_request) Successful in 1m1s
Test / lint-crates (pull_request) Successful in 1m8s
2026-01-01 10:40:44 +08:00
GW_MC
5e1a8364c7 Fix: update test database query results to include upstream target models
All checks were successful
Test / test-frontend (pull_request) Successful in 24s
Test / lint-frontend (pull_request) Successful in 27s
Test / frontend-build (pull_request) Successful in 32s
Verify / verify-generated-database-code (pull_request) Successful in 1m5s
Verify / verify-generated-agent-code (pull_request) Successful in 1m8s
Verify / verify-openapi-spec (pull_request) Successful in 1m5s
Verify / verify-frontend-api-client (pull_request) Successful in 20s
Test / test-crates (pull_request) Successful in 55s
Test / lint-crates (pull_request) Successful in 1m5s
2025-12-31 20:26:20 +08:00
GW_MC
3be9ecc4c1 Refactor: improve config formatting, clean up imports
Some checks failed
Test / test-frontend (pull_request) Successful in 24s
Test / lint-frontend (pull_request) Successful in 27s
Test / frontend-build (pull_request) Successful in 31s
Verify / verify-generated-database-code (pull_request) Successful in 1m9s
Verify / verify-generated-agent-code (pull_request) Successful in 1m12s
Verify / verify-openapi-spec (pull_request) Successful in 1m9s
Verify / verify-frontend-api-client (pull_request) Successful in 22s
Test / test-crates (pull_request) Failing after 58s
Test / lint-crates (pull_request) Successful in 1m8s
2025-12-31 20:10:47 +08:00
GW_MC
545bc66f8c Fix: invalid config when all are backup 2025-12-31 19:17:14 +08:00
GW_MC
75097a661b Add filtering options for upstream targets in get_upstreams 2025-12-31 19:16:55 +08:00
GW_MC
9860dddf60 Fix incorrect upstream_target config 2025-12-31 18:09:30 +08:00
GW_MC
c4634b18f9 Fix CORS method not allowed 2025-12-31 18:09:19 +08:00
GW_MC
a0b4df745e fix upstream does not contain a target when init 2025-12-31 18:03:54 +08:00
GW_MC
46801fba99 improve error logging 2025-12-31 18:03:42 +08:00
GW_MC
cb65d4e9f7 fix incorrect Extension 2025-12-31 18:03:19 +08:00
GW_MC
10cc8f9d97 Fix incorrect path 2025-12-31 18:02:45 +08:00
GW_MC
d184261027 feat: added openapi doc
All checks were successful
Test / lint-frontend (pull_request) Successful in 45s
Test / test-frontend (pull_request) Successful in 44s
Test / frontend-build (pull_request) Successful in 47s
Verify / verify-generated-agent-code (pull_request) Successful in 1m15s
Verify / verify-openapi-spec (pull_request) Successful in 2m29s
Verify / verify-generated-database-code (pull_request) Successful in 2m35s
Verify / verify-frontend-api-client (pull_request) Successful in 19s
Test / lint-crates (pull_request) Successful in 59s
Test / test-crates (pull_request) Successful in 2m44s
2025-12-31 16:44:18 +08:00
GW_MC
6a30a03e59 feat: enhance socket path validation for reqwest client configuration 2025-12-31 16:11:03 +08:00
GW_MC
9c3f775a67 refactor: remove unused Filters struct and clean up imports in create_upstream tests 2025-12-31 16:05:28 +08:00
GW_MC
331b4e1e96 feat: implement transaction handling for upstream and target operations
- Added transaction support in `add_upstream_target`, `remove_upstream`, `remove_upstream_target`, `update_upstream`, and `update_upstream_target` functions to ensure atomicity of operations.
- Updated the `NginxService` to include methods for validating and applying configurations using the agent service.
- Enhanced error handling in agent service interactions, returning appropriate internal server errors when agent communication fails.
- Introduced mock agent service for testing, allowing for simulation of agent interactions without actual network calls.
- Refactored tests to cover scenarios where agent operations fail, ensuring that internal server errors are returned as expected.
2025-12-31 15:57:29 +08:00
GW_MC
4f85d88380 feat: implement conversion from DbErr to ApiError 2025-12-31 14:51:22 +08:00
GW_MC
d81e5fe48d refactor: clean up test module imports in health info endpoint 2025-12-31 14:48:51 +08:00
GW_MC
dff560019f revert editing session 2025-12-31 12:01:08 +08:00
GW_MC
b2a322ed79 chore: added trait for upstream service 2025-12-30 18:22:18 +08:00
GW_MC
f05544267c feat: add remove upstream and remove upstream target handlers 2025-12-30 18:02:46 +08:00
GW_MC
f4db47daf2 feat: implement update handlers for upstream and upstream target management 2025-12-30 15:09:49 +08:00
35 changed files with 3480 additions and 360 deletions

43
Cargo.lock generated
View File

@@ -7,7 +7,7 @@ name = "agent_client"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"mockall", "mockall 0.13.1",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
@@ -2247,7 +2247,21 @@ dependencies = [
"cfg-if", "cfg-if",
"downcast", "downcast",
"fragile", "fragile",
"mockall_derive", "mockall_derive 0.13.1",
"predicates",
"predicates-tree",
]
[[package]]
name = "mockall"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f58d964098a5f9c6b63d0798e5372fd04708193510a7af313c22e9f29b7b620b"
dependencies = [
"cfg-if",
"downcast",
"fragile",
"mockall_derive 0.14.0",
"predicates", "predicates",
"predicates-tree", "predicates-tree",
] ]
@@ -2264,6 +2278,18 @@ dependencies = [
"syn 2.0.111", "syn 2.0.111",
] ]
[[package]]
name = "mockall_derive"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca41ce716dda6a9be188b385aa78ee5260fc25cd3802cb2a8afdc6afbe6b6dbf"
dependencies = [
"cfg-if",
"proc-macro2",
"quote",
"syn 2.0.111",
]
[[package]] [[package]]
name = "native-tls" name = "native-tls"
version = "0.2.14" version = "0.2.14"
@@ -2465,17 +2491,6 @@ dependencies = [
"vcpkg", "vcpkg",
] ]
[[package]]
name = "optfield"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "969ccca8ffc4fb105bd131a228107d5c9dd89d9d627edf3295cbe979156f9712"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
]
[[package]] [[package]]
name = "ordered-float" name = "ordered-float"
version = "4.6.0" version = "4.6.0"
@@ -5599,8 +5614,8 @@ dependencies = [
"jsonwebtoken", "jsonwebtoken",
"migration", "migration",
"mime_guess", "mime_guess",
"mockall 0.14.0",
"once_cell", "once_cell",
"optfield",
"reqwest", "reqwest",
"sea-orm", "sea-orm",
"serde", "serde",

View File

@@ -31,11 +31,12 @@ uuid = { version = "1.19.0", features = ["v4", "serde", "fast-rng"] }
tower-http = { version = "0.6.8", features = ["cors"] } tower-http = { version = "0.6.8", features = ["cors"] }
reqwest = { version = "^0.12", features = ["json", "multipart", "stream"] } reqwest = { version = "^0.12", features = ["json", "multipart", "stream"] }
serde_urlencoded = { version = "0.7.1" } serde_urlencoded = { version = "0.7.1" }
optfield = { version = "0.4.0" }
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"
axum-test = "18.4.1" axum-test = "18.4.1"
agent_client = { path = "../../public/agent-client", features = ["mockall"] }
mockall = { version = "0.14.0", features = [] }
[lints.clippy] [lints.clippy]
unwrap_used = "deny" unwrap_used = "deny"

View File

@@ -1,19 +1,30 @@
use axum::response::IntoResponse; use axum::response::IntoResponse;
use sea_orm::DbErr;
use tracing::error;
use crate::errors::service_error::ServiceError; use crate::errors::service_error::ServiceError;
#[derive(Debug)]
pub enum ApiError { pub enum ApiError {
ServiceError(ServiceError), ServiceError(ServiceError),
} }
impl From<ServiceError> for ApiError { impl From<ServiceError> for ApiError {
fn from(err: ServiceError) -> Self { fn from(err: ServiceError) -> Self {
error!("Service error occurred: {:?}", err);
ApiError::ServiceError(err) ApiError::ServiceError(err)
} }
} }
impl From<DbErr> for ApiError {
fn from(err: DbErr) -> Self {
ServiceError::from(err).into()
}
}
impl IntoResponse for ApiError { impl IntoResponse for ApiError {
fn into_response(self) -> axum::response::Response { fn into_response(self) -> axum::response::Response {
error!("API error occurred: {:?}", self);
match self { match self {
ApiError::ServiceError(service_error) => service_error.into_response(), ApiError::ServiceError(service_error) => service_error.into_response(),
} }

View File

@@ -12,10 +12,6 @@ macro_rules! with_conn {
}}; }};
} }
pub struct Filters {
pub pagination: Option<PaginationFilter>,
}
pub struct PaginationFilter { pub struct PaginationFilter {
pub page: u64, pub page: u64,
pub per_page: u64, pub per_page: u64,

View File

@@ -9,7 +9,7 @@ use axum::{
http::{HeaderValue, Method, StatusCode, Uri}, http::{HeaderValue, Method, StatusCode, Uri},
}; };
use tower::{ServiceBuilder, timeout::TimeoutLayer}; use tower::{ServiceBuilder, timeout::TimeoutLayer};
use tower_http::cors::{AllowHeaders, AllowOrigin, CorsLayer}; use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
use tracing::warn; use tracing::warn;
use crate::{configs::server::CORSSettings, routes::AppState}; use crate::{configs::server::CORSSettings, routes::AppState};
@@ -34,6 +34,7 @@ pub fn apply_root_middleware(
pub fn get_cors_layer(cors_settings: Arc<CORSSettings>) -> CorsLayer { pub fn get_cors_layer(cors_settings: Arc<CORSSettings>) -> CorsLayer {
let mut cors_layer = CorsLayer::new() let mut cors_layer = CorsLayer::new()
.allow_credentials(true) .allow_credentials(true)
.allow_methods(AllowMethods::mirror_request())
.allow_headers(AllowHeaders::mirror_request()); .allow_headers(AllowHeaders::mirror_request());
let allowed_origins = &cors_settings.allowed_origins; let allowed_origins = &cors_settings.allowed_origins;

View File

@@ -78,25 +78,12 @@ pub async fn get_health_info(
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use crate::configs::FromConfig;
use crate::services::agent_client::AgentService;
use crate::services::get_app_service;
use crate::{
routes::{AppState, api::health::state::HealthState},
services::{
auth::{
authentication::{
AuthenticationServiceImpl, strategies::password::PasswordStrategy,
},
user::UserServiceImpl,
},
server_state::ServerStateService,
settings::SettingsService,
},
};
use super::*; use super::*;
use agent_client::apis::configuration::Configuration;
use crate::configs::FromConfig;
use crate::routes::{AppState, api::health::state::HealthState};
use crate::services::get_app_service;
use axum::body::to_bytes; use axum::body::to_bytes;
use axum::{ use axum::{
Router, Router,

View File

@@ -3,6 +3,7 @@ pub mod tag {
pub const HEALTH_TAG: &str = "Health"; pub const HEALTH_TAG: &str = "Health";
pub const AUTH_TAG: &str = "Authentication"; pub const AUTH_TAG: &str = "Authentication";
pub const USER_TAG: &str = "User"; pub const USER_TAG: &str = "User";
pub const NGINX_TAG: &str = "Nginx";
} }
#[derive(utoipa::OpenApi)] #[derive(utoipa::OpenApi)]
@@ -14,6 +15,16 @@ pub mod tag {
crate::routes::api::auth::init_admin::init_admin, crate::routes::api::auth::init_admin::init_admin,
// User management paths // User management paths
crate::routes::api::restricted::user::me::get_user_info, crate::routes::api::restricted::user::me::get_user_info,
// Nginx upstream management
crate::routes::api::restricted::nginx::upstream::create_upstream::create_upstream,
crate::routes::api::restricted::nginx::upstream::create_upstream_target::add_upstream_target,
crate::routes::api::restricted::nginx::upstream::get_upstream::get_upstream_list,
crate::routes::api::restricted::nginx::upstream::get_upstream::get_upstream,
crate::routes::api::restricted::nginx::upstream::get_upstream_target::get_upstream_target,
crate::routes::api::restricted::nginx::upstream::update_upstream::update_upstream,
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,
), ),
components( components(
schemas(crate::routes::api::health::info::HealthInfo), schemas(crate::routes::api::health::info::HealthInfo),
@@ -22,11 +33,25 @@ pub mod tag {
schemas(crate::routes::api::auth::init_admin::AdminInitRequest), schemas(crate::routes::api::auth::init_admin::AdminInitRequest),
// User management schemas // User management schemas
schemas(crate::routes::api::restricted::user::me::UserInfo), schemas(crate::routes::api::restricted::user::me::UserInfo),
// Nginx upstream schemas
schemas(crate::routes::api::restricted::nginx::upstream::create_upstream::CreateUpstreamRequestBody),
schemas(crate::routes::api::restricted::nginx::upstream::create_upstream_target::CreateUpstreamTargetInfo),
schemas(crate::routes::api::restricted::nginx::upstream::get_upstream::GetUpstreamParams),
schemas(crate::routes::api::restricted::nginx::upstream::get_upstream_target::GetUpstreamTargetsParams),
schemas(crate::routes::api::restricted::nginx::upstream::info::response::UpstreamTargetInfo),
schemas(crate::routes::api::restricted::nginx::upstream::info::response::UpstreamInfoResponse),
schemas(crate::routes::api::restricted::nginx::upstream::info::response::UpstreamListResponse),
schemas(crate::routes::api::restricted::nginx::upstream::info::response::UpstreamTargetInfoResponse),
schemas(crate::routes::api::restricted::nginx::upstream::update_upstream::UpdateUpstreamRequestBody),
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),
), ),
tags( tags(
(name = tag::HEALTH_TAG, description = "Health information API"), (name = tag::HEALTH_TAG, description = "Health information API"),
(name = tag::AUTH_TAG, description = "Authentication API"), (name = tag::AUTH_TAG, description = "Authentication API"),
(name = tag::USER_TAG, description = "User management API") (name = tag::USER_TAG, description = "User management API"),
(name = tag::NGINX_TAG, description = "Nginx management API")
) )
)] )]
pub struct ApiDoc; pub struct ApiDoc;

View File

@@ -3,6 +3,10 @@ pub mod create_upstream_target;
pub mod get_upstream; pub mod get_upstream;
pub mod get_upstream_target; pub mod get_upstream_target;
pub mod info; pub mod info;
pub mod remove_upstream;
pub mod remove_upstream_target;
pub mod update_upstream;
pub mod update_upstream_target;
use std::sync::Arc; use std::sync::Arc;
@@ -19,14 +23,21 @@ pub fn get_upstream_router(state: Arc<AppState>) -> Router {
"/upstreams", "/upstreams",
get(get_upstream::get_upstream_list).post(create_upstream::create_upstream), get(get_upstream::get_upstream_list).post(create_upstream::create_upstream),
) )
.route("/upstreams/{upstream_id}", get(get_upstream::get_upstream)) .route(
"/upstreams/{upstream_id}",
get(get_upstream::get_upstream)
.patch(update_upstream::update_upstream)
.delete(remove_upstream::remove_upstream),
)
.route( .route(
"/upstreams/{upstream_id}/targets", "/upstreams/{upstream_id}/targets",
post(create_upstream_target::add_upstream_target), post(create_upstream_target::add_upstream_target),
) )
.route( .route(
"/upstream_targets/{upstream_target_id}", "/upstream_targets/{upstream_target_id}",
get(get_upstream_target::get_upstream_target), get(get_upstream_target::get_upstream_target)
.patch(update_upstream_target::update_upstream_target)
.delete(remove_upstream_target::remove_upstream_target),
) )
.with_state(state) .with_state(state)
} }

View File

@@ -1,11 +1,18 @@
use std::sync::Arc; use std::sync::Arc;
use axum::{Json, extract::State, response::Result as AxumResult}; use axum::{Json, extract::State, response::Result as AxumResult};
use sea_orm::TransactionTrait;
use crate::{ use crate::{
errors::api_error::ApiError, errors::api_error::ApiError,
middlewares::request_info::AuthenticatedRequestInfo, middlewares::request_info::AuthenticatedRequestInfo,
routes::{AppState, api::restricted::nginx::upstream::info::response::UpstreamInfoResponse}, routes::{
AppState,
api::{
openapi::tag::NGINX_TAG,
restricted::nginx::upstream::info::response::UpstreamInfoResponse,
},
},
services::nginx::info::upstream::UpstreamCreateInfo, services::nginx::info::upstream::UpstreamCreateInfo,
}; };
@@ -74,6 +81,18 @@ impl From<CreateUpstreamRequestBody> for ConcreteCreateUpstreamRequestBody {
} }
#[axum::debug_handler] #[axum::debug_handler]
#[utoipa::path(
post,
path = "/api/nginx/upstreams",
request_body = CreateUpstreamRequestBody,
responses(
(status = 200, description = "Upstream created successfully", body = UpstreamInfoResponse),
(status = 401, description = "Unauthorized"),
(status = 422, description = "Invalid request"),
(status = 500, description = "Internal server error"),
),
tag = NGINX_TAG,
)]
pub async fn create_upstream( pub async fn create_upstream(
request_info: AuthenticatedRequestInfo, request_info: AuthenticatedRequestInfo,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
@@ -104,7 +123,18 @@ pub async fn create_upstream(
.collect(), .collect(),
}; };
let upstream_info = upstream_service.create_upstream(create_info, None).await?; let mut tx = state.database_connection.begin().await?;
let upstream_info = upstream_service
.create_upstream(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(upstream_info.into())) Ok(Json(upstream_info.into()))
} }
@@ -126,12 +156,17 @@ mod tests {
create_upstream::{CreateUpstreamRequestBody, UpstreamTargetInfo as ReqTarget}, create_upstream::{CreateUpstreamRequestBody, UpstreamTargetInfo as ReqTarget},
get_upstream_router, get_upstream_router,
}, },
services::get_app_service, services::{agent_client::MockAgentService, get_mock_app_service},
}; };
fn get_router_with_state(db: DatabaseConnection) -> axum::Router { fn get_router_with_state(db: DatabaseConnection) -> axum::Router {
let program_settings = ProgramSettings::mock(); let program_settings = ProgramSettings::mock();
let app_service = get_app_service(&Arc::new(db.clone()), &program_settings); 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 { let state = Arc::new(crate::routes::AppState {
database_connection: Arc::new(db), database_connection: Arc::new(db),
service: Arc::new(app_service), service: Arc::new(app_service),
@@ -174,6 +209,10 @@ mod tests {
let db = MockDatabase::new(DatabaseBackend::Sqlite) let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![vec![up_model.clone()]]) .append_query_results(vec![vec![up_model.clone()]])
.append_query_results(vec![vec![target_model.clone()]]) .append_query_results(vec![vec![target_model.clone()]])
// additional query result for regenerate_and_apply_config -> generate_config
// `find_with_related` returns rows of `(upstream, Option<target>)` which
// the mock DB expects as `(Model, Option<Model>)` per row.
.append_query_results(vec![vec![(up_model.clone(), Some(target_model.clone()))]])
.into_connection(); .into_connection();
let router = get_router_with_state(db.clone()); let router = get_router_with_state(db.clone());
@@ -218,6 +257,85 @@ mod tests {
res.assert_status(StatusCode::UNPROCESSABLE_ENTITY); res.assert_status(StatusCode::UNPROCESSABLE_ENTITY);
} }
#[tokio::test]
async fn handler_create_upstream_agent_error_returns_internal() {
let up_id = uuid::Uuid::new_v4();
let up_model = upstream::Model {
id: up_id,
name: "new_upstream".to_string(),
protocol: "http".to_string(),
algorithm: "round_robin".to_string(),
sticky_session: false,
created_by: Some(uuid::Uuid::new_v4()),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let target_id = uuid::Uuid::new_v4();
let target_model = upstream_target::Model {
id: target_id,
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(),
};
// configure mock agent to error on apply
let mut mock = MockAgentService::new();
mock.expect_validate().returning(|_cfg| Ok(()));
mock.expect_apply().returning(|_cfg| {
Err(
crate::services::agent_client::AgentError::ApplicationFailed(
"internal".to_string(),
"Failed to communicate with the agent.".to_string(),
),
)
});
let mock_agent = Arc::new(mock);
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![vec![up_model.clone()]])
.append_query_results(vec![vec![target_model.clone()]])
.append_query_results(vec![vec![(up_model.clone(), Some(target_model.clone()))]])
.into_connection();
let program_settings = ProgramSettings::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),
});
let router = get_upstream_router(state).layer(axum::middleware::from_fn(
crate::middlewares::require_auth::mock::mock_require_auth,
));
let server = TestServer::new(router).expect("failed to create test server");
let payload = CreateUpstreamRequestBody {
name: "new_upstream".to_string(),
protocol: "http".to_string(),
algorithm: None,
sticky_session: None,
upstream_targets: vec![ReqTarget {
host: "127.0.0.1".to_string(),
port: 8080,
weight: None,
is_backup: None,
enabled: None,
}],
};
let res = server.post("/upstreams").json(&payload).await;
res.assert_status(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
}
#[tokio::test] #[tokio::test]
async fn handler_create_upstream_unauthenticated_returns_unauthorized() { async fn handler_create_upstream_unauthenticated_returns_unauthorized() {
let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection(); let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection();

View File

@@ -1,12 +1,17 @@
use std::sync::Arc; use std::sync::Arc;
use axum::{Json, extract::State, response::Result as AxumResult}; use axum::{Json, extract::State, response::Result as AxumResult};
use sea_orm::TransactionTrait;
use crate::{ use crate::{
errors::api_error::ApiError, errors::api_error::ApiError,
middlewares::request_info::AuthenticatedRequestInfo, middlewares::request_info::AuthenticatedRequestInfo,
routes::{ routes::{
AppState, api::restricted::nginx::upstream::info::response::UpstreamTargetInfoResponse, AppState,
api::{
openapi::tag::NGINX_TAG,
restricted::nginx::upstream::info::response::UpstreamTargetInfoResponse,
},
}, },
services::nginx::info::upstream_target::UpstreamTargetCreateInfo, services::nginx::info::upstream_target::UpstreamTargetCreateInfo,
}; };
@@ -44,6 +49,18 @@ impl From<CreateUpstreamTargetInfo> for ConcreteCreateUpstreamTargetInfo {
} }
#[axum::debug_handler] #[axum::debug_handler]
#[utoipa::path(
post,
path = "/api/nginx/upstreams/{upstream_id}/targets",
request_body = CreateUpstreamTargetInfo,
responses(
(status = 200, description = "Upstream target created successfully", body = UpstreamTargetInfoResponse),
(status = 401, description = "Unauthorized"),
(status = 422, description = "Invalid request"),
(status = 500, description = "Internal server error"),
),
tag = NGINX_TAG,
)]
pub async fn add_upstream_target( pub async fn add_upstream_target(
_request_info: AuthenticatedRequestInfo, _request_info: AuthenticatedRequestInfo,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
@@ -61,10 +78,19 @@ pub async fn add_upstream_target(
upstream_id: concrete_payload.upstream_id, upstream_id: concrete_payload.upstream_id,
}; };
let mut tx = state.database_connection.begin().await?;
let upstream_info = upstream_service let upstream_info = upstream_service
.create_upstream_target(create_info, None) .create_upstream_target(create_info, Some(&mut tx))
.await?; .await?;
state
.service
.nginx
.regenerate_and_apply_config(state.service.agent_client.clone(), Some(&mut tx))
.await?;
tx.commit().await?;
Ok(Json(upstream_info.into())) Ok(Json(upstream_info.into()))
} }
@@ -76,7 +102,7 @@ mod tests {
use axum_test::TestServer; use axum_test::TestServer;
use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase}; use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase};
use database::generated::entities::upstream_target; use database::generated::entities::{upstream, upstream_target};
use crate::{ use crate::{
configs::{FromConfig, ProgramSettings}, configs::{FromConfig, ProgramSettings},
@@ -84,12 +110,17 @@ mod tests {
routes::api::restricted::nginx::upstream::{ routes::api::restricted::nginx::upstream::{
create_upstream_target::CreateUpstreamTargetInfo, get_upstream_router, create_upstream_target::CreateUpstreamTargetInfo, get_upstream_router,
}, },
services::get_app_service, services::{agent_client::MockAgentService, get_mock_app_service},
}; };
fn get_router_with_state(db: DatabaseConnection) -> axum::Router { fn get_router_with_state(db: DatabaseConnection) -> axum::Router {
let program_settings = ProgramSettings::mock(); let program_settings = ProgramSettings::mock();
let app_service = get_app_service(&Arc::new(db.clone()), &program_settings); 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 { let state = Arc::new(crate::routes::AppState {
database_connection: Arc::new(db), database_connection: Arc::new(db),
service: Arc::new(app_service), service: Arc::new(app_service),
@@ -100,6 +131,83 @@ mod tests {
)) ))
} }
#[tokio::test]
async fn handler_add_upstream_target_agent_error_returns_internal() {
let up_id = uuid::Uuid::new_v4();
let target_id = uuid::Uuid::new_v4();
let target_model = upstream_target::Model {
id: target_id,
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 up_model = upstream::Model {
id: up_id,
name: "test_upstream".to_string(),
protocol: "http".to_string(),
algorithm: "round_robin".to_string(),
sticky_session: false,
created_by: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
// configure mock agent to return an error on apply
let mut mock = MockAgentService::new();
mock.expect_validate().returning(|_cfg| Ok(()));
mock.expect_apply().returning(|_cfg| {
Err(
crate::services::agent_client::AgentError::ApplicationFailed(
"internal".to_string(),
"Failed to communicate with the agent.".to_string(),
),
)
});
let mock_agent = Arc::new(mock);
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![vec![target_model.clone()]])
.append_query_results(vec![vec![(up_model.clone(), Some(target_model.clone()))]])
.into_connection();
let program_settings = ProgramSettings::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),
});
let router = get_upstream_router(state).layer(axum::middleware::from_fn(
crate::middlewares::require_auth::mock::mock_require_auth,
));
let server = TestServer::new(router).expect("failed to create test server");
let payload = CreateUpstreamTargetInfo {
upstream_id: up_id,
host: "127.0.0.1".to_string(),
port: 8080,
weight: None,
is_backup: None,
enabled: None,
};
let res = server
.post(&format!("/upstreams/{}/targets", up_id))
.json(&payload)
.await;
res.assert_status(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
}
#[tokio::test] #[tokio::test]
async fn handler_add_upstream_target_succeeds_returns_created() { async fn handler_add_upstream_target_succeeds_returns_created() {
let up_id = uuid::Uuid::new_v4(); let up_id = uuid::Uuid::new_v4();
@@ -117,8 +225,21 @@ mod tests {
updated_at: chrono::Utc::now(), updated_at: chrono::Utc::now(),
}; };
let up_model = upstream::Model {
id: up_id,
name: "test_upstream".to_string(),
protocol: "http".to_string(),
algorithm: "round_robin".to_string(),
sticky_session: false,
created_by: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let db = MockDatabase::new(DatabaseBackend::Sqlite) let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![vec![target_model.clone()]]) .append_query_results(vec![vec![target_model.clone()]])
// additional query result for regenerate_and_apply_config -> generate_config
.append_query_results(vec![vec![(up_model.clone(), Some(target_model.clone()))]])
.into_connection(); .into_connection();
let router = get_router_with_state(db.clone()); let router = get_router_with_state(db.clone());

View File

@@ -14,6 +14,7 @@ use crate::{
AppState, AppState,
api::{ api::{
helper::pagination::{ExtractPagination, PaginationInfo}, helper::pagination::{ExtractPagination, PaginationInfo},
openapi::tag::NGINX_TAG,
restricted::nginx::upstream::info::response::{ restricted::nginx::upstream::info::response::{
UpstreamInfoResponse, UpstreamListResponse, UpstreamInfoResponse, UpstreamListResponse,
}, },
@@ -39,27 +40,62 @@ impl From<GetUpstreamParams> for ConcreteGetUpstreamParams {
} }
} }
#[utoipa::path(
get,
path = "/api/nginx/upstreams",
responses(
(status = 200, description = "List upstreams", body = UpstreamListResponse),
(status = 500, description = "Internal server error"),
),
tag = NGINX_TAG,
)]
pub async fn get_upstream_list( pub async fn get_upstream_list(
ExtractPagination(pagination): ExtractPagination, ExtractPagination(pagination): ExtractPagination,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
) -> AxumResult<Json<UpstreamListResponse>, ServiceError> { ) -> AxumResult<Json<UpstreamListResponse>, ServiceError> {
let upstream_service = &state.service.nginx.get_upstream_service(); let upstream_service = &state.service.nginx.get_upstream_service();
let upstreams = upstream_service
.get_upstreams(Some(pagination.clone().into()), None) let (upstreams_res, upstream_count_res) = tokio::join!(
.await?; upstream_service.get_upstreams(
Some(pagination.clone().into()),
Some(GetUpstreamOptions {
include_targets: true,
filter_by_enabled: false,
}),
None,
),
upstream_service.get_total_upstreams(None, None),
);
let upstreams = upstreams_res?;
let upstream_count = upstream_count_res?;
// //
Ok(Json(UpstreamListResponse { Ok(Json(UpstreamListResponse {
items: upstreams.into_iter().map(|u| u.into()).collect(), items: upstreams.into_iter().map(|u| u.into()).collect(),
pagination: PaginationInfo { pagination: PaginationInfo {
total_items: 0, total_items: upstream_count,
total_pages: 0, total_pages: if upstream_count == 0 {
0
} else {
(upstream_count as f32 / pagination.per_page as f32).ceil() as u32
},
current_page: pagination.page, current_page: pagination.page,
per_page: pagination.per_page, per_page: pagination.per_page,
}, },
})) }))
} }
#[utoipa::path(
get,
path = "/api/nginx/upstreams/{upstream_id}",
responses(
(status = 200, description = "Get upstream info", body = UpstreamInfoResponse),
(status = 404, description = "Not found"),
(status = 500, description = "Internal server error"),
),
tag = NGINX_TAG,
)]
pub async fn get_upstream( pub async fn get_upstream(
Path(upstream_id): Path<Uuid>, Path(upstream_id): Path<Uuid>,
Query(params): Query<GetUpstreamParams>, Query(params): Query<GetUpstreamParams>,
@@ -73,6 +109,7 @@ pub async fn get_upstream(
upstream_id, upstream_id,
Some(GetUpstreamOptions { Some(GetUpstreamOptions {
include_targets: true, include_targets: true,
filter_by_enabled: false,
}), }),
None, None,
) )
@@ -90,19 +127,21 @@ pub async fn get_upstream(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::sync::Arc; use std::{collections::BTreeMap, sync::Arc};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum_test::TestServer; use axum_test::TestServer;
use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase}; use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, Value};
use database::generated::entities::{upstream, upstream_target}; use database::generated::entities::{upstream, upstream_target};
use crate::configs::{FromConfig, ProgramSettings}; use crate::{
configs::{FromConfig, ProgramSettings},
use crate::routes::api::restricted::nginx::upstream::get_upstream_router; routes::api::restricted::nginx::upstream::{
use crate::routes::api::restricted::nginx::upstream::info::response::UpstreamInfoResponse; get_upstream_router, info::response::UpstreamInfoResponse,
use crate::services::get_app_service; },
services::get_app_service,
};
fn get_router_with_state(db: DatabaseConnection) -> axum::Router { fn get_router_with_state(db: DatabaseConnection) -> axum::Router {
let program_settings = ProgramSettings::mock(); let program_settings = ProgramSettings::mock();
@@ -139,7 +178,14 @@ mod tests {
}; };
let db = MockDatabase::new(DatabaseBackend::Sqlite) let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![vec![u1.clone(), u2.clone()]]) .append_query_results(vec![vec![
(u1.clone(), None::<upstream_target::Model>),
(u2.clone(), None::<upstream_target::Model>),
]])
.append_query_results(vec![vec![BTreeMap::from([(
"count".to_string(),
Value::BigInt(Some(2)),
)])]])
.into_connection(); .into_connection();
let router = get_router_with_state(db.clone()); let router = get_router_with_state(db.clone());
@@ -150,6 +196,7 @@ mod tests {
let body = res.json::<UpstreamListResponse>(); let body = res.json::<UpstreamListResponse>();
assert_eq!(body.items.len(), 2); assert_eq!(body.items.len(), 2);
assert_eq!(body.pagination.current_page, 1u32); assert_eq!(body.pagination.current_page, 1u32);
assert_eq!(body.pagination.total_pages, 1u32);
} }
#[tokio::test] #[tokio::test]
@@ -202,6 +249,10 @@ mod tests {
async fn extractor_pagination_validation_rejects_bad_values() { async fn extractor_pagination_validation_rejects_bad_values() {
let db = MockDatabase::new(DatabaseBackend::Sqlite) let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![Vec::<sea_orm::MockRow>::new()]) .append_query_results(vec![Vec::<sea_orm::MockRow>::new()])
.append_query_results(vec![vec![BTreeMap::from([(
"count".to_string(),
Value::BigInt(Some(0)),
)])]])
.into_connection(); .into_connection();
let router = get_router_with_state(db.clone()); let router = get_router_with_state(db.clone());
let server = TestServer::new(router).expect("failed to create test server"); let server = TestServer::new(router).expect("failed to create test server");
@@ -273,6 +324,10 @@ mod tests {
async fn handler_get_upstream_list_empty_returns_empty_items() { async fn handler_get_upstream_list_empty_returns_empty_items() {
let db = MockDatabase::new(DatabaseBackend::Sqlite) let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![Vec::<sea_orm::MockRow>::new()]) .append_query_results(vec![Vec::<sea_orm::MockRow>::new()])
.append_query_results(vec![vec![BTreeMap::from([(
"count".to_string(),
Value::BigInt(Some(0)),
)])]])
.into_connection(); .into_connection();
let router = get_router_with_state(db.clone()); let router = get_router_with_state(db.clone());

View File

@@ -10,7 +10,13 @@ use uuid::Uuid;
use crate::{ use crate::{
errors::api_error::ApiError, errors::api_error::ApiError,
routes::{AppState, api::restricted::nginx::upstream::info::response::UpstreamTargetInfo}, routes::{
AppState,
api::{
openapi::tag::NGINX_TAG,
restricted::nginx::upstream::info::response::UpstreamTargetInfo,
},
},
}; };
#[derive(Serialize, Deserialize, utoipa::ToSchema)] #[derive(Serialize, Deserialize, utoipa::ToSchema)]
@@ -30,6 +36,16 @@ impl From<GetUpstreamTargetsParams> for ConcreteGetUpstreamTargetsParams {
} }
} }
#[utoipa::path(
get,
path = "/api/nginx/upstream_targets/{upstream_target_id}",
responses(
(status = 200, description = "Get upstream target info", body = UpstreamTargetInfo),
(status = 404, description = "Not found"),
(status = 500, description = "Internal server error"),
),
tag = NGINX_TAG,
)]
pub async fn get_upstream_target( pub async fn get_upstream_target(
Path(upstream_target_id): Path<Uuid>, Path(upstream_target_id): Path<Uuid>,
Query(params): Query<GetUpstreamTargetsParams>, Query(params): Query<GetUpstreamTargetsParams>,

View File

@@ -162,3 +162,71 @@ impl From<crate::services::nginx::info::upstream_target::UpstreamTargetInfo>
} }
} }
} }
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
pub struct UpdateUpstreamInfoResponse {
pub id: uuid::Uuid,
pub name: String,
pub protocol: String,
pub algorithm: String,
pub sticky_session: bool,
pub created_by: Option<uuid::Uuid>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
//
pub upstream_targets: Vec<UpstreamTargetBasicInfo>,
}
impl From<crate::services::nginx::info::upstream::UpstreamInfo> for UpdateUpstreamInfoResponse {
fn from(info: crate::services::nginx::info::upstream::UpstreamInfo) -> Self {
Self {
id: info.id,
name: info.name,
protocol: info.protocol,
algorithm: info.algorithm,
sticky_session: info.sticky_session,
created_by: info.created_by,
created_at: info.created_at,
updated_at: info.updated_at,
upstream_targets: info
.upstream_targets
.into_iter()
.map(|t| t.into())
.collect(),
}
}
}
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
pub struct UpdateUpstreamTargetInfoResponse {
pub id: uuid::Uuid,
pub host: String,
pub port: i64,
pub enabled: bool,
pub is_backup: bool,
pub weight: i32,
//
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
//
pub upstream_id: Uuid,
}
impl From<crate::services::nginx::info::upstream_target::UpstreamTargetInfo>
for UpdateUpstreamTargetInfoResponse
{
fn from(info: crate::services::nginx::info::upstream_target::UpstreamTargetInfo) -> Self {
Self {
id: info.id,
host: info.target_host,
port: info.target_port,
enabled: info.enabled,
is_backup: info.is_backup,
weight: info.weight as i32,
//
created_at: info.created_at,
updated_at: info.updated_at,
upstream_id: info.upstream_id,
}
}
}

View File

@@ -0,0 +1,238 @@
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},
};
#[utoipa::path(
delete,
path = "/api/nginx/upstreams/{upstream_id}",
responses(
(status = 200, description = "Upstream 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_upstream(
_request_info: AuthenticatedRequestInfo,
Path(upstream_id): Path<Uuid>,
State(state): State<Arc<AppState>>,
) -> AxumResult<Json<()>, ApiError> {
let upstream_service = &state.service.nginx.get_upstream_service();
let mut tx = state.database_connection.begin().await?;
upstream_service
.delete_upstream(upstream_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::{upstream, upstream_target};
use crate::{
configs::{FromConfig, ProgramSettings},
middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER,
routes::api::restricted::nginx::upstream::get_upstream_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_upstream_router(state).layer(axum::middleware::from_fn(
crate::middlewares::require_auth::mock::mock_require_auth,
))
}
#[tokio::test]
async fn handler_remove_upstream_succeeds_returns_ok() {
let up_id = uuid::Uuid::new_v4();
let existing = upstream::Model {
id: up_id,
name: "todelete".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![(existing.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!("/upstreams/{}", up_id)).await;
res.assert_status_ok();
}
#[tokio::test]
async fn handler_remove_upstream_agent_error_returns_internal() {
let up_id = uuid::Uuid::new_v4();
let existing = upstream::Model {
id: up_id,
name: "todelete".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 mut mock = MockAgentService::new();
mock.expect_validate().returning(|_cfg| Ok(()));
mock.expect_apply().returning(|_cfg| {
Err(
crate::services::agent_client::AgentError::ApplicationFailed(
"internal".to_string(),
"Failed to communicate with the agent.".to_string(),
),
)
});
let mock_agent = Arc::new(mock);
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,
},
])
.append_query_results(vec![vec![(existing.clone(), Some(target_model.clone()))]])
.into_connection();
let program_settings = ProgramSettings::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),
});
let router = get_upstream_router(state).layer(axum::middleware::from_fn(
crate::middlewares::require_auth::mock::mock_require_auth,
));
let server = TestServer::new(router).expect("failed to create test server");
let res = server.delete(&format!("/upstreams/{}", up_id)).await;
res.assert_status(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
}
#[tokio::test]
async fn handler_remove_upstream_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!("/upstreams/{}", uuid::Uuid::new_v4()))
.add_header(REQUEST_AUTH_USER_INVALID_HEADER, "true")
.await;
res.assert_status(StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn handler_remove_upstream_not_found_returns_not_found() {
let empty_results: Vec<Vec<upstream::Model>> = vec![Vec::<upstream::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!("/upstreams/{}", uuid::Uuid::new_v4()))
.await;
res.assert_status(StatusCode::NOT_FOUND);
}
}

View File

@@ -0,0 +1,230 @@
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},
};
#[utoipa::path(
delete,
path = "/api/nginx/upstream_targets/{upstream_target_id}",
responses(
(status = 200, description = "Upstream target 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_upstream_target(
_request_info: AuthenticatedRequestInfo,
Path(upstream_target_id): Path<Uuid>,
State(state): State<Arc<AppState>>,
) -> AxumResult<Json<()>, ApiError> {
let upstream_service = &state.service.nginx.get_upstream_service();
let mut tx = state.database_connection.begin().await?;
upstream_service
.delete_upstream_target(upstream_target_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::{upstream, upstream_target};
use crate::{
configs::{FromConfig, ProgramSettings},
middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER,
routes::api::restricted::nginx::upstream::get_upstream_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_upstream_router(state).layer(axum::middleware::from_fn(
crate::middlewares::require_auth::mock::mock_require_auth,
))
}
#[tokio::test]
async fn handler_remove_upstream_target_succeeds_returns_ok() {
let ut_id = uuid::Uuid::new_v4();
let current_model = upstream_target::Model {
id: ut_id,
upstream_id: uuid::Uuid::new_v4(),
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(),
};
// first find_by_id, then delete (delete typically doesn't return models)
let up_model = upstream::Model {
id: current_model.upstream_id,
name: "test_upstream".to_string(),
protocol: "http".to_string(),
algorithm: "round_robin".to_string(),
sticky_session: false,
created_by: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let first: Vec<Vec<upstream_target::Model>> = vec![vec![current_model.clone()]];
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(first)
.append_exec_results(vec![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(current_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!("/upstream_targets/{}", ut_id)).await;
res.assert_status_ok();
}
#[tokio::test]
async fn handler_remove_upstream_target_agent_error_returns_internal() {
let ut_id = uuid::Uuid::new_v4();
let current_model = upstream_target::Model {
id: ut_id,
upstream_id: uuid::Uuid::new_v4(),
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 up_model = upstream::Model {
id: current_model.upstream_id,
name: "test_upstream".to_string(),
protocol: "http".to_string(),
algorithm: "round_robin".to_string(),
sticky_session: false,
created_by: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let mut mock = MockAgentService::new();
mock.expect_validate().returning(|_cfg| Ok(()));
mock.expect_apply().returning(|_cfg| {
Err(
crate::services::agent_client::AgentError::ApplicationFailed(
"internal".to_string(),
"Failed to communicate with the agent.".to_string(),
),
)
});
let mock_agent = Arc::new(mock);
let first: Vec<Vec<upstream_target::Model>> = vec![vec![current_model.clone()]];
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(first)
.append_exec_results(vec![MockExecResult {
rows_affected: 1,
last_insert_id: 0,
}])
.append_query_results(vec![vec![(up_model.clone(), Some(current_model.clone()))]])
.into_connection();
let program_settings = ProgramSettings::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),
});
let router = get_upstream_router(state).layer(axum::middleware::from_fn(
crate::middlewares::require_auth::mock::mock_require_auth,
));
let server = TestServer::new(router).expect("failed to create test server");
let res = server.delete(&format!("/upstream_targets/{}", ut_id)).await;
res.assert_status(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
}
#[tokio::test]
async fn handler_remove_upstream_target_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!("/upstream_targets/{}", uuid::Uuid::new_v4()))
.add_header(REQUEST_AUTH_USER_INVALID_HEADER, "true")
.await;
res.assert_status(StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn handler_remove_upstream_target_not_found_returns_not_found() {
let empty_results: Vec<Vec<upstream_target::Model>> =
vec![Vec::<upstream_target::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!("/upstream_targets/{}", uuid::Uuid::new_v4()))
.await;
res.assert_status(StatusCode::NOT_FOUND);
}
}

View File

@@ -0,0 +1,318 @@
use std::sync::Arc;
use axum::{
Json,
extract::{Path, State},
response::Result as AxumResult,
};
use sea_orm::TransactionTrait;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{
errors::api_error::ApiError,
middlewares::request_info::AuthenticatedRequestInfo,
routes::{
AppState, api::openapi::tag::NGINX_TAG,
api::restricted::nginx::upstream::info::response::UpdateUpstreamInfoResponse,
},
services::nginx::info::upstream::UpdateUpstreamInfo,
};
#[derive(Deserialize, utoipa::ToSchema, Serialize)]
pub struct UpstreamTargetBasicUpdateInfo {
pub id: i64,
pub enabled: bool,
}
#[derive(Deserialize, utoipa::ToSchema, Serialize)]
pub struct UpdateUpstreamRequestBody {
pub name: Option<String>,
pub protocol: Option<String>,
pub algorithm: Option<String>,
pub sticky_session: Option<bool>,
// only updates upstream targets' enabled status for now
pub upstream_targets: Option<Vec<UpstreamTargetBasicUpdateInfo>>,
}
impl From<UpdateUpstreamRequestBody> for UpdateUpstreamInfo {
fn from(val: UpdateUpstreamRequestBody) -> Self {
Self {
name: val.name,
protocol: val.protocol,
algorithm: val.algorithm,
sticky_session: val.sticky_session,
//
upstream_targets: None,
}
}
}
#[utoipa::path(
patch,
path = "/api/nginx/upstreams/{upstream_id}",
request_body = UpdateUpstreamRequestBody,
responses(
(status = 200, description = "Upstream updated successfully", body = UpdateUpstreamInfoResponse),
(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_upstream(
_request_info: AuthenticatedRequestInfo,
Path(upstream_id): Path<Uuid>,
State(state): State<Arc<AppState>>,
Json(payload): Json<UpdateUpstreamRequestBody>,
) -> AxumResult<Json<UpdateUpstreamInfoResponse>, ApiError> {
let upstream_service = &state.service.nginx.get_upstream_service();
let update_info: UpdateUpstreamInfo = payload.into();
let mut tx = state.database_connection.begin().await?;
let r = upstream_service
.update_upstream(upstream_id, update_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(r.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::{upstream, upstream_target};
use super::UpdateUpstreamRequestBody;
use crate::{
configs::{FromConfig, ProgramSettings},
middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER,
routes::api::restricted::nginx::upstream::get_upstream_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_upstream_router(state).layer(axum::middleware::from_fn(
crate::middlewares::require_auth::mock::mock_require_auth,
))
}
#[tokio::test]
async fn handler_update_upstream_succeeds_returns_ok() {
let up_id = uuid::Uuid::new_v4();
let current_model = upstream::Model {
id: up_id,
name: "old_upstream".to_string(),
protocol: "http".to_string(),
algorithm: "round_robin".to_string(),
sticky_session: false,
created_by: Some(uuid::Uuid::new_v4()),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let updated_model = upstream::Model {
id: up_id,
name: "updated_upstream".to_string(),
protocol: "http".to_string(),
algorithm: "round_robin".to_string(),
sticky_session: false,
created_by: Some(uuid::Uuid::new_v4()),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
// first find_by_id, then update returns updated model
let up_model = current_model.clone();
let first: Vec<Vec<upstream::Model>> = vec![vec![current_model.clone()]];
let second: Vec<Vec<upstream::Model>> = vec![vec![updated_model.clone()]];
let 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(),
Option::<upstream_target::Model>::None,
)]])
.into_connection();
let router = get_router_with_state(db.clone());
let server = TestServer::new(router).expect("failed to create test server");
let payload = UpdateUpstreamRequestBody {
name: Some("updated_upstream".to_string()),
protocol: None,
algorithm: None,
sticky_session: None,
upstream_targets: None,
};
let res = server
.patch(&format!("/upstreams/{}", up_id))
.json(&payload)
.await;
res.assert_status_ok();
let text = res.text();
let body: crate::routes::api::restricted::nginx::upstream::info::response::UpdateUpstreamInfoResponse =
serde_json::from_str(&text).expect("failed to parse json");
assert_eq!(body.id, up_id);
assert_eq!(body.name, "updated_upstream");
}
#[tokio::test]
async fn handler_update_upstream_agent_error_returns_internal() {
let up_id = uuid::Uuid::new_v4();
let current_model = upstream::Model {
id: up_id,
name: "old_upstream".to_string(),
protocol: "http".to_string(),
algorithm: "round_robin".to_string(),
sticky_session: false,
created_by: Some(uuid::Uuid::new_v4()),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let updated_model = upstream::Model {
id: up_id,
name: "updated_upstream".to_string(),
protocol: "http".to_string(),
algorithm: "round_robin".to_string(),
sticky_session: false,
created_by: Some(uuid::Uuid::new_v4()),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let up_model = current_model.clone();
let mut mock = MockAgentService::new();
mock.expect_validate().returning(|_cfg| Ok(()));
mock.expect_apply().returning(|_cfg| {
Err(
crate::services::agent_client::AgentError::ApplicationFailed(
"internal".to_string(),
"Failed to communicate with the agent.".to_string(),
),
)
});
let mock_agent = Arc::new(mock);
let first: Vec<Vec<upstream::Model>> = vec![vec![current_model.clone()]];
let second: Vec<Vec<upstream::Model>> = vec![vec![updated_model.clone()]];
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(first)
.append_query_results(second)
.append_query_results(vec![vec![(
up_model.clone(),
Option::<upstream_target::Model>::None,
)]])
.into_connection();
let program_settings = ProgramSettings::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),
});
let router = get_upstream_router(state).layer(axum::middleware::from_fn(
crate::middlewares::require_auth::mock::mock_require_auth,
));
let server = TestServer::new(router).expect("failed to create test server");
let payload = UpdateUpstreamRequestBody {
name: Some("updated_upstream".to_string()),
protocol: None,
algorithm: None,
sticky_session: None,
upstream_targets: None,
};
let res = server
.patch(&format!("/upstreams/{}", up_id))
.json(&payload)
.await;
res.assert_status(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
}
#[tokio::test]
async fn handler_update_upstream_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 = UpdateUpstreamRequestBody {
name: Some("updated_upstream".to_string()),
protocol: None,
algorithm: None,
sticky_session: None,
upstream_targets: None,
};
let res = server
.patch(&format!("/upstreams/{}", 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_upstream_not_found_returns_not_found() {
let empty_results: Vec<Vec<upstream::Model>> = vec![Vec::<upstream::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 = UpdateUpstreamRequestBody {
name: Some("updated_upstream".to_string()),
protocol: None,
algorithm: None,
sticky_session: None,
upstream_targets: None,
};
let res = server
.patch(&format!("/upstreams/{}", uuid::Uuid::new_v4()))
.json(&payload)
.await;
res.assert_status(StatusCode::NOT_FOUND);
}
}

View File

@@ -0,0 +1,348 @@
use std::sync::Arc;
use axum::{
Json,
extract::{Path, State},
response::Result as AxumResult,
};
use sea_orm::TransactionTrait;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{
errors::api_error::ApiError,
middlewares::request_info::AuthenticatedRequestInfo,
routes::{
AppState, api::openapi::tag::NGINX_TAG,
api::restricted::nginx::upstream::info::response::UpdateUpstreamTargetInfoResponse,
},
services::nginx::info::upstream_target::UpdateUpstreamTargetInfo,
};
#[derive(Deserialize, utoipa::ToSchema, Serialize)]
pub struct UpdateUpstreamTargetRequestBody {
pub host: Option<String>,
pub port: Option<i64>,
pub enabled: Option<bool>,
pub is_backup: Option<bool>,
pub weight: Option<i32>,
}
impl From<UpdateUpstreamTargetRequestBody> for UpdateUpstreamTargetInfo {
fn from(val: UpdateUpstreamTargetRequestBody) -> Self {
Self {
target_host: val.host,
target_port: val.port,
enabled: val.enabled,
is_backup: val.is_backup,
weight: val.weight.map(|w| w as i64),
}
}
}
#[utoipa::path(
patch,
path = "/api/nginx/upstream_targets/{upstream_target_id}",
request_body = UpdateUpstreamTargetRequestBody,
responses(
(status = 200, description = "Upstream target updated successfully", body = UpdateUpstreamTargetInfoResponse),
(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_upstream_target(
_request_info: AuthenticatedRequestInfo,
Path(upstream_target_id): Path<Uuid>,
State(state): State<Arc<AppState>>,
Json(payload): Json<UpdateUpstreamTargetRequestBody>,
) -> AxumResult<Json<UpdateUpstreamTargetInfoResponse>, ApiError> {
let upstream_service = &state.service.nginx.get_upstream_service();
let update_info: UpdateUpstreamTargetInfo = payload.into();
let mut tx = state.database_connection.begin().await?;
let r = upstream_service
.update_upstream_target(upstream_target_id, update_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(r.into()))
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use axum::http::StatusCode;
use axum::routing::patch;
use axum_test::TestServer;
use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase};
use database::generated::entities::{upstream, upstream_target};
use super::UpdateUpstreamTargetRequestBody;
use crate::{
configs::{FromConfig, ProgramSettings},
middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER,
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),
});
axum::Router::new()
.route(
"/upstream_targets/{upstream_target_id}",
patch(crate::routes::api::restricted::nginx::upstream::update_upstream_target::update_upstream_target),
)
.with_state(state)
.layer(axum::middleware::from_fn(
crate::middlewares::require_auth::mock::mock_require_auth,
))
}
#[tokio::test]
async fn handler_update_upstream_target_succeeds_returns_ok() {
let target_id = uuid::Uuid::new_v4();
let current_model = upstream_target::Model {
id: target_id,
upstream_id: uuid::Uuid::new_v4(),
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 updated_model = upstream_target::Model {
id: target_id,
upstream_id: current_model.upstream_id,
target_host: "127.0.0.1".to_string(),
target_port: 8081,
weight: 2,
is_backup: false,
enabled: false,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let up_model = upstream::Model {
id: current_model.upstream_id,
name: "test_upstream".to_string(),
protocol: "http".to_string(),
algorithm: "round_robin".to_string(),
sticky_session: false,
created_by: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let first: Vec<Vec<upstream_target::Model>> = vec![vec![current_model.clone()]];
let second: Vec<Vec<upstream_target::Model>> = vec![vec![updated_model.clone()]];
// additional query result for regenerate_and_apply_config -> generate_config
let third: Vec<Vec<(upstream::Model, Option<upstream_target::Model>)>> =
vec![vec![(up_model.clone(), Some(updated_model.clone()))]];
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(first)
.append_query_results(second)
.append_query_results(third)
.into_connection();
let router = get_router_with_state(db.clone());
let server = TestServer::new(router).expect("failed to create test server");
let payload = UpdateUpstreamTargetRequestBody {
host: None,
port: Some(8081),
enabled: Some(false),
is_backup: None,
weight: Some(2),
};
let res = server
.patch(&format!("/upstream_targets/{}", target_id))
.json(&payload)
.await;
res.assert_status_ok();
let text = res.text();
let body: crate::routes::api::restricted::nginx::upstream::info::response::UpdateUpstreamTargetInfoResponse =
serde_json::from_str(&text).expect("failed to parse json");
assert_eq!(body.id, target_id);
assert_eq!(body.port, 8081);
assert!(!body.enabled);
}
#[tokio::test]
async fn handler_update_upstream_target_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 = UpdateUpstreamTargetRequestBody {
host: None,
port: Some(8081),
enabled: Some(false),
is_backup: None,
weight: Some(2),
};
let res = server
.patch(&format!("/upstream_targets/{}", 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_upstream_target_not_found_returns_not_found() {
let empty_results: Vec<Vec<upstream_target::Model>> =
vec![Vec::<upstream_target::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 = UpdateUpstreamTargetRequestBody {
host: None,
port: Some(8081),
enabled: Some(false),
is_backup: None,
weight: Some(2),
};
let res = server
.patch(&format!("/upstream_targets/{}", uuid::Uuid::new_v4()))
.json(&payload)
.await;
res.assert_status(StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn handler_update_upstream_target_agent_error_returns_internal() {
let target_id = uuid::Uuid::new_v4();
let current_model = upstream_target::Model {
id: target_id,
upstream_id: uuid::Uuid::new_v4(),
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 updated_model = upstream_target::Model {
id: target_id,
upstream_id: current_model.upstream_id,
target_host: "127.0.0.1".to_string(),
target_port: 8081,
weight: 2,
is_backup: false,
enabled: false,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let up_model = upstream::Model {
id: current_model.upstream_id,
name: "test_upstream".to_string(),
protocol: "http".to_string(),
algorithm: "round_robin".to_string(),
sticky_session: false,
created_by: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let mut mock = MockAgentService::new();
mock.expect_validate().returning(|_cfg| Ok(()));
mock.expect_apply().returning(|_cfg| {
Err(
crate::services::agent_client::AgentError::ApplicationFailed(
"internal".to_string(),
"Failed to communicate with the agent.".to_string(),
),
)
});
let mock_agent = Arc::new(mock);
let first: Vec<Vec<upstream_target::Model>> = vec![vec![current_model.clone()]];
let second: Vec<Vec<upstream_target::Model>> = vec![vec![updated_model.clone()]];
let third: Vec<Vec<(upstream::Model, Option<upstream_target::Model>)>> =
vec![vec![(up_model.clone(), Some(updated_model.clone()))]];
let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(first)
.append_query_results(second)
.append_query_results(third)
.into_connection();
let program_settings = ProgramSettings::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),
});
let router = axum::Router::new()
.route(
"/upstream_targets/{upstream_target_id}",
axum::routing::patch(crate::routes::api::restricted::nginx::upstream::update_upstream_target::update_upstream_target),
)
.with_state(state)
.layer(axum::middleware::from_fn(
crate::middlewares::require_auth::mock::mock_require_auth,
));
let server = TestServer::new(router).expect("failed to create test server");
let payload = UpdateUpstreamTargetRequestBody {
host: None,
port: Some(8081),
enabled: Some(false),
is_backup: None,
weight: Some(2),
};
let res = server
.patch(&format!("/upstream_targets/{}", target_id))
.json(&payload)
.await;
res.assert_status(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
}
}

View File

@@ -1,7 +1,7 @@
use std::sync::Arc; use std::sync::Arc;
use axum::{ use axum::{
Extension, Json, Json,
extract::State, extract::State,
http::StatusCode, http::StatusCode,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
@@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};
use tracing::error; use tracing::error;
use crate::{ use crate::{
middlewares::request_info::RequestInfo, middlewares::request_info::AuthenticatedRequestInfo,
routes::{AppState, api::openapi::tag::USER_TAG}, routes::{AppState, api::openapi::tag::USER_TAG},
}; };
@@ -38,15 +38,9 @@ pub struct UserInfo {
)] )]
pub async fn get_user_info( pub async fn get_user_info(
State(app_state): State<Arc<AppState>>, State(app_state): State<Arc<AppState>>,
request_info: Extension<Arc<RequestInfo>>, request_info: AuthenticatedRequestInfo,
) -> Response { ) -> Response {
let user_id = match request_info.user_id { let user_id = request_info.user_id;
Some(id) => id,
None => {
error!("User ID not found in request info");
return (StatusCode::UNAUTHORIZED).into_response();
}
};
match app_state.service.user.get_user_by_id(user_id, None).await { match app_state.service.user.get_user_by_id(user_id, None).await {
Ok(user) => { Ok(user) => {

View File

@@ -8,10 +8,13 @@ use std::sync::Arc;
use ::agent_client::apis::configuration::Configuration; use ::agent_client::apis::configuration::Configuration;
#[cfg(test)]
use crate::services::agent_client::MockAgentService;
use crate::{ use crate::{
configs::ProgramSettings, configs::ProgramSettings,
routes::{self, AuthState}, routes::{self, AuthState},
services::{ services::{
agent_client::{AgentService, AgentServiceImpl},
auth::{ auth::{
authentication::{AuthenticationServiceImpl, strategies::password::PasswordStrategy}, authentication::{AuthenticationServiceImpl, strategies::password::PasswordStrategy},
user::{UserService, UserServiceImpl}, user::{UserService, UserServiceImpl},
@@ -32,7 +35,7 @@ pub struct AppService {
#[allow(dead_code)] #[allow(dead_code)]
pub nginx: ServiceState<NginxService>, pub nginx: ServiceState<NginxService>,
#[allow(dead_code)] #[allow(dead_code)]
pub agent_client: ServiceState<agent_client::AgentService>, pub agent_client: ServiceState<dyn AgentService>,
} }
pub fn get_app_service( pub fn get_app_service(
@@ -52,8 +55,31 @@ pub fn get_app_service(
}, },
user: Arc::new(UserServiceImpl::new(db_connection.clone())), user: Arc::new(UserServiceImpl::new(db_connection.clone())),
nginx: Arc::new(NginxService::new(db_connection.clone())), nginx: Arc::new(NginxService::new(db_connection.clone())),
agent_client: Arc::new(agent_client::AgentService::new(Configuration::from( agent_client: Arc::new(AgentServiceImpl::new(Configuration::from(
settings.agent.clone(), settings.agent.clone(),
))), ))),
} }
} }
#[cfg(test)]
pub fn get_mock_app_service(
db_connection: &Arc<sea_orm::DatabaseConnection>,
settings: &ProgramSettings,
mock_agent: Arc<MockAgentService>,
) -> AppService {
AppService {
server_state: Arc::new(ServerStateService::new(db_connection.clone())),
settings: Arc::new(SettingsService::new(db_connection.clone())),
auth_state: routes::AuthState {
strategy: routes::AuthStrategy {
password: Arc::new(PasswordStrategy::new(db_connection.clone())),
},
authentication: Arc::new(AuthenticationServiceImpl::new(
settings.auth.jwt_secret.clone(),
)),
},
user: Arc::new(UserServiceImpl::new(db_connection.clone())),
nginx: Arc::new(NginxService::new(db_connection.clone())),
agent_client: mock_agent,
}
}

View File

@@ -1,21 +1,114 @@
use std::sync::Arc; use std::{os::unix::fs::FileTypeExt, sync::Arc};
use agent_client::apis::{ApiClient, configuration::Configuration}; use agent_client::{
use tracing::warn; apis::{
Api, ApiClient, Error as ApiError, ResponseContent,
configuration::Configuration,
nginx_agent_api::{ValidateAndReloadParams, ValidateParams, WriteConfigParams},
},
models::{ValidateAndReloadBody, ValidateBody, WriteConfigBody},
};
use tracing::{error, warn};
use crate::configs::agent::AgentSettings; use crate::{configs::agent::AgentSettings, errors::service_error::ServiceError};
pub struct AgentService { #[derive(Debug)]
pub enum AgentError {
// (internal messages, user-facing messages)
#[allow(dead_code)]
ValidationFailed(String, String),
// (internal messages, user-facing messages)
ApplicationFailed(String, String),
}
impl From<AgentError> for ServiceError {
fn from(err: AgentError) -> Self {
error!("Agent error occurred: {:?}", err);
match err {
AgentError::ValidationFailed(_internal, user) => ServiceError::InternalError(user),
AgentError::ApplicationFailed(_internal, user) => ServiceError::InternalError(user),
}
}
}
impl<T: std::fmt::Debug> From<ResponseContent<T>> for AgentError {
fn from(err: ResponseContent<T>) -> Self {
let ResponseContent {
status,
content,
entity,
} = err;
{
let entity_str = entity
.map(|e| format!("{:?}", e))
.unwrap_or_else(|| "<empty>".to_string());
AgentError::ApplicationFailed(
format!(
"Agent responded with error status {}: {}, entity: {}",
status, content, entity_str
),
"Agent reported an error during operation.".to_string(),
)
}
}
}
impl<T: std::fmt::Debug> From<ApiError<T>> for AgentError {
fn from(err: ApiError<T>) -> Self {
match err {
ApiError::ResponseError(resp) => AgentError::from(resp),
ApiError::Io(err) => AgentError::ApplicationFailed(
format!("IO error during agent communication: {}", err),
"Failed to communicate with the agent.".to_string(),
),
ApiError::Reqwest(err) => AgentError::ApplicationFailed(
format!("Reqwest error during agent communication: {}", err),
"Failed to communicate with the agent.".to_string(),
),
ApiError::Serde(err) => AgentError::ApplicationFailed(
format!("Serialization error during agent communication: {}", err),
"Failed to communicate with the agent.".to_string(),
),
}
}
}
#[cfg_attr(test, mockall::automock)]
#[async_trait::async_trait]
pub trait AgentService: Send + Sync {
#[allow(dead_code)]
fn get_client(&self) -> Arc<ApiClient>;
// TODO: improve error handling and reporting, error reasons
// validate configurations that has been created/updated before the given timestamp
#[allow(dead_code)]
async fn validate(&self, config: &str) -> Result<(), AgentError>;
// validate and apply configurations that has been created/updated before the given timestamp
async fn apply(&self, config: &str) -> Result<(), AgentError>;
}
pub struct AgentServiceImpl {
client: Arc<ApiClient>, client: Arc<ApiClient>,
} }
impl AgentServiceImpl {
pub fn new(config: impl Into<Arc<Configuration>>) -> Self {
let client = ApiClient::new(config.into());
AgentServiceImpl {
client: Arc::new(client),
}
}
}
impl From<AgentSettings> for Configuration { impl From<AgentSettings> for Configuration {
fn from(settings: AgentSettings) -> Self { fn from(settings: AgentSettings) -> Self {
let mut config = Configuration::default(); let mut config = Configuration::default();
let mut builder = reqwest::Client::builder(); let mut builder = reqwest::Client::builder();
let url = settings.socket_path; let url = settings.socket_path;
if url.starts_with("unix://") { // check if the url is a unix socket path
let is_socket = std::fs::metadata(&url).is_ok_and(|m| m.file_type().is_socket());
if is_socket || url.starts_with("unix://") {
builder = builder.unix_socket(url.to_string()); builder = builder.unix_socket(url.to_string());
config.client = builder.build().expect("Failed to build reqwest client"); config.client = builder.build().expect("Failed to build reqwest client");
} else { } else {
@@ -27,17 +120,73 @@ impl From<AgentSettings> for Configuration {
} }
} }
impl AgentService { #[async_trait::async_trait]
pub fn new(config: impl Into<Arc<Configuration>>) -> Self { impl AgentService for AgentServiceImpl {
let client = ApiClient::new(config.into()); fn get_client(&self) -> Arc<ApiClient> {
AgentService { Arc::clone(&self.client)
client: Arc::new(client), }
async fn validate(&self, config: &str) -> Result<(), AgentError> {
let timestamp = chrono::Utc::now().timestamp_millis();
let name = Self::get_config_name(true);
self._validate(&name, timestamp, config).await
}
async fn apply(&self, config: &str) -> Result<(), AgentError> {
let timestamp = chrono::Utc::now().timestamp_millis();
let name = Self::get_config_name(false);
self._validate(&name, timestamp, config).await?;
self._apply(&name, timestamp).await
} }
} }
#[allow(dead_code)] impl AgentServiceImpl {
pub fn get_client(&self) -> Arc<ApiClient> { fn get_config_name(is_validate_only: bool) -> String {
Arc::clone(&self.client) format!(
"nginx_config_{}{}",
if is_validate_only {
"validation_"
} else {
"application_"
},
uuid::Uuid::new_v4()
)
}
async fn _validate(&self, name: &str, timestamp: i64, config: &str) -> Result<(), AgentError> {
let api = self.client.nginx_agent_api();
api.write_config(WriteConfigParams {
write_config_body: WriteConfigBody {
config_name: name.to_string(),
content: config.to_string(),
timestamp,
},
})
.await?;
api.validate(ValidateParams {
validate_body: ValidateBody {
config_name: name.to_string(),
timestamp,
},
})
.await?;
Ok(())
}
async fn _apply(&self, name: &str, timestamp: i64) -> Result<(), AgentError> {
let api = self.client.nginx_agent_api();
api.validate_and_reload(ValidateAndReloadParams {
validate_and_reload_body: ValidateAndReloadBody {
config_name: name.to_string(),
timestamp,
},
})
.await?;
Ok(())
} }
} }
@@ -56,7 +205,7 @@ mod tests {
#[test] #[test]
fn test_agent_service_creation() { fn test_agent_service_creation() {
let config = Configuration::default(); let config = Configuration::default();
let service = AgentService::new(config); let service = AgentServiceImpl::new(config);
let client = service.get_client(); let client = service.get_client();
assert!(Arc::ptr_eq(&client, &service.client)); assert!(Arc::ptr_eq(&client, &service.client));
} }

View File

@@ -6,14 +6,24 @@ pub mod upstream;
use std::sync::Arc; use std::sync::Arc;
use sea_orm::DatabaseConnection; use sea_orm::{DatabaseConnection, DatabaseTransaction};
use upstream::UpstreamService; use crate::{
errors::service_error::ServiceError,
services::{
agent_client::AgentService,
nginx::{
builder::{NginxConfigBuilder, NginxConfigProvider},
upstream::{UpstreamService, UpstreamServiceImpl},
},
},
};
pub struct NginxService { pub struct NginxService {
#[allow(dead_code)]
connection: Arc<DatabaseConnection>, connection: Arc<DatabaseConnection>,
// //
upstream_service: Arc<UpstreamService>, upstream_service: Arc<dyn UpstreamService>,
} }
impl NginxService { impl NginxService {
@@ -21,11 +31,56 @@ impl NginxService {
Self { Self {
connection: connection.clone(), connection: connection.clone(),
// //
upstream_service: Arc::new(UpstreamService::new(connection.clone())), upstream_service: Arc::new(UpstreamServiceImpl::new(connection.clone())),
} }
} }
pub fn get_upstream_service(&self) -> Arc<UpstreamService> { pub fn get_upstream_service(&self) -> Arc<dyn UpstreamService> {
self.upstream_service.clone() self.upstream_service.clone()
} }
#[allow(dead_code)]
pub async fn validate_config(
&self,
agent: Arc<dyn AgentService>,
config: &str,
) -> Result<(), ServiceError> {
agent.validate(config).await?;
Ok(())
}
pub async fn apply_changes(
&self,
agent: Arc<dyn AgentService>,
config: &str,
) -> Result<(), ServiceError> {
agent.apply(config).await?;
Ok(())
}
pub async fn generate_config(
&self,
tx: Option<&mut DatabaseTransaction>,
) -> Result<String, ServiceError> {
let mut builder = NginxConfigBuilder::default();
self.upstream_service
.generate_config(&mut builder, tx)
.await?;
Ok(builder.to_nginx_config(None))
}
pub async fn regenerate_and_apply_config(
&self,
agent: Arc<dyn AgentService>,
tx: Option<&mut DatabaseTransaction>,
) -> Result<(), ServiceError> {
let config = self.generate_config(tx).await?;
self.apply_changes(agent, &config).await?;
Ok(())
}
} }

View File

@@ -6,17 +6,12 @@ pub trait NginxConfigProvider {
fn to_nginx_config(&self, indent: Option<usize>) -> String; fn to_nginx_config(&self, indent: Option<usize>) -> String;
} }
#[derive(Default)]
pub struct NginxConfigBuilder { pub struct NginxConfigBuilder {
upstreams: Vec<UpstreamInfo>, upstreams: Vec<UpstreamInfo>,
} }
impl NginxConfigBuilder { impl NginxConfigBuilder {
pub fn new() -> Self {
Self {
upstreams: Vec::new(),
}
}
pub fn add_upstream(&mut self, upstream: UpstreamInfo) { pub fn add_upstream(&mut self, upstream: UpstreamInfo) {
self.upstreams.push(upstream); self.upstreams.push(upstream);
} }
@@ -41,7 +36,8 @@ impl NginxConfigProvider for NginxConfigBuilder {
} }
// TODO: Add other sections like servers, locations, etc. // TODO: Add other sections like servers, locations, etc.
// trailing newline for file ending
config.push('\n');
config config
} }
} }

View File

@@ -1,7 +1,8 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use optfield::optfield;
use database::generated::entities::{upstream, upstream_target}; use database::generated::entities::{upstream, upstream_target};
use sea_orm::ActiveValue::{Set, Unchanged};
use tracing::warn;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
@@ -13,15 +14,16 @@ use crate::{
set_if_some, set_if_some,
}; };
#[optfield(pub UpdateUpstreamInfo)] const PLACEHOLDER_TARGET: &str = "server 127.0.0.1:65535 down; # placeholder target";
#[derive(Clone)] #[derive(Clone)]
pub struct UpstreamInfo { pub struct UpstreamInfo {
pub id: uuid::Uuid, pub id: Uuid,
pub name: String, pub name: String,
pub protocol: String, pub protocol: String,
pub algorithm: String, pub algorithm: String,
pub sticky_session: bool, pub sticky_session: bool,
pub created_by: Option<uuid::Uuid>, pub created_by: Option<Uuid>,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
// //
@@ -33,11 +35,21 @@ pub struct UpstreamCreateInfo {
pub protocol: String, pub protocol: String,
pub algorithm: String, pub algorithm: String,
pub sticky_session: bool, pub sticky_session: bool,
pub created_by: Option<uuid::Uuid>, pub created_by: Option<Uuid>,
// //
pub upstream_targets: Vec<upstream_target_info::UpstreamTargetCreateInfo>, pub upstream_targets: Vec<upstream_target_info::UpstreamTargetCreateInfo>,
} }
#[derive(Clone)]
pub struct UpdateUpstreamInfo {
pub name: Option<String>,
pub protocol: Option<String>,
pub algorithm: Option<String>,
pub sticky_session: Option<bool>,
//
pub upstream_targets: Option<Vec<(Uuid, bool)>>,
}
impl NginxConfigProvider for UpstreamInfo { impl NginxConfigProvider for UpstreamInfo {
fn to_nginx_config(&self, indent: Option<usize>) -> String { fn to_nginx_config(&self, indent: Option<usize>) -> String {
let targets_config: Vec<String> = self let targets_config: Vec<String> = self
@@ -46,12 +58,53 @@ impl NginxConfigProvider for UpstreamInfo {
.map(|target| target.to_nginx_config(Some(indent.unwrap_or(0) + INDENT_SIZE))) .map(|target| target.to_nginx_config(Some(indent.unwrap_or(0) + INDENT_SIZE)))
.collect(); .collect();
format!( let mut targets_config_str = {
"upstream {} {{\n{}\n}}", let config_str = match self.algorithm.as_str() {
self.name, "least-conn" => "least_conn",
targets_config.join("\n".indent(indent.unwrap_or(0) + INDENT_SIZE).as_str()) "ip-hash" => "ip_hash",
) "round-robin" => "",
.indent(indent.unwrap_or(0)) v => {
// TODO: allow arbitrary algorithms via config extensions/plugins
warn!(
"Unknown upstream algorithm '{}', defaulting to 'round-robin'",
v
);
""
}
}
.to_string();
// TODO: add support for sticky session / checking for nginx sticky module existence
// if self.sticky_session {
// config_str.push_str("sticky")
// }
if config_str.trim().is_empty() {
String::new()
} else {
config_str + ";"
}
}
.indent(indent.unwrap_or(0) + INDENT_SIZE * 2);
targets_config_str.push('\n');
targets_config_str.push_str(
&(if targets_config.is_empty() {
// add placeholder if no targets
PLACEHOLDER_TARGET.to_string()
} else {
// normal targets
targets_config.join("\n")
}
.indent(indent.unwrap_or(0) + INDENT_SIZE)),
);
// add placeholder if all targets are backup
if self.upstream_targets.iter().all(|v| v.is_backup) {
targets_config_str.push('\n');
targets_config_str
.push_str(&PLACEHOLDER_TARGET.indent(indent.unwrap_or(0) + INDENT_SIZE));
}
format!("upstream {} {{\n{}\n}}", self.name, targets_config_str).indent(indent.unwrap_or(0))
} }
} }
@@ -97,6 +150,17 @@ impl From<upstream::Model> for UpstreamInfo {
} }
} }
impl From<(upstream::Model, Option<Vec<upstream_target::Model>>)> for UpstreamInfo {
fn from(data: (upstream::Model, Option<Vec<upstream_target::Model>>)) -> Self {
let (upstream_model, upstream_target_models) = data;
if let Some(targets) = upstream_target_models {
UpstreamInfo::from((upstream_model, targets))
} else {
UpstreamInfo::from(upstream_model)
}
}
}
impl From<(upstream::Model, Vec<upstream_target::Model>)> for UpstreamInfo { impl From<(upstream::Model, Vec<upstream_target::Model>)> for UpstreamInfo {
fn from(data: (upstream::Model, Vec<upstream_target::Model>)) -> Self { fn from(data: (upstream::Model, Vec<upstream_target::Model>)) -> Self {
let (upstream_model, upstream_target_models) = data; let (upstream_model, upstream_target_models) = data;
@@ -142,18 +206,14 @@ impl From<UpstreamInfo> for (upstream::ActiveModel, Vec<upstream_target::ActiveM
impl UpdateUpstreamInfo { impl UpdateUpstreamInfo {
pub fn apply_to_model(self, current_model: upstream::Model) -> upstream::ActiveModel { pub fn apply_to_model(self, current_model: upstream::Model) -> upstream::ActiveModel {
upstream::ActiveModel { upstream::ActiveModel {
id: sea_orm::ActiveValue::Unchanged(current_model.id), id: Unchanged(current_model.id),
name: set_if_some!(self.name), name: set_if_some!(self.name),
protocol: set_if_some!(self.protocol), protocol: set_if_some!(self.protocol),
algorithm: set_if_some!(self.algorithm), algorithm: set_if_some!(self.algorithm),
sticky_session: set_if_some!(self.sticky_session), sticky_session: set_if_some!(self.sticky_session),
created_by: set_if_some!(if self.created_by.is_some() { created_by: Unchanged(current_model.created_by),
Some(self.created_by) created_at: Unchanged(current_model.created_at),
} else { updated_at: Set(chrono::Utc::now()),
None
}),
created_at: set_if_some!(self.created_at),
updated_at: set_if_some!(self.updated_at),
} }
} }
} }

View File

@@ -1,5 +1,4 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use optfield::optfield;
use sea_orm::ActiveValue::{Set, Unchanged}; use sea_orm::ActiveValue::{Set, Unchanged};
use uuid::Uuid; use uuid::Uuid;
@@ -11,7 +10,6 @@ use crate::{
set_if_some, set_if_some,
}; };
#[optfield(pub UpdateUpstreamTargetInfo)]
#[derive(Clone)] #[derive(Clone)]
pub struct UpstreamTargetInfo { pub struct UpstreamTargetInfo {
pub id: uuid::Uuid, pub id: uuid::Uuid,
@@ -27,6 +25,15 @@ pub struct UpstreamTargetInfo {
pub upstream: Option<UpstreamBasicInfo>, pub upstream: Option<UpstreamBasicInfo>,
} }
#[derive(Clone)]
pub struct UpdateUpstreamTargetInfo {
pub target_host: Option<String>,
pub target_port: Option<i64>,
pub weight: Option<i64>,
pub is_backup: Option<bool>,
pub enabled: Option<bool>,
}
#[derive(Clone)] #[derive(Clone)]
pub struct UpstreamBasicInfo { pub struct UpstreamBasicInfo {
pub id: uuid::Uuid, pub id: uuid::Uuid,
@@ -123,7 +130,7 @@ impl From<UpstreamTargetCreateInfo> for upstream_target::ActiveModel {
impl NginxConfigProvider for UpstreamTargetInfo { impl NginxConfigProvider for UpstreamTargetInfo {
fn to_nginx_config(&self, indent: Option<usize>) -> String { fn to_nginx_config(&self, indent: Option<usize>) -> String {
format!( format!(
"{}:{} weight={}{}{}", "server {}:{} weight={}{}{};",
self.target_host, self.target_host,
self.target_port, self.target_port,
self.weight, self.weight,
@@ -146,9 +153,9 @@ impl UpdateUpstreamTargetInfo {
weight: set_if_some!(self.weight), weight: set_if_some!(self.weight),
is_backup: set_if_some!(self.is_backup), is_backup: set_if_some!(self.is_backup),
enabled: set_if_some!(self.enabled), enabled: set_if_some!(self.enabled),
created_at: set_if_some!(self.created_at), created_at: Unchanged(current_model.created_at),
updated_at: set_if_some!(self.updated_at), updated_at: Set(chrono::Utc::now()),
upstream_id: set_if_some!(self.upstream_id), upstream_id: Unchanged(current_model.upstream_id),
} }
} }
} }

View File

@@ -1,8 +1,8 @@
use std::sync::Arc; use std::sync::Arc;
use sea_orm::{ use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, DatabaseTransaction, EntityTrait, ActiveModelTrait, ColumnTrait, DatabaseConnection, DatabaseTransaction, EntityTrait, ExprTrait,
ModelTrait, QueryFilter, QuerySelect, TransactionTrait, FromQueryResult, ModelTrait, QueryFilter, QuerySelect, QueryTrait, TransactionTrait,
}; };
use database::generated::entities::{upstream, upstream_target}; use database::generated::entities::{upstream, upstream_target};
@@ -10,34 +10,115 @@ use database::generated::entities::{upstream, upstream_target};
use crate::{ use crate::{
errors::service_error::ServiceError, errors::service_error::ServiceError,
helpers::database::PaginationFilter, helpers::database::PaginationFilter,
services::nginx::info::{ services::nginx::{
builder::NginxConfigBuilder,
info::{
upstream::{UpdateUpstreamInfo, UpstreamCreateInfo, UpstreamInfo}, upstream::{UpdateUpstreamInfo, UpstreamCreateInfo, UpstreamInfo},
upstream_target::{UpdateUpstreamTargetInfo, UpstreamTargetCreateInfo, UpstreamTargetInfo}, upstream_target::{
UpdateUpstreamTargetInfo, UpstreamTargetCreateInfo, UpstreamTargetInfo,
},
},
}, },
with_conn, with_conn,
}; };
pub struct UpstreamService { #[async_trait::async_trait]
pub trait UpstreamService: Send + Sync {
async fn create_upstream(
&self,
create_info: UpstreamCreateInfo,
tx: Option<&mut DatabaseTransaction>,
) -> Result<UpstreamInfo, ServiceError>;
async fn get_total_upstreams(
&self,
options: Option<UpstreamTotalCountOptions>,
tx: Option<&mut DatabaseTransaction>,
) -> Result<u64, ServiceError>;
async fn get_upstream(
&self,
upstream_id: uuid::Uuid,
options: Option<GetUpstreamOptions>,
tx: Option<&mut DatabaseTransaction>,
) -> Result<UpstreamInfo, ServiceError>;
async fn get_upstreams(
&self,
pagination: Option<PaginationFilter>,
options: Option<GetUpstreamOptions>,
tx: Option<&mut DatabaseTransaction>,
) -> Result<Vec<UpstreamInfo>, ServiceError>;
async fn update_upstream(
&self,
id: uuid::Uuid,
upstream: UpdateUpstreamInfo,
tx: Option<&mut DatabaseTransaction>,
) -> Result<UpstreamInfo, ServiceError>;
async fn delete_upstream(
&self,
upstream_id: uuid::Uuid,
tx: Option<&mut DatabaseTransaction>,
) -> Result<(), ServiceError>;
async fn create_upstream_target(
&self,
create_info: UpstreamTargetCreateInfo,
tx: Option<&mut DatabaseTransaction>,
) -> Result<UpstreamTargetInfo, ServiceError>;
async fn get_upstream_target(
&self,
target_id: uuid::Uuid,
options: Option<GetUpstreamTargetOptions>,
tx: Option<&mut DatabaseTransaction>,
) -> Result<UpstreamTargetInfo, ServiceError>;
#[allow(dead_code)]
async fn get_upstream_targets_by_upstream(
&self,
upstream_id: uuid::Uuid,
tx: Option<&mut DatabaseTransaction>,
) -> Result<Vec<UpstreamTargetInfo>, ServiceError>;
async fn update_upstream_target(
&self,
id: uuid::Uuid,
target: UpdateUpstreamTargetInfo,
tx: Option<&mut DatabaseTransaction>,
) -> Result<UpstreamTargetInfo, ServiceError>;
async fn delete_upstream_target(
&self,
target_id: uuid::Uuid,
tx: Option<&mut DatabaseTransaction>,
) -> Result<(), ServiceError>;
async fn generate_config(
&self,
builder: &mut NginxConfigBuilder,
tx: Option<&mut DatabaseTransaction>,
) -> Result<(), ServiceError>;
}
pub struct UpstreamServiceImpl {
connection: Arc<DatabaseConnection>, connection: Arc<DatabaseConnection>,
} }
#[derive(Default)] #[derive(Default)]
pub struct GetUpstreamOptions { pub struct GetUpstreamOptions {
pub include_targets: bool, pub include_targets: bool,
pub filter_by_enabled: bool,
} }
#[allow(dead_code)]
pub struct UpstreamTotalCountOptions {}
#[derive(Default)] #[derive(Default)]
pub struct GetUpstreamTargetOptions { pub struct GetUpstreamTargetOptions {
pub include_upstream: bool, pub include_upstream: bool,
} }
impl UpstreamService { impl UpstreamServiceImpl {
pub fn new(connection: Arc<DatabaseConnection>) -> Self { pub fn new(connection: Arc<DatabaseConnection>) -> Self {
Self { connection } Self { connection }
} }
// }
//
pub async fn create_upstream( #[async_trait::async_trait]
impl UpstreamService for UpstreamServiceImpl {
async fn create_upstream(
&self, &self,
create_info: UpstreamCreateInfo, create_info: UpstreamCreateInfo,
tx: Option<&mut DatabaseTransaction>, tx: Option<&mut DatabaseTransaction>,
@@ -79,7 +160,28 @@ impl UpstreamService {
Ok(r.into()) Ok(r.into())
} }
pub async fn get_upstream( async fn get_total_upstreams(
&self,
_options: Option<UpstreamTotalCountOptions>,
tx: Option<&mut DatabaseTransaction>,
) -> Result<u64, ServiceError> {
#[derive(Debug, FromQueryResult)]
struct CountResult {
// The field name must match the column alias in the query
count: i64,
}
let count_info = with_conn!(&*self.connection, tx, conn, {
upstream::Entity::find()
.select_only()
.column_as(upstream::Column::Id, "count")
.into_model::<CountResult>()
.one(*conn)
.await?
});
Ok(count_info.map_or(0, |c| c.count) as u64)
}
async fn get_upstream(
&self, &self,
upstream_id: uuid::Uuid, upstream_id: uuid::Uuid,
options: Option<GetUpstreamOptions>, options: Option<GetUpstreamOptions>,
@@ -97,6 +199,9 @@ impl UpstreamService {
)))?; )))?;
let targets = upstream_target::Entity::find() let targets = upstream_target::Entity::find()
.filter(upstream_target::Column::UpstreamId.eq(upstream_id)) .filter(upstream_target::Column::UpstreamId.eq(upstream_id))
.apply_if(Some(concrete_options.filter_by_enabled), |query, _v| {
query.filter(upstream_target::Column::Enabled.eq(true))
})
.all(*conn) .all(*conn)
.await?; .await?;
(up, targets) (up, targets)
@@ -117,9 +222,10 @@ impl UpstreamService {
Ok(info) Ok(info)
} }
pub async fn get_upstreams( async fn get_upstreams(
&self, &self,
pagination: Option<PaginationFilter>, pagination: Option<PaginationFilter>,
options: Option<GetUpstreamOptions>,
tx: Option<&mut DatabaseTransaction>, tx: Option<&mut DatabaseTransaction>,
) -> Result<Vec<UpstreamInfo>, ServiceError> { ) -> Result<Vec<UpstreamInfo>, ServiceError> {
let r = with_conn!(&*self.connection, tx, conn, { let r = with_conn!(&*self.connection, tx, conn, {
@@ -130,19 +236,45 @@ impl UpstreamService {
} else { } else {
find_query find_query
}; };
find_query.all(*conn).await? let find_query = match options {
Some(opts) => {
if opts.include_targets && opts.filter_by_enabled {
find_query.filter(
upstream_target::Column::Enabled
.eq(true)
.or(upstream_target::Column::Id.is_null()),
)
} else {
find_query
}
}
_ => find_query,
};
find_query
.find_with_related(upstream_target::Entity)
.all(*conn)
.await?
}); });
Ok(r.into_iter().map(|m| m.into()).collect()) Ok(r.into_iter().map(|m| m.into()).collect())
} }
pub async fn update_upstream( async fn update_upstream(
&self, &self,
id: uuid::Uuid, id: uuid::Uuid,
upstream: UpdateUpstreamInfo, upstream: UpdateUpstreamInfo,
tx: Option<&mut DatabaseTransaction>, tx: Option<&mut DatabaseTransaction>,
) -> Result<UpstreamInfo, ServiceError> { ) -> Result<UpstreamInfo, ServiceError> {
let current_model = with_conn!(&*self.connection, tx, conn, { // If a transaction was provided use it, otherwise create and own one here.
let mut maybe_owned_tx: Option<DatabaseTransaction> = None;
let tx_ref: Option<&mut DatabaseTransaction> = if let Some(tx) = tx {
Some(tx)
} else {
maybe_owned_tx = Some(self.connection.begin().await?);
maybe_owned_tx.as_mut()
};
let current_model = with_conn!(&*self.connection, tx_ref, conn, {
upstream::Entity::find_by_id(id) upstream::Entity::find_by_id(id)
.one(*conn) .one(*conn)
.await? .await?
@@ -151,13 +283,40 @@ impl UpstreamService {
id id
)))? )))?
}); });
let active_model = upstream.apply_to_model(current_model); let upstream_active_model = upstream.clone().apply_to_model(current_model);
let r = active_model.update(&*self.connection).await?; let r = with_conn!(&*self.connection, tx_ref, conn, {
let updated_upstream_model = upstream_active_model.update(*conn).await?;
// update upstream targets if any
if let Some(targets) = upstream.upstream_targets {
for (target_id, enabled) in targets.into_iter() {
let target_model = upstream_target::Entity::find_by_id(target_id)
.one(*conn)
.await?
.ok_or(ServiceError::NotFound(format!(
"Upstream target with id {} not found",
target_id
)))?;
let mut target_active_model: upstream_target::ActiveModel = target_model.into();
target_active_model.enabled = sea_orm::ActiveValue::Set(enabled);
target_active_model.update(*conn).await?;
Ok::<(), ServiceError>(())?;
}
}
updated_upstream_model
});
// Commit
if let Some(t) = maybe_owned_tx.take() {
t.commit().await?;
}
Ok(r.into()) Ok(r.into())
} }
pub async fn delete_upstream( async fn delete_upstream(
&self, &self,
upstream_id: uuid::Uuid, upstream_id: uuid::Uuid,
tx: Option<&mut DatabaseTransaction>, tx: Option<&mut DatabaseTransaction>,
@@ -172,6 +331,11 @@ impl UpstreamService {
)))? )))?
}); });
with_conn!(&*self.connection, tx, conn, { with_conn!(&*self.connection, tx, conn, {
// delete all targets belonging to the upstream
upstream_target::Entity::delete_many()
.filter(upstream_target::Column::UpstreamId.eq(upstream_id))
.exec(*conn)
.await?;
model.delete(*conn).await?; model.delete(*conn).await?;
Ok(()) Ok(())
}) })
@@ -179,7 +343,7 @@ impl UpstreamService {
// //
// //
pub async fn create_upstream_target( async fn create_upstream_target(
&self, &self,
create_info: UpstreamTargetCreateInfo, create_info: UpstreamTargetCreateInfo,
tx: Option<&mut DatabaseTransaction>, tx: Option<&mut DatabaseTransaction>,
@@ -189,7 +353,7 @@ impl UpstreamService {
Ok(r.into()) Ok(r.into())
} }
pub async fn get_upstream_target( async fn get_upstream_target(
&self, &self,
target_id: uuid::Uuid, target_id: uuid::Uuid,
options: Option<GetUpstreamTargetOptions>, options: Option<GetUpstreamTargetOptions>,
@@ -232,7 +396,7 @@ impl UpstreamService {
Ok(info) Ok(info)
} }
pub async fn get_upstream_targets_by_upstream( async fn get_upstream_targets_by_upstream(
&self, &self,
upstream_id: uuid::Uuid, upstream_id: uuid::Uuid,
tx: Option<&mut DatabaseTransaction>, tx: Option<&mut DatabaseTransaction>,
@@ -246,7 +410,7 @@ impl UpstreamService {
Ok(r.into_iter().map(|m| m.into()).collect()) Ok(r.into_iter().map(|m| m.into()).collect())
} }
pub async fn update_upstream_target( async fn update_upstream_target(
&self, &self,
id: uuid::Uuid, id: uuid::Uuid,
target: UpdateUpstreamTargetInfo, target: UpdateUpstreamTargetInfo,
@@ -263,11 +427,13 @@ impl UpstreamService {
}); });
let active_model = target.apply_to_model(current_model); let active_model = target.apply_to_model(current_model);
let r = active_model.update(&*self.connection).await?; let r = with_conn!(&*self.connection, tx, conn, {
active_model.update(*conn).await?
});
Ok(r.into()) Ok(r.into())
} }
pub async fn delete_upstream_target( async fn delete_upstream_target(
&self, &self,
target_id: uuid::Uuid, target_id: uuid::Uuid,
tx: Option<&mut DatabaseTransaction>, tx: Option<&mut DatabaseTransaction>,
@@ -286,6 +452,26 @@ impl UpstreamService {
Ok(()) Ok(())
}) })
} }
async fn generate_config(
&self,
builder: &mut NginxConfigBuilder,
tx: Option<&mut DatabaseTransaction>,
) -> Result<(), ServiceError> {
// get all upstreams and their targets
let upstreams = with_conn!(&*self.connection, tx, conn, {
upstream::Entity::find()
.find_with_related(upstream_target::Entity)
.all(*conn)
.await?
});
let upstreams_info = upstreams
.into_iter()
.map(|(up_model, target_models)| (up_model, target_models).into())
.collect::<Vec<UpstreamInfo>>();
builder.add_upstreams(upstreams_info);
Ok(())
}
} }
#[cfg(test)] #[cfg(test)]
@@ -315,7 +501,7 @@ mod tests {
.append_query_results(vec![vec![up_model.clone()]]) .append_query_results(vec![vec![up_model.clone()]])
.into_connection(); .into_connection();
let svc = UpstreamService::new(Arc::new(db)); let svc = UpstreamServiceImpl::new(Arc::new(db));
let create_info = crate::services::nginx::info::upstream::UpstreamCreateInfo { let create_info = crate::services::nginx::info::upstream::UpstreamCreateInfo {
name: "test_upstream".to_string(), name: "test_upstream".to_string(),
@@ -366,13 +552,14 @@ mod tests {
.append_query_results(vec![vec![target_model.clone()]]) .append_query_results(vec![vec![target_model.clone()]])
.into_connection(); .into_connection();
let svc = UpstreamService::new(Arc::new(db)); let svc = UpstreamServiceImpl::new(Arc::new(db));
let res = svc let res = svc
.get_upstream( .get_upstream(
up_id, up_id,
Some(GetUpstreamOptions { Some(GetUpstreamOptions {
include_targets: true, include_targets: true,
filter_by_enabled: false,
}), }),
None, None,
) )
@@ -391,7 +578,7 @@ mod tests {
.append_query_results(vec![Vec::<sea_orm::MockRow>::new()]) .append_query_results(vec![Vec::<sea_orm::MockRow>::new()])
.into_connection(); .into_connection();
let svc = UpstreamService::new(Arc::new(db)); let svc = UpstreamServiceImpl::new(Arc::new(db));
let res = svc.get_upstream(uuid::Uuid::new_v4(), None, None).await; let res = svc.get_upstream(uuid::Uuid::new_v4(), None, None).await;
@@ -422,12 +609,15 @@ mod tests {
}; };
let db = MockDatabase::new(DatabaseBackend::Sqlite) let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![vec![u1.clone(), u2.clone()]]) .append_query_results(vec![vec![
(u1.clone(), None::<upstream_target::Model>),
(u2.clone(), None::<upstream_target::Model>),
]])
.into_connection(); .into_connection();
let svc = UpstreamService::new(Arc::new(db)); let svc = UpstreamServiceImpl::new(Arc::new(db));
let res = svc.get_upstreams(None, None).await; let res = svc.get_upstreams(None, None, None).await;
assert!(res.is_ok()); assert!(res.is_ok());
let list = res.expect("Failed to get upstreams"); let list = res.expect("Failed to get upstreams");
assert_eq!(list.len(), 2); assert_eq!(list.len(), 2);
@@ -453,7 +643,7 @@ mod tests {
.append_query_results(vec![vec![t.clone()]]) .append_query_results(vec![vec![t.clone()]])
.into_connection(); .into_connection();
let svc = UpstreamService::new(Arc::new(db)); let svc = UpstreamServiceImpl::new(Arc::new(db));
let res = svc.get_upstream_targets_by_upstream(up_id, None).await; let res = svc.get_upstream_targets_by_upstream(up_id, None).await;
assert!(res.is_ok()); assert!(res.is_ok());
@@ -491,17 +681,13 @@ mod tests {
.append_query_results(vec![vec![updated.clone()]]) // update result .append_query_results(vec![vec![updated.clone()]]) // update result
.into_connection(); .into_connection();
let svc = UpstreamService::new(Arc::new(db)); let svc = UpstreamServiceImpl::new(Arc::new(db));
let update_info = crate::services::nginx::info::upstream::UpdateUpstreamInfo { let update_info = crate::services::nginx::info::upstream::UpdateUpstreamInfo {
id: None,
name: None, name: None,
protocol: None, protocol: None,
algorithm: None, algorithm: None,
sticky_session: None, sticky_session: None,
created_by: None,
created_at: None,
updated_at: None,
upstream_targets: None, upstream_targets: None,
}; };
let res = svc.update_upstream(id, update_info, None).await; let res = svc.update_upstream(id, update_info, None).await;
@@ -516,20 +702,17 @@ mod tests {
.append_query_results(vec![Vec::<sea_orm::MockRow>::new()]) .append_query_results(vec![Vec::<sea_orm::MockRow>::new()])
.into_connection(); .into_connection();
let svc = UpstreamService::new(Arc::new(db)); let svc = UpstreamServiceImpl::new(Arc::new(db));
let res = svc let res = svc
.update_upstream( .update_upstream(
uuid::Uuid::new_v4(), uuid::Uuid::new_v4(),
crate::services::nginx::info::upstream::UpdateUpstreamInfo { crate::services::nginx::info::upstream::UpdateUpstreamInfo {
id: None,
name: None, name: None,
protocol: None, protocol: None,
algorithm: None, algorithm: None,
sticky_session: None, sticky_session: None,
created_by: None,
created_at: None,
updated_at: None,
upstream_targets: None, upstream_targets: None,
}, },
None, None,
@@ -555,13 +738,19 @@ mod tests {
let db = MockDatabase::new(DatabaseBackend::Sqlite) let db = MockDatabase::new(DatabaseBackend::Sqlite)
.append_query_results(vec![vec![existing.clone()]]) .append_query_results(vec![vec![existing.clone()]])
.append_exec_results(vec![MockExecResult { .append_exec_results(vec![
MockExecResult {
rows_affected: 1, rows_affected: 1,
last_insert_id: 0, last_insert_id: 0,
}]) },
MockExecResult {
rows_affected: 1,
last_insert_id: 0,
},
])
.into_connection(); .into_connection();
let svc = UpstreamService::new(Arc::new(db)); let svc = UpstreamServiceImpl::new(Arc::new(db));
let res = svc.delete_upstream(id, None).await; let res = svc.delete_upstream(id, None).await;
assert!(res.is_ok()); assert!(res.is_ok());
@@ -573,7 +762,7 @@ mod tests {
.append_query_results(vec![Vec::<sea_orm::MockRow>::new()]) .append_query_results(vec![Vec::<sea_orm::MockRow>::new()])
.into_connection(); .into_connection();
let svc = UpstreamService::new(Arc::new(db)); let svc = UpstreamServiceImpl::new(Arc::new(db));
let res = svc.delete_upstream(uuid::Uuid::new_v4(), None).await; let res = svc.delete_upstream(uuid::Uuid::new_v4(), None).await;
assert!(matches!(res, Err(ServiceError::NotFound(_)))); assert!(matches!(res, Err(ServiceError::NotFound(_))));
@@ -599,7 +788,7 @@ mod tests {
.append_query_results(vec![vec![created.clone()]]) .append_query_results(vec![vec![created.clone()]])
.into_connection(); .into_connection();
let svc = UpstreamService::new(Arc::new(db)); let svc = UpstreamServiceImpl::new(Arc::new(db));
let create_info = crate::services::nginx::info::upstream_target::UpstreamTargetCreateInfo { let create_info = crate::services::nginx::info::upstream_target::UpstreamTargetCreateInfo {
target_host: "1.2.3.4".to_string(), target_host: "1.2.3.4".to_string(),
@@ -647,19 +836,14 @@ mod tests {
.append_query_results(vec![vec![updated.clone()]]) .append_query_results(vec![vec![updated.clone()]])
.into_connection(); .into_connection();
let svc = UpstreamService::new(Arc::new(db)); let svc = UpstreamServiceImpl::new(Arc::new(db));
let update_info = crate::services::nginx::info::upstream_target::UpdateUpstreamTargetInfo { let update_info = crate::services::nginx::info::upstream_target::UpdateUpstreamTargetInfo {
id: None,
target_host: None, target_host: None,
target_port: None, target_port: None,
weight: None, weight: None,
is_backup: None, is_backup: None,
enabled: None, enabled: None,
created_at: None,
updated_at: None,
upstream_id: None,
upstream: None,
}; };
let res = svc.update_upstream_target(id, update_info, None).await; let res = svc.update_upstream_target(id, update_info, None).await;
assert!(res.is_ok()); assert!(res.is_ok());
@@ -690,7 +874,7 @@ mod tests {
}]) }])
.into_connection(); .into_connection();
let svc = UpstreamService::new(Arc::new(db)); let svc = UpstreamServiceImpl::new(Arc::new(db));
let res = svc.delete_upstream_target(id, None).await; let res = svc.delete_upstream_target(id, None).await;
assert!(res.is_ok()); assert!(res.is_ok());
} }

View File

@@ -106,6 +106,357 @@
} }
} }
}, },
"/api/nginx/upstream_targets/{upstream_target_id}": {
"get": {
"tags": [
"Nginx"
],
"operationId": "get_upstream_target",
"parameters": [
{
"name": "upstream_target_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"200": {
"description": "Get upstream target info",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpstreamTargetInfo"
}
}
}
},
"404": {
"description": "Not found"
},
"500": {
"description": "Internal server error"
}
}
},
"delete": {
"tags": [
"Nginx"
],
"operationId": "remove_upstream_target",
"parameters": [
{
"name": "upstream_target_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"200": {
"description": "Upstream target 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_upstream_target",
"parameters": [
{
"name": "upstream_target_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateUpstreamTargetRequestBody"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Upstream target updated successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateUpstreamTargetInfoResponse"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"404": {
"description": "Not found"
},
"422": {
"description": "Invalid request"
},
"500": {
"description": "Internal server error"
}
}
}
},
"/api/nginx/upstreams": {
"get": {
"tags": [
"Nginx"
],
"operationId": "get_upstream_list",
"responses": {
"200": {
"description": "List upstreams",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpstreamListResponse"
}
}
}
},
"500": {
"description": "Internal server error"
}
}
},
"post": {
"tags": [
"Nginx"
],
"operationId": "create_upstream",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateUpstreamRequestBody"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Upstream created successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpstreamInfoResponse"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"422": {
"description": "Invalid request"
},
"500": {
"description": "Internal server error"
}
}
}
},
"/api/nginx/upstreams/{upstream_id}": {
"get": {
"tags": [
"Nginx"
],
"operationId": "get_upstream",
"parameters": [
{
"name": "upstream_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"200": {
"description": "Get upstream info",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpstreamInfoResponse"
}
}
}
},
"404": {
"description": "Not found"
},
"500": {
"description": "Internal server error"
}
}
},
"delete": {
"tags": [
"Nginx"
],
"operationId": "remove_upstream",
"parameters": [
{
"name": "upstream_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"200": {
"description": "Upstream 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_upstream",
"parameters": [
{
"name": "upstream_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateUpstreamRequestBody"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Upstream updated successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateUpstreamInfoResponse"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"404": {
"description": "Not found"
},
"422": {
"description": "Invalid request"
},
"500": {
"description": "Internal server error"
}
}
}
},
"/api/nginx/upstreams/{upstream_id}/targets": {
"post": {
"tags": [
"Nginx"
],
"operationId": "add_upstream_target",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateUpstreamTargetInfo"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Upstream target created successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpstreamTargetInfoResponse"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"422": {
"description": "Invalid request"
},
"500": {
"description": "Internal server error"
}
}
}
},
"/api/user/me": { "/api/user/me": {
"get": { "get": {
"tags": [ "tags": [
@@ -157,6 +508,102 @@
} }
} }
}, },
"CreateUpstreamRequestBody": {
"type": "object",
"required": [
"name",
"protocol",
"upstream_targets"
],
"properties": {
"algorithm": {
"type": [
"string",
"null"
]
},
"name": {
"type": "string"
},
"protocol": {
"type": "string"
},
"sticky_session": {
"type": [
"boolean",
"null"
]
},
"upstream_targets": {
"type": "array",
"items": {
"$ref": "#/components/schemas/UpstreamTargetInfo"
}
}
}
},
"CreateUpstreamTargetInfo": {
"type": "object",
"required": [
"upstream_id",
"host",
"port"
],
"properties": {
"enabled": {
"type": [
"boolean",
"null"
]
},
"host": {
"type": "string"
},
"is_backup": {
"type": [
"boolean",
"null"
]
},
"port": {
"type": "integer",
"format": "int64"
},
"upstream_id": {
"type": "string",
"format": "uuid"
},
"weight": {
"type": [
"integer",
"null"
],
"format": "int64"
}
}
},
"GetUpstreamParams": {
"type": "object",
"properties": {
"include_targets": {
"type": [
"boolean",
"null"
]
}
}
},
"GetUpstreamTargetsParams": {
"type": "object",
"properties": {
"include_upstream": {
"type": [
"boolean",
"null"
]
}
}
},
"HealthInfo": { "HealthInfo": {
"type": "object", "type": "object",
"description": "System health information", "description": "System health information",
@@ -212,6 +659,486 @@
} }
} }
}, },
"PaginationInfo": {
"type": "object",
"description": "Pagination information included in API responses",
"required": [
"total_items",
"total_pages",
"current_page",
"per_page"
],
"properties": {
"current_page": {
"type": "integer",
"format": "int32",
"description": "Current page number",
"minimum": 0
},
"per_page": {
"type": "integer",
"format": "int32",
"description": "Items per page",
"minimum": 0
},
"total_items": {
"type": "integer",
"format": "int64",
"description": "Total number of items",
"minimum": 0
},
"total_pages": {
"type": "integer",
"format": "int32",
"description": "Total number of pages",
"minimum": 0
}
}
},
"UpdateUpstreamInfoResponse": {
"type": "object",
"required": [
"id",
"name",
"protocol",
"algorithm",
"sticky_session",
"created_at",
"updated_at",
"upstream_targets"
],
"properties": {
"algorithm": {
"type": "string"
},
"created_at": {
"type": "string",
"format": "date-time"
},
"created_by": {
"type": [
"string",
"null"
],
"format": "uuid"
},
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"protocol": {
"type": "string"
},
"sticky_session": {
"type": "boolean"
},
"updated_at": {
"type": "string",
"format": "date-time"
},
"upstream_targets": {
"type": "array",
"items": {
"$ref": "#/components/schemas/UpstreamTargetBasicInfo"
}
}
}
},
"UpdateUpstreamRequestBody": {
"type": "object",
"properties": {
"algorithm": {
"type": [
"string",
"null"
]
},
"name": {
"type": [
"string",
"null"
]
},
"protocol": {
"type": [
"string",
"null"
]
},
"sticky_session": {
"type": [
"boolean",
"null"
]
},
"upstream_targets": {
"type": [
"array",
"null"
],
"items": {
"$ref": "#/components/schemas/UpstreamTargetBasicUpdateInfo"
}
}
}
},
"UpdateUpstreamTargetInfoResponse": {
"type": "object",
"required": [
"id",
"host",
"port",
"enabled",
"is_backup",
"weight",
"created_at",
"updated_at",
"upstream_id"
],
"properties": {
"created_at": {
"type": "string",
"format": "date-time"
},
"enabled": {
"type": "boolean"
},
"host": {
"type": "string"
},
"id": {
"type": "string",
"format": "uuid"
},
"is_backup": {
"type": "boolean"
},
"port": {
"type": "integer",
"format": "int64"
},
"updated_at": {
"type": "string",
"format": "date-time"
},
"upstream_id": {
"type": "string",
"format": "uuid"
},
"weight": {
"type": "integer",
"format": "int32"
}
}
},
"UpdateUpstreamTargetRequestBody": {
"type": "object",
"properties": {
"enabled": {
"type": [
"boolean",
"null"
]
},
"host": {
"type": [
"string",
"null"
]
},
"is_backup": {
"type": [
"boolean",
"null"
]
},
"port": {
"type": [
"integer",
"null"
],
"format": "int64"
},
"weight": {
"type": [
"integer",
"null"
],
"format": "int32"
}
}
},
"UpstreamBasicInfo": {
"type": "object",
"required": [
"id",
"name",
"protocol",
"created_at",
"updated_at"
],
"properties": {
"created_at": {
"type": "string",
"format": "date-time"
},
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"protocol": {
"type": "string"
},
"updated_at": {
"type": "string",
"format": "date-time"
}
}
},
"UpstreamInfoResponse": {
"type": "object",
"required": [
"id",
"name",
"protocol",
"algorithm",
"sticky_session",
"created_at",
"updated_at",
"upstream_targets"
],
"properties": {
"algorithm": {
"type": "string"
},
"created_at": {
"type": "string",
"format": "date-time"
},
"created_by": {
"type": [
"string",
"null"
],
"format": "uuid"
},
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"protocol": {
"type": "string"
},
"sticky_session": {
"type": "boolean"
},
"updated_at": {
"type": "string",
"format": "date-time"
},
"upstream_targets": {
"type": "array",
"items": {
"$ref": "#/components/schemas/UpstreamTargetBasicInfo"
}
}
}
},
"UpstreamListResponse": {
"type": "object",
"required": [
"items",
"pagination"
],
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/UpstreamInfoResponse"
}
},
"pagination": {
"$ref": "#/components/schemas/PaginationInfo"
}
}
},
"UpstreamTargetBasicInfo": {
"type": "object",
"required": [
"id",
"target_host",
"target_port",
"enabled",
"is_backup",
"weight",
"created_at",
"updated_at"
],
"properties": {
"created_at": {
"type": "string",
"format": "date-time"
},
"enabled": {
"type": "boolean"
},
"id": {
"type": "string",
"format": "uuid"
},
"is_backup": {
"type": "boolean"
},
"target_host": {
"type": "string"
},
"target_port": {
"type": "integer",
"format": "int64"
},
"updated_at": {
"type": "string",
"format": "date-time"
},
"weight": {
"type": "integer",
"format": "int32"
}
}
},
"UpstreamTargetBasicUpdateInfo": {
"type": "object",
"required": [
"id",
"enabled"
],
"properties": {
"enabled": {
"type": "boolean"
},
"id": {
"type": "integer",
"format": "int64"
}
}
},
"UpstreamTargetInfo": {
"type": "object",
"required": [
"id",
"target_host",
"target_port",
"enabled",
"is_backup",
"weight",
"created_at",
"updated_at",
"upstream_id"
],
"properties": {
"created_at": {
"type": "string",
"format": "date-time"
},
"enabled": {
"type": "boolean"
},
"id": {
"type": "string",
"format": "uuid"
},
"is_backup": {
"type": "boolean"
},
"target_host": {
"type": "string"
},
"target_port": {
"type": "integer",
"format": "int64"
},
"updated_at": {
"type": "string",
"format": "date-time"
},
"upstream": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/UpstreamBasicInfo"
}
]
},
"upstream_id": {
"type": "string",
"format": "uuid"
},
"weight": {
"type": "integer",
"format": "int32"
}
}
},
"UpstreamTargetInfoResponse": {
"type": "object",
"required": [
"id",
"host",
"port",
"enabled",
"is_backup",
"weight",
"created_at",
"updated_at",
"upstream_id"
],
"properties": {
"created_at": {
"type": "string",
"format": "date-time"
},
"enabled": {
"type": "boolean"
},
"host": {
"type": "string"
},
"id": {
"type": "string",
"format": "uuid"
},
"is_backup": {
"type": "boolean"
},
"port": {
"type": "integer",
"format": "int64"
},
"updated_at": {
"type": "string",
"format": "date-time"
},
"upstream_id": {
"type": "string",
"format": "uuid"
},
"weight": {
"type": "integer",
"format": "int32"
}
}
},
"UserInfo": { "UserInfo": {
"type": "object", "type": "object",
"description": "System health information", "description": "System health information",
@@ -245,6 +1172,10 @@
{ {
"name": "User", "name": "User",
"description": "User management API" "description": "User management API"
},
{
"name": "Nginx",
"description": "Nginx management API"
} }
] ]
} }

View File

@@ -1,6 +1,42 @@
export namespace Schemas { export namespace Schemas {
// <Schemas> // <Schemas>
export type AdminInitRequest = { password: string; setup_secret: string; username: string }; export type AdminInitRequest = { password: string; setup_secret: string; username: string };
export type UpstreamBasicInfo = {
created_at: string;
id: string;
name: string;
protocol: string;
updated_at: string;
};
export type UpstreamTargetInfo = {
created_at: string;
enabled: boolean;
id: string;
is_backup: boolean;
target_host: string;
target_port: number;
updated_at: string;
upstream?: (null | UpstreamBasicInfo) | undefined;
upstream_id: string;
weight: number;
};
export type CreateUpstreamRequestBody = {
algorithm?: (string | null) | undefined;
name: string;
protocol: string;
sticky_session?: (boolean | null) | undefined;
upstream_targets: Array<UpstreamTargetInfo>;
};
export type CreateUpstreamTargetInfo = {
enabled?: (boolean | null) | undefined;
host: string;
is_backup?: (boolean | null) | undefined;
port: number;
upstream_id: string;
weight?: (number | null) | undefined;
};
export type GetUpstreamParams = Partial<{ include_targets: boolean | null }>;
export type GetUpstreamTargetsParams = Partial<{ include_upstream: boolean | null }>;
export type HealthInfo = { export type HealthInfo = {
errors?: (Array<string> | null) | undefined; errors?: (Array<string> | null) | undefined;
is_initialized: boolean; is_initialized: boolean;
@@ -9,6 +45,77 @@ export namespace Schemas {
version: string; version: string;
}; };
export type LoginRequest = { password: string; username: string }; export type LoginRequest = { password: string; username: string };
export type PaginationInfo = { current_page: number; per_page: number; total_items: number; total_pages: number };
export type UpstreamTargetBasicInfo = {
created_at: string;
enabled: boolean;
id: string;
is_backup: boolean;
target_host: string;
target_port: number;
updated_at: string;
weight: number;
};
export type UpdateUpstreamInfoResponse = {
algorithm: string;
created_at: string;
created_by?: (string | null) | undefined;
id: string;
name: string;
protocol: string;
sticky_session: boolean;
updated_at: string;
upstream_targets: Array<UpstreamTargetBasicInfo>;
};
export type UpdateUpstreamRequestBody = Partial<{
algorithm: string | null;
name: string | null;
protocol: string | null;
sticky_session: boolean | null;
upstream_targets: Array<UpstreamTargetBasicUpdateInfo> | null;
}>;
export type UpdateUpstreamTargetInfoResponse = {
created_at: string;
enabled: boolean;
host: string;
id: string;
is_backup: boolean;
port: number;
updated_at: string;
upstream_id: string;
weight: number;
};
export type UpdateUpstreamTargetRequestBody = Partial<{
enabled: boolean | null;
host: string | null;
is_backup: boolean | null;
port: number | null;
weight: number | null;
}>;
export type UpstreamInfoResponse = {
algorithm: string;
created_at: string;
created_by?: (string | null) | undefined;
id: string;
name: string;
protocol: string;
sticky_session: boolean;
updated_at: string;
upstream_targets: Array<UpstreamTargetBasicInfo>;
};
export type UpstreamListResponse = { items: Array<UpstreamInfoResponse>; pagination: PaginationInfo };
export type UpstreamTargetBasicUpdateInfo = { enabled: boolean; id: number };
export type UpstreamTargetInfoResponse = {
created_at: string;
enabled: boolean;
host: string;
id: string;
is_backup: boolean;
port: number;
updated_at: string;
upstream_id: string;
weight: number;
};
export type UserInfo = { id: string; username: string }; export type UserInfo = { id: string; username: string };
// </Schemas> // </Schemas>
@@ -42,6 +149,95 @@ export namespace Endpoints {
parameters: never; parameters: never;
responses: { 200: Schemas.HealthInfo; 404: unknown }; responses: { 200: Schemas.HealthInfo; 404: unknown };
}; };
export type get_Get_upstream_target = {
method: "GET";
path: "/api/nginx/upstream_targets/{upstream_target_id}";
requestFormat: "json";
parameters: {
path: { upstream_target_id: string };
};
responses: { 200: Schemas.UpstreamTargetInfo; 404: unknown; 500: unknown };
};
export type delete_Remove_upstream_target = {
method: "DELETE";
path: "/api/nginx/upstream_targets/{upstream_target_id}";
requestFormat: "json";
parameters: {
path: { upstream_target_id: string };
};
responses: { 200: unknown; 401: unknown; 404: unknown; 500: unknown };
};
export type patch_Update_upstream_target = {
method: "PATCH";
path: "/api/nginx/upstream_targets/{upstream_target_id}";
requestFormat: "json";
parameters: {
path: { upstream_target_id: string };
body: Schemas.UpdateUpstreamTargetRequestBody;
};
responses: {
200: Schemas.UpdateUpstreamTargetInfoResponse;
401: unknown;
404: unknown;
422: unknown;
500: unknown;
};
};
export type get_Get_upstream_list = {
method: "GET";
path: "/api/nginx/upstreams";
requestFormat: "json";
parameters: never;
responses: { 200: Schemas.UpstreamListResponse; 500: unknown };
};
export type post_Create_upstream = {
method: "POST";
path: "/api/nginx/upstreams";
requestFormat: "json";
parameters: {
body: Schemas.CreateUpstreamRequestBody;
};
responses: { 200: Schemas.UpstreamInfoResponse; 401: unknown; 422: unknown; 500: unknown };
};
export type get_Get_upstream = {
method: "GET";
path: "/api/nginx/upstreams/{upstream_id}";
requestFormat: "json";
parameters: {
path: { upstream_id: string };
};
responses: { 200: Schemas.UpstreamInfoResponse; 404: unknown; 500: unknown };
};
export type delete_Remove_upstream = {
method: "DELETE";
path: "/api/nginx/upstreams/{upstream_id}";
requestFormat: "json";
parameters: {
path: { upstream_id: string };
};
responses: { 200: unknown; 401: unknown; 404: unknown; 500: unknown };
};
export type patch_Update_upstream = {
method: "PATCH";
path: "/api/nginx/upstreams/{upstream_id}";
requestFormat: "json";
parameters: {
path: { upstream_id: string };
body: Schemas.UpdateUpstreamRequestBody;
};
responses: { 200: Schemas.UpdateUpstreamInfoResponse; 401: unknown; 404: unknown; 422: unknown; 500: unknown };
};
export type post_Add_upstream_target = {
method: "POST";
path: "/api/nginx/upstreams/{upstream_id}/targets";
requestFormat: "json";
parameters: {
body: Schemas.CreateUpstreamTargetInfo;
};
responses: { 200: Schemas.UpstreamTargetInfoResponse; 401: unknown; 422: unknown; 500: unknown };
};
export type get_Get_user_info = { export type get_Get_user_info = {
method: "GET"; method: "GET";
path: "/api/user/me"; path: "/api/user/me";
@@ -58,11 +254,24 @@ export type EndpointByMethod = {
post: { post: {
"/api/auth/init_admin": Endpoints.post_Init_admin; "/api/auth/init_admin": Endpoints.post_Init_admin;
"/api/auth/login": Endpoints.post_Login; "/api/auth/login": Endpoints.post_Login;
"/api/nginx/upstreams": Endpoints.post_Create_upstream;
"/api/nginx/upstreams/{upstream_id}/targets": Endpoints.post_Add_upstream_target;
}; };
get: { get: {
"/api/health/info": Endpoints.get_Get_health_info; "/api/health/info": Endpoints.get_Get_health_info;
"/api/nginx/upstream_targets/{upstream_target_id}": Endpoints.get_Get_upstream_target;
"/api/nginx/upstreams": Endpoints.get_Get_upstream_list;
"/api/nginx/upstreams/{upstream_id}": Endpoints.get_Get_upstream;
"/api/user/me": Endpoints.get_Get_user_info; "/api/user/me": Endpoints.get_Get_user_info;
}; };
delete: {
"/api/nginx/upstream_targets/{upstream_target_id}": Endpoints.delete_Remove_upstream_target;
"/api/nginx/upstreams/{upstream_id}": Endpoints.delete_Remove_upstream;
};
patch: {
"/api/nginx/upstream_targets/{upstream_target_id}": Endpoints.patch_Update_upstream_target;
"/api/nginx/upstreams/{upstream_id}": Endpoints.patch_Update_upstream;
};
}; };
// </EndpointByMethod> // </EndpointByMethod>
@@ -70,6 +279,8 @@ export type EndpointByMethod = {
// <EndpointByMethod.Shorthands> // <EndpointByMethod.Shorthands>
export type PostEndpoints = EndpointByMethod["post"]; export type PostEndpoints = EndpointByMethod["post"];
export type GetEndpoints = EndpointByMethod["get"]; export type GetEndpoints = EndpointByMethod["get"];
export type DeleteEndpoints = EndpointByMethod["delete"];
export type PatchEndpoints = EndpointByMethod["patch"];
// </EndpointByMethod.Shorthands> // </EndpointByMethod.Shorthands>
// <ApiClientTypes> // <ApiClientTypes>
@@ -364,6 +575,68 @@ export class ApiClient {
} }
// </ApiClient.get> // </ApiClient.get>
// <ApiClient.delete>
delete<Path extends keyof DeleteEndpoints, TEndpoint extends DeleteEndpoints[Path]>(
path: Path,
...params: MaybeOptionalArg<
TEndpoint extends { parameters: infer UParams }
? NotNever<UParams> extends true
? UParams & { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean }
: { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean }
: { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean }
>
): Promise<Extract<InferResponseByStatus<TEndpoint, SuccessStatusCode>, { data: {} }>["data"]>;
delete<Path extends keyof DeleteEndpoints, TEndpoint extends DeleteEndpoints[Path]>(
path: Path,
...params: MaybeOptionalArg<
TEndpoint extends { parameters: infer UParams }
? NotNever<UParams> extends true
? UParams & { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean }
: { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean }
: { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean }
>
): Promise<SafeApiResponse<TEndpoint>>;
delete<Path extends keyof DeleteEndpoints, _TEndpoint extends DeleteEndpoints[Path]>(
path: Path,
...params: MaybeOptionalArg<any>
): Promise<any> {
return this.request("delete", path, ...params);
}
// </ApiClient.delete>
// <ApiClient.patch>
patch<Path extends keyof PatchEndpoints, TEndpoint extends PatchEndpoints[Path]>(
path: Path,
...params: MaybeOptionalArg<
TEndpoint extends { parameters: infer UParams }
? NotNever<UParams> extends true
? UParams & { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean }
: { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean }
: { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean }
>
): Promise<Extract<InferResponseByStatus<TEndpoint, SuccessStatusCode>, { data: {} }>["data"]>;
patch<Path extends keyof PatchEndpoints, TEndpoint extends PatchEndpoints[Path]>(
path: Path,
...params: MaybeOptionalArg<
TEndpoint extends { parameters: infer UParams }
? NotNever<UParams> extends true
? UParams & { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean }
: { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean }
: { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean }
>
): Promise<SafeApiResponse<TEndpoint>>;
patch<Path extends keyof PatchEndpoints, _TEndpoint extends PatchEndpoints[Path]>(
path: Path,
...params: MaybeOptionalArg<any>
): Promise<any> {
return this.request("patch", path, ...params);
}
// </ApiClient.patch>
// <ApiClient.request> // <ApiClient.request>
/** /**
* Generic request method with full type-safety for any endpoint * Generic request method with full type-safety for any endpoint

View File

@@ -43,6 +43,8 @@ const createQueryKey = <TOptions extends EndpointParameters>(
// <EndpointByMethod.Shorthands> // <EndpointByMethod.Shorthands>
export type PostEndpoints = EndpointByMethod["post"]; export type PostEndpoints = EndpointByMethod["post"];
export type GetEndpoints = EndpointByMethod["get"]; export type GetEndpoints = EndpointByMethod["get"];
export type DeleteEndpoints = EndpointByMethod["delete"];
export type PatchEndpoints = EndpointByMethod["patch"];
// </EndpointByMethod.Shorthands> // </EndpointByMethod.Shorthands>
// <ApiClientTypes> // <ApiClientTypes>
@@ -130,6 +132,66 @@ export class TanstackQueryApiClient {
} }
// </ApiClient.get> // </ApiClient.get>
// <ApiClient.delete>
delete<Path extends keyof DeleteEndpoints, TEndpoint extends DeleteEndpoints[Path]>(
path: Path,
...params: MaybeOptionalArg<TEndpoint["parameters"]>
) {
const queryKey = createQueryKey(path as string, params[0]);
const query = {
/** type-only property if you need easy access to the endpoint params */
"~endpoint": {} as TEndpoint,
queryKey,
queryFn: {} as "You need to pass .queryOptions to the useQuery hook",
queryOptions: queryOptions({
queryFn: async ({ queryKey, signal }) => {
const requestParams = {
...(params[0] || {}),
...(queryKey[0] || {}),
overrides: { signal },
withResponse: false as const,
};
const res = await this.client.delete(path, requestParams as never);
return res as InferResponseData<TEndpoint, SuccessStatusCode>;
},
queryKey: queryKey,
}),
};
return query;
}
// </ApiClient.delete>
// <ApiClient.patch>
patch<Path extends keyof PatchEndpoints, TEndpoint extends PatchEndpoints[Path]>(
path: Path,
...params: MaybeOptionalArg<TEndpoint["parameters"]>
) {
const queryKey = createQueryKey(path as string, params[0]);
const query = {
/** type-only property if you need easy access to the endpoint params */
"~endpoint": {} as TEndpoint,
queryKey,
queryFn: {} as "You need to pass .queryOptions to the useQuery hook",
queryOptions: queryOptions({
queryFn: async ({ queryKey, signal }) => {
const requestParams = {
...(params[0] || {}),
...(queryKey[0] || {}),
overrides: { signal },
withResponse: false as const,
};
const res = await this.client.patch(path, requestParams as never);
return res as InferResponseData<TEndpoint, SuccessStatusCode>;
},
queryKey: queryKey,
}),
};
return query;
}
// </ApiClient.patch>
// <ApiClient.request> // <ApiClient.request>
/** /**
* Generic mutation method with full type-safety for any endpoint; it doesnt require parameters to be passed initially * Generic mutation method with full type-safety for any endpoint; it doesnt require parameters to be passed initially

View File

@@ -1,30 +0,0 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0.0-rc.18
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "edit_operation")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub session_id: Uuid,
pub resource_type: String,
pub resource_id: Option<Uuid>,
pub operation_type: String,
#[sea_orm(column_type = "JsonBinary")]
pub payload: Json,
pub created_at: DateTimeUtc,
pub applied_at: Option<DateTimeUtc>,
#[sea_orm(
belongs_to,
from = "session_id",
to = "id",
on_update = "Cascade",
on_delete = "Cascade"
)]
pub editing_session: HasOne<super::editing_session::Entity>,
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -1,23 +0,0 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0.0-rc.18
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "editing_session")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub name: Option<String>,
pub created_by: Option<Uuid>,
pub status: String,
pub created_at: DateTimeUtc,
pub applied_at: Option<DateTimeUtc>,
pub applied_by: Option<Uuid>,
pub expires_at: Option<DateTimeUtc>,
#[sea_orm(has_many)]
pub edit_operations: HasMany<super::edit_operation::Entity>,
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -3,8 +3,6 @@
pub mod prelude; pub mod prelude;
pub mod config; pub mod config;
pub mod edit_operation;
pub mod editing_session;
pub mod upstream; pub mod upstream;
pub mod upstream_target; pub mod upstream_target;
pub mod user; pub mod user;

View File

@@ -1,8 +1,6 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0.0-rc.18 //! `SeaORM` Entity, @generated by sea-orm-codegen 2.0.0-rc.18
pub use super::config::Entity as Config; pub use super::config::Entity as Config;
pub use super::edit_operation::Entity as EditOperation;
pub use super::editing_session::Entity as EditingSession;
pub use super::upstream::Entity as Upstream; pub use super::upstream::Entity as Upstream;
pub use super::upstream_target::Entity as UpstreamTarget; pub use super::upstream_target::Entity as UpstreamTarget;
pub use super::user::Entity as User; pub use super::user::Entity as User;

View File

@@ -15,7 +15,6 @@ impl MigratorTrait for Migrator {
Box::new(m20251011_000003_create_user_identity_table::Migration), Box::new(m20251011_000003_create_user_identity_table::Migration),
Box::new(m20251223_000004_create_upstream_table::Migration), Box::new(m20251223_000004_create_upstream_table::Migration),
Box::new(m20251223_000005_create_upstream_target_table::Migration), Box::new(m20251223_000005_create_upstream_target_table::Migration),
Box::new(m20251230_000006_create_editing_session_table::Migration),
] ]
} }
} }

View File

@@ -3,4 +3,3 @@ pub mod m20251011_000002_create_user_table;
pub mod m20251011_000003_create_user_identity_table; pub mod m20251011_000003_create_user_identity_table;
pub mod m20251223_000004_create_upstream_table; pub mod m20251223_000004_create_upstream_table;
pub mod m20251223_000005_create_upstream_target_table; pub mod m20251223_000005_create_upstream_target_table;
pub mod m20251230_000006_create_editing_session_table;

View File

@@ -1,117 +0,0 @@
use sea_orm_migration::{prelude::*, schema::*};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[forbid(dead_code)]
#[derive(DeriveIden)]
pub enum EditingSession {
Table,
Id,
Name,
CreatedBy,
Status,
CreatedAt,
AppliedAt,
AppliedBy,
ExpiresAt,
}
#[forbid(dead_code)]
#[derive(DeriveIden)]
pub enum EditOperation {
Table,
Id,
SessionId,
ResourceType,
ResourceId,
OperationType,
Payload,
CreatedAt,
AppliedAt,
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(EditingSession::Table)
.if_not_exists()
.col(pk_uuid(EditingSession::Id))
.col(ColumnDef::new(EditingSession::Name).string().null())
.col(ColumnDef::new(EditingSession::CreatedBy).uuid().null())
.col(
ColumnDef::new(EditingSession::Status)
.string()
.default("pending")
.not_null(),
)
.col(
ColumnDef::new(EditingSession::CreatedAt)
.timestamp()
.default(SimpleExpr::Keyword(Keyword::CurrentTimestamp))
.not_null(),
)
.col(ColumnDef::new(EditingSession::AppliedAt).timestamp().null())
.col(ColumnDef::new(EditingSession::AppliedBy).uuid().null())
.col(ColumnDef::new(EditingSession::ExpiresAt).timestamp().null())
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(EditOperation::Table)
.if_not_exists()
.col(pk_uuid(EditOperation::Id))
.col(ColumnDef::new(EditOperation::SessionId).uuid().not_null())
.col(
ColumnDef::new(EditOperation::ResourceType)
.string()
.not_null(),
) // e.g. "upstream", "location"
.col(ColumnDef::new(EditOperation::ResourceId).uuid().null()) // null for create
.col(
ColumnDef::new(EditOperation::OperationType)
.string()
.not_null(),
) // "create"|"update"|"delete"
.col(
ColumnDef::new(EditOperation::Payload)
.json_binary()
.not_null(),
) // patch or full object
.col(
ColumnDef::new(EditOperation::CreatedAt)
.timestamp()
.default(SimpleExpr::Keyword(Keyword::CurrentTimestamp))
.not_null(),
)
.col(ColumnDef::new(EditOperation::AppliedAt).timestamp().null())
.foreign_key(
ForeignKey::create()
.name("fk-edit-op-session")
.from(EditOperation::Table, EditOperation::SessionId)
.to(EditingSession::Table, EditingSession::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(EditOperation::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(EditingSession::Table).to_owned())
.await
}
}