Compare commits
23 Commits
feature/ag
...
b43f9fcb00
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b43f9fcb00 | ||
|
|
1b251fd89f | ||
|
|
dd79cbe0bb | ||
|
|
37f4163870 | ||
|
|
481abd7f26 | ||
|
|
7a557d6e00 | ||
|
|
0cbc223b4e | ||
|
|
fdfd1c98e0 | ||
|
|
abeea4fad7 | ||
|
|
fea205cba8 | ||
|
|
3cc6b40e61 | ||
|
|
41023939db | ||
|
|
6eb0d9060b | ||
|
|
7d83838da3 | ||
|
|
921165476c | ||
|
|
a0621e2407 | ||
|
|
f8b13be650 | ||
|
|
238c3db92b | ||
|
|
814f76291c | ||
|
|
7ac3368715 | ||
|
|
35fadb46f6 | ||
|
|
91d0e1cd7c | ||
| c33e3aa0ca |
160
Cargo.lock
generated
160
Cargo.lock
generated
@@ -292,6 +292,35 @@ dependencies = [
|
||||
"syn 2.0.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-test"
|
||||
version = "18.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3290e73c56c5cc4701cdd7d46b9ced1b4bd61c7e9f9c769a9e9e87ff617d75d2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"bytes",
|
||||
"bytesize",
|
||||
"cookie",
|
||||
"expect-json",
|
||||
"http",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"mime",
|
||||
"pretty_assertions",
|
||||
"reserve-port",
|
||||
"rust-multipart-rfc7578_2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"smallvec",
|
||||
"tokio",
|
||||
"tower",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base16ct"
|
||||
version = "0.2.0"
|
||||
@@ -509,6 +538,12 @@ version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
|
||||
|
||||
[[package]]
|
||||
name = "bytesize"
|
||||
version = "2.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.45"
|
||||
@@ -994,6 +1029,12 @@ dependencies = [
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "diff"
|
||||
version = "0.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -1123,6 +1164,15 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email_address"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
@@ -1191,6 +1241,35 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "expect-json"
|
||||
version = "1.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "422e7906e79941e5ac58c64dfd2da03e6ae3de62227f87606fbbe125d91080f9"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"email_address",
|
||||
"expect-json-macros",
|
||||
"num",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"typetag",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "expect-json-macros"
|
||||
version = "1.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6b515b7f10f1e61bfd938522e9884509b82060af2016153f5b3d6f44d6da89c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.3.0"
|
||||
@@ -1925,6 +2004,15 @@ dependencies = [
|
||||
"syn 2.0.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inventory"
|
||||
version = "0.3.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.11.0"
|
||||
@@ -2377,6 +2465,17 @@ dependencies = [
|
||||
"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]]
|
||||
name = "ordered-float"
|
||||
version = "4.6.0"
|
||||
@@ -2741,6 +2840,16 @@ dependencies = [
|
||||
"termtree",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pretty_assertions"
|
||||
version = "1.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d"
|
||||
dependencies = [
|
||||
"diff",
|
||||
"yansi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
@@ -3115,6 +3224,15 @@ dependencies = [
|
||||
"webpki-roots 1.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reserve-port"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21918d6644020c6f6ef1993242989bf6d4952d2e025617744f184c02df51c356"
|
||||
dependencies = [
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfc6979"
|
||||
version = "0.4.0"
|
||||
@@ -3212,6 +3330,21 @@ dependencies = [
|
||||
"ordered-multimap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-multipart-rfc7578_2"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c839d037155ebc06a571e305af66ff9fd9063a6e662447051737e1ac75beea41"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"mime",
|
||||
"rand 0.9.2",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust_decimal"
|
||||
version = "1.39.0"
|
||||
@@ -4684,6 +4817,30 @@ version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
|
||||
[[package]]
|
||||
name = "typetag"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be2212c8a9b9bcfca32024de14998494cf9a5dfa59ea1b829de98bac374b86bf"
|
||||
dependencies = [
|
||||
"erased-serde",
|
||||
"inventory",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"typetag-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typetag-impl"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27a7a9b72ba121f6f1f6c3632b85604cac41aedb5ddc70accbebb6cac83de846"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ucd-trie"
|
||||
version = "0.1.7"
|
||||
@@ -5433,6 +5590,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"axum-extra",
|
||||
"axum-test",
|
||||
"chrono",
|
||||
"clap",
|
||||
"config",
|
||||
@@ -5442,10 +5600,12 @@ dependencies = [
|
||||
"migration",
|
||||
"mime_guess",
|
||||
"once_cell",
|
||||
"optfield",
|
||||
"reqwest",
|
||||
"sea-orm",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tower",
|
||||
|
||||
@@ -30,9 +30,12 @@ jsonwebtoken = { version = "10.2.0", features = ["rust_crypto"] }
|
||||
uuid = { version = "1.19.0", features = ["v4", "serde", "fast-rng"] }
|
||||
tower-http = { version = "0.6.8", features = ["cors"] }
|
||||
reqwest = { version = "^0.12", features = ["json", "multipart", "stream"] }
|
||||
serde_urlencoded = { version = "0.7.1" }
|
||||
optfield = { version = "0.4.0" }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
axum-test = "18.4.1"
|
||||
|
||||
[lints.clippy]
|
||||
unwrap_used = "deny"
|
||||
@@ -1 +1,2 @@
|
||||
pub mod api_error;
|
||||
pub mod service_error;
|
||||
|
||||
21
apps/api/src/errors/api_error.rs
Normal file
21
apps/api/src/errors/api_error.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use axum::response::IntoResponse;
|
||||
|
||||
use crate::errors::service_error::ServiceError;
|
||||
|
||||
pub enum ApiError {
|
||||
ServiceError(ServiceError),
|
||||
}
|
||||
|
||||
impl From<ServiceError> for ApiError {
|
||||
fn from(err: ServiceError) -> Self {
|
||||
ApiError::ServiceError(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
match self {
|
||||
ApiError::ServiceError(service_error) => service_error.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use axum::response::IntoResponse;
|
||||
use sea_orm::DbErr;
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -37,3 +38,23 @@ impl From<DbErr> for ServiceError {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ServiceError {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
let (status, message) = match &self {
|
||||
ServiceError::NotFound(msg) => (axum::http::StatusCode::NOT_FOUND, msg.clone()),
|
||||
ServiceError::DatabaseError(msg) => {
|
||||
(axum::http::StatusCode::INTERNAL_SERVER_ERROR, msg.clone())
|
||||
}
|
||||
ServiceError::Unauthorized(msg) => (axum::http::StatusCode::UNAUTHORIZED, msg.clone()),
|
||||
ServiceError::InternalError(msg) => {
|
||||
(axum::http::StatusCode::INTERNAL_SERVER_ERROR, msg.clone())
|
||||
}
|
||||
ServiceError::BadRequest(msg) => (axum::http::StatusCode::BAD_REQUEST, msg.clone()),
|
||||
};
|
||||
let body = axum::Json(serde_json::json!({
|
||||
"error": message,
|
||||
}));
|
||||
(status, body).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod constants;
|
||||
pub mod database;
|
||||
pub mod macros;
|
||||
|
||||
@@ -11,3 +11,20 @@ macro_rules! with_conn {
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
pub struct Filters {
|
||||
pub pagination: Option<PaginationFilter>,
|
||||
}
|
||||
|
||||
pub struct PaginationFilter {
|
||||
pub page: u64,
|
||||
pub per_page: u64,
|
||||
}
|
||||
|
||||
impl PaginationFilter {
|
||||
pub fn get_offset_limit(&self) -> (u64, u64) {
|
||||
let offset = (self.page - 1) * self.per_page;
|
||||
let limit = self.per_page;
|
||||
(offset, limit)
|
||||
}
|
||||
}
|
||||
|
||||
9
apps/api/src/helpers/macros.rs
Normal file
9
apps/api/src/helpers/macros.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
#[macro_export]
|
||||
macro_rules! set_if_some {
|
||||
($field:expr) => {
|
||||
match $field {
|
||||
Some(value) => sea_orm::ActiveValue::Set(value),
|
||||
None => sea_orm::ActiveValue::NotSet,
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,34 @@
|
||||
use axum::{extract::FromRequestParts, http::StatusCode};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RequestInfo {
|
||||
pub user_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
pub struct AuthenticatedRequestInfo {
|
||||
pub user_id: Uuid,
|
||||
}
|
||||
|
||||
impl<S> FromRequestParts<S> for AuthenticatedRequestInfo
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = StatusCode;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut axum::http::request::Parts,
|
||||
_state: &S,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let request_info = parts
|
||||
.extensions
|
||||
.get::<RequestInfo>()
|
||||
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
if let Some(user_id) = request_info.user_id {
|
||||
Ok(AuthenticatedRequestInfo { user_id })
|
||||
} else {
|
||||
Err(StatusCode::UNAUTHORIZED)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,3 +68,42 @@ async fn handle_unauthenticated() -> Result<Response, StatusCode> {
|
||||
// TODO: log unauthenticated access attempts
|
||||
Err(StatusCode::UNAUTHORIZED)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod mock {
|
||||
|
||||
use super::*;
|
||||
|
||||
pub const REQUEST_AUTH_USER_ID_HEADER: &str = "x-mock-authenticated-user-id";
|
||||
pub const REQUEST_AUTH_USER_INVALID_HEADER: &str = "x-mock-authenticated-invalid";
|
||||
|
||||
pub async fn mock_require_auth(
|
||||
req: Request<axum::body::Body>,
|
||||
next: Next,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let mut req = req;
|
||||
let invalid_present = req
|
||||
.headers()
|
||||
.get(REQUEST_AUTH_USER_INVALID_HEADER)
|
||||
.is_some();
|
||||
let user_id_header = req.headers().get(REQUEST_AUTH_USER_ID_HEADER).cloned();
|
||||
|
||||
if invalid_present {
|
||||
return handle_unauthenticated().await;
|
||||
}
|
||||
|
||||
let user = req
|
||||
.extensions_mut()
|
||||
.get_or_insert_with(|| RequestInfo { user_id: None });
|
||||
user.user_id = Some(if let Some(user_id_header) = user_id_header {
|
||||
let user_id_str = user_id_header
|
||||
.to_str()
|
||||
.map_err(|_| StatusCode::UNAUTHORIZED)?;
|
||||
Uuid::parse_str(user_id_str).map_err(|_| StatusCode::UNAUTHORIZED)?
|
||||
} else {
|
||||
Uuid::new_v4()
|
||||
});
|
||||
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
mod auth;
|
||||
mod health;
|
||||
mod helper;
|
||||
mod openapi;
|
||||
mod restricted;
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ pub async fn get_health_info(
|
||||
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::{
|
||||
@@ -116,18 +117,10 @@ mod test {
|
||||
let app_state = Arc::new(AppState {
|
||||
database_connection: db.clone(),
|
||||
config: Arc::new(crate::configs::ProgramSettings::mock()),
|
||||
service: Arc::new(crate::routes::AppService {
|
||||
settings: Arc::new(SettingsService::new(db.clone())),
|
||||
auth_state: crate::routes::AuthState {
|
||||
strategy: crate::routes::AuthStrategy {
|
||||
password: Arc::new(PasswordStrategy::new(db.clone())),
|
||||
},
|
||||
authentication: Arc::new(AuthenticationServiceImpl::new(None)),
|
||||
},
|
||||
user: Arc::new(UserServiceImpl::new(db.clone())),
|
||||
server_state: Arc::new(ServerStateService::new(db.clone())),
|
||||
agent_client: Arc::new(AgentService::new(Configuration::default())),
|
||||
}),
|
||||
service: Arc::new(get_app_service(
|
||||
&db.clone(),
|
||||
&crate::configs::ProgramSettings::mock(),
|
||||
)),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
|
||||
1
apps/api/src/routes/api/helper.rs
Normal file
1
apps/api/src/routes/api/helper.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod pagination;
|
||||
76
apps/api/src/routes/api/helper/pagination.rs
Normal file
76
apps/api/src/routes/api/helper/pagination.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
use axum::{
|
||||
extract::FromRequestParts,
|
||||
http::{StatusCode, request::Parts},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::helpers::database::PaginationFilter;
|
||||
|
||||
#[derive(Serialize, Deserialize, utoipa::ToSchema, Clone)]
|
||||
/// Pagination parameters for API requests
|
||||
pub struct Pagination {
|
||||
/// Page number (1-based)
|
||||
pub page: u32,
|
||||
/// Items per page
|
||||
pub per_page: u32,
|
||||
}
|
||||
|
||||
impl Default for Pagination {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
page: 1,
|
||||
per_page: 20,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Pagination> for PaginationFilter {
|
||||
fn from(pagination: Pagination) -> Self {
|
||||
Self {
|
||||
page: pagination.page as u64,
|
||||
per_page: pagination.per_page as u64,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
|
||||
/// Pagination information included in API responses
|
||||
pub struct PaginationInfo {
|
||||
/// Total number of items
|
||||
pub total_items: u64,
|
||||
/// Total number of pages
|
||||
pub total_pages: u32,
|
||||
/// Current page number
|
||||
pub current_page: u32,
|
||||
/// Items per page
|
||||
pub per_page: u32,
|
||||
}
|
||||
|
||||
/// Extractor for pagination parameters from query string
|
||||
pub struct ExtractPagination(pub Pagination);
|
||||
|
||||
impl<S> FromRequestParts<S> for ExtractPagination
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = (StatusCode, &'static str);
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
let query = parts.uri.query().unwrap_or("");
|
||||
let pagination: Pagination = serde_urlencoded::from_str(query).unwrap_or_default();
|
||||
|
||||
// validation
|
||||
if pagination.page == 0 {
|
||||
return Err((StatusCode::BAD_REQUEST, "page must be greater than 0"));
|
||||
}
|
||||
|
||||
if pagination.per_page < 1 || pagination.per_page > 100 {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"per_page must be between 1 and 100",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(ExtractPagination(pagination))
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod nginx;
|
||||
pub mod user;
|
||||
|
||||
use std::sync::Arc;
|
||||
@@ -9,6 +10,7 @@ use crate::{middlewares::require_auth::require_auth, routes::AppState};
|
||||
pub fn get_restricted_router(state: Arc<AppState>) -> Router {
|
||||
Router::new()
|
||||
.nest("/user", user::get_user_router(state.clone()))
|
||||
.nest("/nginx", nginx::get_nginx_router(state.clone()))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_auth,
|
||||
|
||||
11
apps/api/src/routes/api/restricted/nginx.rs
Normal file
11
apps/api/src/routes/api/restricted/nginx.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
pub mod upstream;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::Router;
|
||||
|
||||
use crate::routes::AppState;
|
||||
|
||||
pub fn get_nginx_router(state: Arc<AppState>) -> Router {
|
||||
Router::new().merge(upstream::get_upstream_router(state.clone()))
|
||||
}
|
||||
32
apps/api/src/routes/api/restricted/nginx/upstream.rs
Normal file
32
apps/api/src/routes/api/restricted/nginx/upstream.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
pub mod create_upstream;
|
||||
pub mod create_upstream_target;
|
||||
pub mod get_upstream;
|
||||
pub mod get_upstream_target;
|
||||
pub mod info;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
routing::{get, post},
|
||||
};
|
||||
|
||||
use crate::routes::AppState;
|
||||
|
||||
pub fn get_upstream_router(state: Arc<AppState>) -> Router {
|
||||
Router::new()
|
||||
.route(
|
||||
"/upstreams",
|
||||
get(get_upstream::get_upstream_list).post(create_upstream::create_upstream),
|
||||
)
|
||||
.route("/upstreams/{upstream_id}", get(get_upstream::get_upstream))
|
||||
.route(
|
||||
"/upstreams/{upstream_id}/targets",
|
||||
post(create_upstream_target::add_upstream_target),
|
||||
)
|
||||
.route(
|
||||
"/upstream_targets/{upstream_target_id}",
|
||||
get(get_upstream_target::get_upstream_target),
|
||||
)
|
||||
.with_state(state)
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{Json, extract::State, response::Result as AxumResult};
|
||||
|
||||
use crate::{
|
||||
errors::api_error::ApiError,
|
||||
middlewares::request_info::AuthenticatedRequestInfo,
|
||||
routes::{AppState, api::restricted::nginx::upstream::info::response::UpstreamInfoResponse},
|
||||
services::nginx::info::upstream::UpstreamCreateInfo,
|
||||
};
|
||||
|
||||
#[derive(serde::Deserialize, utoipa::ToSchema, serde::Serialize)]
|
||||
pub struct UpstreamTargetInfo {
|
||||
pub host: String,
|
||||
pub port: i64,
|
||||
pub weight: Option<i64>,
|
||||
pub is_backup: Option<bool>,
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
pub struct ConcreteUpstreamTargetInfo {
|
||||
pub host: String,
|
||||
pub port: i64,
|
||||
pub weight: i64,
|
||||
pub is_backup: bool,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl From<UpstreamTargetInfo> for ConcreteUpstreamTargetInfo {
|
||||
fn from(info: UpstreamTargetInfo) -> Self {
|
||||
Self {
|
||||
host: info.host,
|
||||
port: info.port,
|
||||
weight: info.weight.unwrap_or(1),
|
||||
is_backup: info.is_backup.unwrap_or(false),
|
||||
enabled: info.enabled.unwrap_or(true),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, utoipa::ToSchema, serde::Serialize)]
|
||||
pub struct CreateUpstreamRequestBody {
|
||||
pub name: String,
|
||||
pub protocol: String,
|
||||
pub algorithm: Option<String>,
|
||||
pub sticky_session: Option<bool>,
|
||||
pub upstream_targets: Vec<UpstreamTargetInfo>,
|
||||
}
|
||||
|
||||
struct ConcreteCreateUpstreamRequestBody {
|
||||
pub name: String,
|
||||
pub protocol: String,
|
||||
pub algorithm: String,
|
||||
pub sticky_session: bool,
|
||||
pub upstream_targets: Vec<ConcreteUpstreamTargetInfo>,
|
||||
}
|
||||
|
||||
impl From<CreateUpstreamRequestBody> for ConcreteCreateUpstreamRequestBody {
|
||||
fn from(payload: CreateUpstreamRequestBody) -> Self {
|
||||
Self {
|
||||
name: payload.name,
|
||||
protocol: payload.protocol,
|
||||
algorithm: payload
|
||||
.algorithm
|
||||
.unwrap_or_else(|| "round_robin".to_string()),
|
||||
sticky_session: payload.sticky_session.unwrap_or(false),
|
||||
upstream_targets: payload
|
||||
.upstream_targets
|
||||
.into_iter()
|
||||
.map(|target| target.into())
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[axum::debug_handler]
|
||||
pub async fn create_upstream(
|
||||
request_info: AuthenticatedRequestInfo,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(payload): Json<CreateUpstreamRequestBody>,
|
||||
) -> AxumResult<Json<UpstreamInfoResponse>, ApiError> {
|
||||
let upstream_service = &state.service.nginx.get_upstream_service();
|
||||
let concrete_payload: ConcreteCreateUpstreamRequestBody = payload.into();
|
||||
|
||||
let create_info = UpstreamCreateInfo {
|
||||
name: concrete_payload.name,
|
||||
protocol: concrete_payload.protocol,
|
||||
algorithm: concrete_payload.algorithm,
|
||||
sticky_session: concrete_payload.sticky_session,
|
||||
created_by: Some(request_info.user_id),
|
||||
upstream_targets: concrete_payload
|
||||
.upstream_targets
|
||||
.into_iter()
|
||||
.map(
|
||||
|target| crate::services::nginx::info::upstream_target::UpstreamTargetCreateInfo {
|
||||
target_host: target.host,
|
||||
target_port: target.port,
|
||||
weight: target.weight,
|
||||
is_backup: target.is_backup,
|
||||
enabled: target.enabled,
|
||||
upstream_id: uuid::Uuid::nil(), // Placeholder, will be set in service
|
||||
},
|
||||
)
|
||||
.collect(),
|
||||
};
|
||||
|
||||
let upstream_info = upstream_service.create_upstream(create_info, None).await?;
|
||||
|
||||
Ok(Json(upstream_info.into()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum_test::TestServer;
|
||||
use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase};
|
||||
|
||||
use database::generated::entities::{upstream, upstream_target};
|
||||
|
||||
use crate::{
|
||||
configs::{FromConfig, ProgramSettings},
|
||||
middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER,
|
||||
routes::api::restricted::nginx::upstream::{
|
||||
create_upstream::{CreateUpstreamRequestBody, UpstreamTargetInfo as ReqTarget},
|
||||
get_upstream_router,
|
||||
},
|
||||
services::get_app_service,
|
||||
};
|
||||
|
||||
fn get_router_with_state(db: DatabaseConnection) -> axum::Router {
|
||||
let program_settings = ProgramSettings::mock();
|
||||
let app_service = get_app_service(&Arc::new(db.clone()), &program_settings);
|
||||
let state = Arc::new(crate::routes::AppState {
|
||||
database_connection: Arc::new(db),
|
||||
service: Arc::new(app_service),
|
||||
config: Arc::new(program_settings),
|
||||
});
|
||||
get_upstream_router(state).layer(axum::middleware::from_fn(
|
||||
crate::middlewares::require_auth::mock::mock_require_auth,
|
||||
))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_create_upstream_succeeds_returns_created() {
|
||||
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(),
|
||||
};
|
||||
|
||||
// service will likely perform an insert and then query to return created models
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![vec![up_model.clone()]])
|
||||
.append_query_results(vec![vec![target_model.clone()]])
|
||||
.into_connection();
|
||||
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let payload = 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_ok();
|
||||
let text = res.text();
|
||||
let body: crate::routes::api::restricted::nginx::upstream::info::response::UpstreamInfoResponse =
|
||||
serde_json::from_str(&text).expect("failed to parse json");
|
||||
|
||||
assert_eq!(body.id, up_id);
|
||||
assert_eq!(body.name, "new_upstream");
|
||||
assert_eq!(body.protocol, "http");
|
||||
assert_eq!(body.upstream_targets.len(), 1);
|
||||
assert_eq!(body.upstream_targets[0].id, target_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_create_upstream_invalid_payload_returns_bad_request() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection();
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
// missing required fields -> send empty object
|
||||
let res = server.post("/upstreams").json(&serde_json::json!({})).await;
|
||||
res.assert_status(StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_create_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 = 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")
|
||||
.add_header(REQUEST_AUTH_USER_INVALID_HEADER, "true")
|
||||
.json(&payload)
|
||||
.await;
|
||||
|
||||
res.assert_status(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{Json, extract::State, response::Result as AxumResult};
|
||||
|
||||
use crate::{
|
||||
errors::api_error::ApiError,
|
||||
middlewares::request_info::AuthenticatedRequestInfo,
|
||||
routes::{
|
||||
AppState, api::restricted::nginx::upstream::info::response::UpstreamTargetInfoResponse,
|
||||
},
|
||||
services::nginx::info::upstream_target::UpstreamTargetCreateInfo,
|
||||
};
|
||||
|
||||
#[derive(serde::Deserialize, utoipa::ToSchema, serde::Serialize)]
|
||||
pub struct CreateUpstreamTargetInfo {
|
||||
pub upstream_id: uuid::Uuid,
|
||||
pub host: String,
|
||||
pub port: i64,
|
||||
pub weight: Option<i64>,
|
||||
pub is_backup: Option<bool>,
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
pub struct ConcreteCreateUpstreamTargetInfo {
|
||||
pub upstream_id: uuid::Uuid,
|
||||
pub host: String,
|
||||
pub port: i64,
|
||||
pub weight: i64,
|
||||
pub is_backup: bool,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl From<CreateUpstreamTargetInfo> for ConcreteCreateUpstreamTargetInfo {
|
||||
fn from(info: CreateUpstreamTargetInfo) -> Self {
|
||||
Self {
|
||||
upstream_id: info.upstream_id,
|
||||
host: info.host,
|
||||
port: info.port,
|
||||
weight: info.weight.unwrap_or(1),
|
||||
is_backup: info.is_backup.unwrap_or(false),
|
||||
enabled: info.enabled.unwrap_or(true),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[axum::debug_handler]
|
||||
pub async fn add_upstream_target(
|
||||
_request_info: AuthenticatedRequestInfo,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(payload): Json<CreateUpstreamTargetInfo>,
|
||||
) -> AxumResult<Json<UpstreamTargetInfoResponse>, ApiError> {
|
||||
let upstream_service = &state.service.nginx.get_upstream_service();
|
||||
let concrete_payload: ConcreteCreateUpstreamTargetInfo = payload.into();
|
||||
|
||||
let create_info = UpstreamTargetCreateInfo {
|
||||
weight: concrete_payload.weight,
|
||||
is_backup: concrete_payload.is_backup,
|
||||
enabled: concrete_payload.enabled,
|
||||
target_host: concrete_payload.host,
|
||||
target_port: concrete_payload.port,
|
||||
upstream_id: concrete_payload.upstream_id,
|
||||
};
|
||||
|
||||
let upstream_info = upstream_service
|
||||
.create_upstream_target(create_info, None)
|
||||
.await?;
|
||||
|
||||
Ok(Json(upstream_info.into()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum_test::TestServer;
|
||||
use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase};
|
||||
|
||||
use database::generated::entities::upstream_target;
|
||||
|
||||
use crate::{
|
||||
configs::{FromConfig, ProgramSettings},
|
||||
middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER,
|
||||
routes::api::restricted::nginx::upstream::{
|
||||
create_upstream_target::CreateUpstreamTargetInfo, get_upstream_router,
|
||||
},
|
||||
services::get_app_service,
|
||||
};
|
||||
|
||||
fn get_router_with_state(db: DatabaseConnection) -> axum::Router {
|
||||
let program_settings = ProgramSettings::mock();
|
||||
let app_service = get_app_service(&Arc::new(db.clone()), &program_settings);
|
||||
let state = Arc::new(crate::routes::AppState {
|
||||
database_connection: Arc::new(db),
|
||||
service: Arc::new(app_service),
|
||||
config: Arc::new(program_settings),
|
||||
});
|
||||
get_upstream_router(state).layer(axum::middleware::from_fn(
|
||||
crate::middlewares::require_auth::mock::mock_require_auth,
|
||||
))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_add_upstream_target_succeeds_returns_created() {
|
||||
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 db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![vec![target_model.clone()]])
|
||||
.into_connection();
|
||||
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let payload = 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_ok();
|
||||
let text = res.text();
|
||||
let body: crate::routes::api::restricted::nginx::upstream::info::response::UpstreamTargetInfoResponse =
|
||||
serde_json::from_str(&text).expect("failed to parse json");
|
||||
|
||||
assert_eq!(body.id, target_id);
|
||||
assert_eq!(body.host, "127.0.0.1");
|
||||
assert_eq!(body.port, 8080);
|
||||
assert_eq!(body.upstream_id, up_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_add_upstream_target_invalid_payload_returns_bad_request() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection();
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let res = server
|
||||
.post(&format!("/upstreams/{}/targets", uuid::Uuid::new_v4()))
|
||||
.json(&serde_json::json!({}))
|
||||
.await;
|
||||
|
||||
res.assert_status(StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_add_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 = CreateUpstreamTargetInfo {
|
||||
upstream_id: uuid::Uuid::new_v4(),
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 8080,
|
||||
weight: None,
|
||||
is_backup: None,
|
||||
enabled: None,
|
||||
};
|
||||
|
||||
let res = server
|
||||
.post(&format!("/upstreams/{}/targets", payload.upstream_id))
|
||||
.add_header(REQUEST_AUTH_USER_INVALID_HEADER, "true")
|
||||
.json(&payload)
|
||||
.await;
|
||||
|
||||
res.assert_status(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
response::Result as AxumResult,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
errors::{api_error::ApiError, service_error::ServiceError},
|
||||
routes::{
|
||||
AppState,
|
||||
api::{
|
||||
helper::pagination::{ExtractPagination, PaginationInfo},
|
||||
restricted::nginx::upstream::info::response::{
|
||||
UpstreamInfoResponse, UpstreamListResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
services::nginx::upstream::GetUpstreamOptions,
|
||||
};
|
||||
|
||||
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct GetUpstreamParams {
|
||||
pub include_targets: Option<bool>,
|
||||
}
|
||||
|
||||
pub struct ConcreteGetUpstreamParams {
|
||||
pub include_targets: bool,
|
||||
}
|
||||
|
||||
impl From<GetUpstreamParams> for ConcreteGetUpstreamParams {
|
||||
fn from(params: GetUpstreamParams) -> Self {
|
||||
Self {
|
||||
include_targets: params.include_targets.unwrap_or(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_upstream_list(
|
||||
ExtractPagination(pagination): ExtractPagination,
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> AxumResult<Json<UpstreamListResponse>, ServiceError> {
|
||||
let upstream_service = &state.service.nginx.get_upstream_service();
|
||||
let upstreams = upstream_service
|
||||
.get_upstreams(Some(pagination.clone().into()), None)
|
||||
.await?;
|
||||
|
||||
//
|
||||
Ok(Json(UpstreamListResponse {
|
||||
items: upstreams.into_iter().map(|u| u.into()).collect(),
|
||||
pagination: PaginationInfo {
|
||||
total_items: 0,
|
||||
total_pages: 0,
|
||||
current_page: pagination.page,
|
||||
per_page: pagination.per_page,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_upstream(
|
||||
Path(upstream_id): Path<Uuid>,
|
||||
Query(params): Query<GetUpstreamParams>,
|
||||
State(_state): State<Arc<AppState>>,
|
||||
) -> AxumResult<Json<UpstreamInfoResponse>, ApiError> {
|
||||
let concrete_params: ConcreteGetUpstreamParams = params.into();
|
||||
let upstream_service = &_state.service.nginx.get_upstream_service();
|
||||
let upstream_info = if concrete_params.include_targets {
|
||||
upstream_service
|
||||
.get_upstream(
|
||||
upstream_id,
|
||||
Some(GetUpstreamOptions {
|
||||
include_targets: true,
|
||||
}),
|
||||
None,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
upstream_service
|
||||
.get_upstream(upstream_id, None, None)
|
||||
.await?
|
||||
};
|
||||
|
||||
//
|
||||
Ok(Json(upstream_info.into()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
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 crate::configs::{FromConfig, ProgramSettings};
|
||||
|
||||
use crate::routes::api::restricted::nginx::upstream::get_upstream_router;
|
||||
use crate::routes::api::restricted::nginx::upstream::info::response::UpstreamInfoResponse;
|
||||
use crate::services::get_app_service;
|
||||
|
||||
fn get_router_with_state(db: DatabaseConnection) -> axum::Router {
|
||||
let program_settings = ProgramSettings::mock();
|
||||
let app_service = get_app_service(&Arc::new(db.clone()), &program_settings);
|
||||
let state = Arc::new(crate::routes::AppState {
|
||||
database_connection: Arc::new(db),
|
||||
service: Arc::new(app_service),
|
||||
config: Arc::new(program_settings),
|
||||
});
|
||||
get_upstream_router(state)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_get_upstream_list_returns_list() {
|
||||
let u1 = upstream::Model {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
name: "u1".to_string(),
|
||||
protocol: "http".to_string(),
|
||||
algorithm: "rr".to_string(),
|
||||
sticky_session: false,
|
||||
created_by: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
let u2 = upstream::Model {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
name: "u2".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 db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![vec![u1.clone(), u2.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.get("/upstreams").await;
|
||||
res.assert_status_ok();
|
||||
let body = res.json::<UpstreamListResponse>();
|
||||
assert_eq!(body.items.len(), 2);
|
||||
assert_eq!(body.pagination.current_page, 1u32);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_get_upstream_with_targets_returns_targets() {
|
||||
let up_id = uuid::Uuid::new_v4();
|
||||
|
||||
let up_model = upstream::Model {
|
||||
id: up_id,
|
||||
name: "with_targets".to_string(),
|
||||
protocol: "http".to_string(),
|
||||
algorithm: "least_conn".to_string(),
|
||||
sticky_session: true,
|
||||
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)
|
||||
// find_by_id -> returns upstream model
|
||||
.append_query_results(vec![vec![up_model.clone()]])
|
||||
// find targets -> returns the target(s)
|
||||
.append_query_results(vec![vec![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 url = format!("/upstreams/{}?include_targets=true", up_id);
|
||||
let res = server.get(&url).await;
|
||||
res.assert_status_ok();
|
||||
let body = res.json::<UpstreamInfoResponse>();
|
||||
assert_eq!(body.id, up_id);
|
||||
assert_eq!(body.upstream_targets.len(), 1);
|
||||
assert_eq!(body.upstream_targets[0].target_host, "127.0.0.1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn extractor_pagination_validation_rejects_bad_values() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![Vec::<sea_orm::MockRow>::new()])
|
||||
.into_connection();
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
// page = 0 should be rejected
|
||||
let res = server.get("/upstreams?page=0&per_page=10").await;
|
||||
res.assert_status(StatusCode::BAD_REQUEST);
|
||||
|
||||
// per_page out of range should be rejected
|
||||
let res = server.get("/upstreams?page=1&per_page=0").await;
|
||||
res.assert_status(StatusCode::BAD_REQUEST);
|
||||
|
||||
// valid values accepted
|
||||
let res = server.get("/upstreams?page=2&per_page=5").await;
|
||||
res.assert_status_ok();
|
||||
let body = res.json::<UpstreamListResponse>();
|
||||
assert_eq!(body.pagination.current_page, 2u32);
|
||||
assert_eq!(body.pagination.per_page, 5u32);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_get_upstream_not_found_returns_service_error() {
|
||||
let up_id = uuid::Uuid::new_v4();
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![Vec::<sea_orm::MockRow>::new()])
|
||||
.into_connection();
|
||||
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let url = format!("/upstreams/{}?include_targets=false", up_id);
|
||||
let res = server.get(&url).await;
|
||||
res.assert_status(StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_get_upstream_without_targets_returns_info() {
|
||||
let up_id = uuid::Uuid::new_v4();
|
||||
|
||||
let up_model = upstream::Model {
|
||||
id: up_id,
|
||||
name: "simple_up".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 db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
// find_by_id -> returns upstream model
|
||||
.append_query_results(vec![vec![up_model.clone()]])
|
||||
.into_connection();
|
||||
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
// include_targets omitted -> should not include targets
|
||||
let url = format!("/upstreams/{}", up_id);
|
||||
let res = server.get(&url).await;
|
||||
res.assert_status_ok();
|
||||
let body = res.json::<UpstreamInfoResponse>();
|
||||
assert_eq!(body.id, up_id);
|
||||
assert!(body.upstream_targets.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_get_upstream_list_empty_returns_empty_items() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![Vec::<sea_orm::MockRow>::new()])
|
||||
.into_connection();
|
||||
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let res = server.get("/upstreams?page=3&per_page=10").await;
|
||||
res.assert_status_ok();
|
||||
let body = res.json::<UpstreamListResponse>();
|
||||
assert_eq!(body.items.len(), 0);
|
||||
assert_eq!(body.pagination.current_page, 3u32);
|
||||
assert_eq!(body.pagination.per_page, 10u32);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
response::Result as AxumResult,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
errors::api_error::ApiError,
|
||||
routes::{AppState, api::restricted::nginx::upstream::info::response::UpstreamTargetInfo},
|
||||
};
|
||||
|
||||
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct GetUpstreamTargetsParams {
|
||||
pub include_upstream: Option<bool>,
|
||||
}
|
||||
|
||||
pub struct ConcreteGetUpstreamTargetsParams {
|
||||
pub include_upstream: bool,
|
||||
}
|
||||
|
||||
impl From<GetUpstreamTargetsParams> for ConcreteGetUpstreamTargetsParams {
|
||||
fn from(params: GetUpstreamTargetsParams) -> Self {
|
||||
Self {
|
||||
include_upstream: params.include_upstream.unwrap_or(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_upstream_target(
|
||||
Path(upstream_target_id): Path<Uuid>,
|
||||
Query(params): Query<GetUpstreamTargetsParams>,
|
||||
State(_state): State<Arc<AppState>>,
|
||||
) -> AxumResult<Json<UpstreamTargetInfo>, ApiError> {
|
||||
let concrete_params: ConcreteGetUpstreamTargetsParams = params.into();
|
||||
let upstream_service = &_state.service.nginx.get_upstream_service();
|
||||
let upstream_target_info = upstream_service
|
||||
.get_upstream_target(
|
||||
upstream_target_id,
|
||||
if concrete_params.include_upstream {
|
||||
Some(crate::services::nginx::upstream::GetUpstreamTargetOptions {
|
||||
include_upstream: true,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(upstream_target_info.into()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
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 crate::configs::{FromConfig, ProgramSettings};
|
||||
|
||||
use crate::routes::api::restricted::nginx::upstream::get_upstream_router;
|
||||
use crate::services::get_app_service;
|
||||
|
||||
fn get_router_with_state(db: DatabaseConnection) -> axum::Router {
|
||||
let program_settings = ProgramSettings::mock();
|
||||
let app_service = get_app_service(&Arc::new(db.clone()), &program_settings);
|
||||
let state = Arc::new(crate::routes::AppState {
|
||||
database_connection: Arc::new(db),
|
||||
service: Arc::new(app_service),
|
||||
config: Arc::new(program_settings),
|
||||
});
|
||||
get_upstream_router(state)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_get_upstream_target_with_upstream_returns_upstream() {
|
||||
let up_id = uuid::Uuid::new_v4();
|
||||
|
||||
let up_model = upstream::Model {
|
||||
id: up_id,
|
||||
name: "with_targets".to_string(),
|
||||
protocol: "http".to_string(),
|
||||
algorithm: "least_conn".to_string(),
|
||||
sticky_session: true,
|
||||
created_by: None,
|
||||
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(),
|
||||
};
|
||||
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
// query returns joined (upstream_target, upstream)
|
||||
.append_query_results(vec![vec![(target_model.clone(), Some(up_model.clone()))]])
|
||||
.into_connection();
|
||||
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let url = format!("/upstream_targets/{}?include_upstream=true", target_id);
|
||||
let res = server.get(&url).await;
|
||||
res.assert_status_ok();
|
||||
let text = res.text();
|
||||
let body: UpstreamTargetInfo = serde_json::from_str(&text).expect("failed to parse json");
|
||||
assert_eq!(body.upstream_id, up_id);
|
||||
assert!(body.upstream.is_some());
|
||||
let upstream = body.upstream.expect("upstream to be present");
|
||||
assert_eq!(upstream.id, up_id);
|
||||
assert_eq!(upstream.name, "with_targets");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_get_upstream_target_without_upstream_returns_info() {
|
||||
let target_id = uuid::Uuid::new_v4();
|
||||
|
||||
let target_model = upstream_target::Model {
|
||||
id: target_id,
|
||||
upstream_id: uuid::Uuid::new_v4(),
|
||||
target_host: "10.0.0.1".to_string(),
|
||||
target_port: 9090,
|
||||
weight: 5,
|
||||
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![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 url = format!("/upstream_targets/{}", target_id);
|
||||
let res = server.get(&url).await;
|
||||
res.assert_status_ok();
|
||||
let text = res.text();
|
||||
let body: UpstreamTargetInfo = serde_json::from_str(&text).expect("failed to parse json");
|
||||
assert_eq!(body.id, target_id);
|
||||
assert!(body.upstream.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_get_upstream_target_not_found_returns_service_error() {
|
||||
let target_id = uuid::Uuid::new_v4();
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![Vec::<sea_orm::MockRow>::new()])
|
||||
.into_connection();
|
||||
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let url = format!("/upstream_targets/{}?include_upstream=false", target_id);
|
||||
let res = server.get(&url).await;
|
||||
res.assert_status(StatusCode::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod response;
|
||||
@@ -0,0 +1,164 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::routes::api::helper::pagination::PaginationInfo;
|
||||
|
||||
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct UpstreamTargetInfo {
|
||||
pub id: uuid::Uuid,
|
||||
pub target_host: String,
|
||||
pub target_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,
|
||||
pub upstream: Option<UpstreamBasicInfo>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct UpstreamBasicInfo {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub protocol: String,
|
||||
//
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl From<crate::services::nginx::info::upstream_target::UpstreamTargetInfo>
|
||||
for UpstreamTargetInfo
|
||||
{
|
||||
fn from(info: crate::services::nginx::info::upstream_target::UpstreamTargetInfo) -> Self {
|
||||
Self {
|
||||
id: info.id,
|
||||
target_host: info.target_host,
|
||||
target_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,
|
||||
upstream: info.upstream.map(|u| UpstreamBasicInfo {
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
protocol: u.protocol,
|
||||
created_at: u.created_at,
|
||||
updated_at: u.updated_at,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct UpstreamTargetBasicInfo {
|
||||
pub id: uuid::Uuid,
|
||||
pub target_host: String,
|
||||
pub target_port: i64,
|
||||
pub enabled: bool,
|
||||
pub is_backup: bool,
|
||||
pub weight: i32,
|
||||
//
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl From<crate::services::nginx::info::upstream_target::UpstreamTargetInfo>
|
||||
for UpstreamTargetBasicInfo
|
||||
{
|
||||
fn from(info: crate::services::nginx::info::upstream_target::UpstreamTargetInfo) -> Self {
|
||||
Self {
|
||||
id: info.id,
|
||||
target_host: info.target_host,
|
||||
target_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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct UpstreamInfoResponse {
|
||||
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 UpstreamInfoResponse {
|
||||
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 UpstreamListResponse {
|
||||
pub items: Vec<UpstreamInfoResponse>,
|
||||
pub pagination: PaginationInfo,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct UpstreamTargetInfoResponse {
|
||||
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 UpstreamTargetInfoResponse
|
||||
{
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod agent_client;
|
||||
pub mod auth;
|
||||
pub mod nginx;
|
||||
pub mod server_state;
|
||||
pub mod settings;
|
||||
|
||||
@@ -15,6 +16,7 @@ use crate::{
|
||||
authentication::{AuthenticationServiceImpl, strategies::password::PasswordStrategy},
|
||||
user::{UserService, UserServiceImpl},
|
||||
},
|
||||
nginx::NginxService,
|
||||
server_state::{ServerStateService, ServerStateStore},
|
||||
settings::{SettingsService, SettingsStore},
|
||||
},
|
||||
@@ -28,6 +30,8 @@ pub struct AppService {
|
||||
pub user: ServiceState<dyn UserService>,
|
||||
pub server_state: ServiceState<dyn ServerStateStore>,
|
||||
#[allow(dead_code)]
|
||||
pub nginx: ServiceState<NginxService>,
|
||||
#[allow(dead_code)]
|
||||
pub agent_client: ServiceState<agent_client::AgentService>,
|
||||
}
|
||||
|
||||
@@ -47,6 +51,7 @@ pub fn get_app_service(
|
||||
)),
|
||||
},
|
||||
user: Arc::new(UserServiceImpl::new(db_connection.clone())),
|
||||
nginx: Arc::new(NginxService::new(db_connection.clone())),
|
||||
agent_client: Arc::new(agent_client::AgentService::new(Configuration::from(
|
||||
settings.agent.clone(),
|
||||
))),
|
||||
|
||||
31
apps/api/src/services/nginx.rs
Normal file
31
apps/api/src/services/nginx.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
pub mod builder;
|
||||
pub mod info;
|
||||
pub mod traits;
|
||||
|
||||
pub mod upstream;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use sea_orm::DatabaseConnection;
|
||||
|
||||
use upstream::UpstreamService;
|
||||
|
||||
pub struct NginxService {
|
||||
connection: Arc<DatabaseConnection>,
|
||||
//
|
||||
upstream_service: Arc<UpstreamService>,
|
||||
}
|
||||
|
||||
impl NginxService {
|
||||
pub fn new(connection: Arc<DatabaseConnection>) -> Self {
|
||||
Self {
|
||||
connection: connection.clone(),
|
||||
//
|
||||
upstream_service: Arc::new(UpstreamService::new(connection.clone())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_upstream_service(&self) -> Arc<UpstreamService> {
|
||||
self.upstream_service.clone()
|
||||
}
|
||||
}
|
||||
47
apps/api/src/services/nginx/builder.rs
Normal file
47
apps/api/src/services/nginx/builder.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use crate::services::nginx::info::upstream::UpstreamInfo;
|
||||
|
||||
pub const INDENT_SIZE: usize = 2;
|
||||
|
||||
pub trait NginxConfigProvider {
|
||||
fn to_nginx_config(&self, indent: Option<usize>) -> String;
|
||||
}
|
||||
|
||||
pub struct NginxConfigBuilder {
|
||||
upstreams: Vec<UpstreamInfo>,
|
||||
}
|
||||
|
||||
impl NginxConfigBuilder {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
upstreams: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_upstream(&mut self, upstream: UpstreamInfo) {
|
||||
self.upstreams.push(upstream);
|
||||
}
|
||||
|
||||
pub fn add_upstreams(&mut self, upstreams: Vec<UpstreamInfo>) {
|
||||
for upstream in upstreams {
|
||||
self.add_upstream(upstream);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NginxConfigProvider for NginxConfigBuilder {
|
||||
fn to_nginx_config(&self, indent: Option<usize>) -> String {
|
||||
let mut config = format!(
|
||||
"# Nginx Config Generated by YANPM at {}",
|
||||
chrono::Utc::now()
|
||||
);
|
||||
|
||||
for upstream in &self.upstreams {
|
||||
config.push('\n');
|
||||
config.push_str(&upstream.to_nginx_config(indent));
|
||||
}
|
||||
|
||||
// TODO: Add other sections like servers, locations, etc.
|
||||
|
||||
config
|
||||
}
|
||||
}
|
||||
2
apps/api/src/services/nginx/info.rs
Normal file
2
apps/api/src/services/nginx/info.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod upstream;
|
||||
pub mod upstream_target;
|
||||
159
apps/api/src/services/nginx/info/upstream.rs
Normal file
159
apps/api/src/services/nginx/info/upstream.rs
Normal file
@@ -0,0 +1,159 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use optfield::optfield;
|
||||
|
||||
use database::generated::entities::{upstream, upstream_target};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
services::nginx::{
|
||||
builder::{INDENT_SIZE, NginxConfigProvider},
|
||||
info::upstream_target as upstream_target_info,
|
||||
traits::indentable::Indentable,
|
||||
},
|
||||
set_if_some,
|
||||
};
|
||||
|
||||
#[optfield(pub UpdateUpstreamInfo)]
|
||||
#[derive(Clone)]
|
||||
pub struct UpstreamInfo {
|
||||
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<upstream_target_info::UpstreamTargetInfo>,
|
||||
}
|
||||
|
||||
pub struct UpstreamCreateInfo {
|
||||
pub name: String,
|
||||
pub protocol: String,
|
||||
pub algorithm: String,
|
||||
pub sticky_session: bool,
|
||||
pub created_by: Option<uuid::Uuid>,
|
||||
//
|
||||
pub upstream_targets: Vec<upstream_target_info::UpstreamTargetCreateInfo>,
|
||||
}
|
||||
|
||||
impl NginxConfigProvider for UpstreamInfo {
|
||||
fn to_nginx_config(&self, indent: Option<usize>) -> String {
|
||||
let targets_config: Vec<String> = self
|
||||
.upstream_targets
|
||||
.iter()
|
||||
.map(|target| target.to_nginx_config(Some(indent.unwrap_or(0) + INDENT_SIZE)))
|
||||
.collect();
|
||||
|
||||
format!(
|
||||
"upstream {} {{\n{}\n}}",
|
||||
self.name,
|
||||
targets_config.join("\n".indent(indent.unwrap_or(0) + INDENT_SIZE).as_str())
|
||||
)
|
||||
.indent(indent.unwrap_or(0))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UpstreamCreateInfo> for (upstream::ActiveModel, Vec<upstream_target::ActiveModel>) {
|
||||
fn from(val: UpstreamCreateInfo) -> (upstream::ActiveModel, Vec<upstream_target::ActiveModel>) {
|
||||
let upstream_uuid = Uuid::new_v4();
|
||||
let upstream = upstream::ActiveModel {
|
||||
id: sea_orm::ActiveValue::Set(upstream_uuid),
|
||||
name: sea_orm::ActiveValue::Set(val.name),
|
||||
protocol: sea_orm::ActiveValue::Set(val.protocol),
|
||||
algorithm: sea_orm::ActiveValue::Set(val.algorithm),
|
||||
sticky_session: sea_orm::ActiveValue::Set(val.sticky_session),
|
||||
created_by: sea_orm::ActiveValue::Set(val.created_by),
|
||||
created_at: sea_orm::ActiveValue::Set(chrono::Utc::now()),
|
||||
updated_at: sea_orm::ActiveValue::Set(chrono::Utc::now()),
|
||||
};
|
||||
let upstream_targets = val
|
||||
.upstream_targets
|
||||
.into_iter()
|
||||
.map(|target| {
|
||||
let mut active_model: upstream_target::ActiveModel = target.into();
|
||||
active_model.upstream_id = sea_orm::ActiveValue::Set(upstream_uuid);
|
||||
active_model
|
||||
})
|
||||
.collect();
|
||||
(upstream, upstream_targets)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<upstream::Model> for UpstreamInfo {
|
||||
fn from(model: upstream::Model) -> Self {
|
||||
Self {
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
protocol: model.protocol,
|
||||
algorithm: model.algorithm,
|
||||
sticky_session: model.sticky_session,
|
||||
created_by: model.created_by,
|
||||
created_at: model.created_at,
|
||||
updated_at: model.updated_at,
|
||||
upstream_targets: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<(upstream::Model, Vec<upstream_target::Model>)> for UpstreamInfo {
|
||||
fn from(data: (upstream::Model, Vec<upstream_target::Model>)) -> Self {
|
||||
let (upstream_model, upstream_target_models) = data;
|
||||
|
||||
Self {
|
||||
id: upstream_model.id,
|
||||
name: upstream_model.name,
|
||||
protocol: upstream_model.protocol,
|
||||
algorithm: upstream_model.algorithm,
|
||||
sticky_session: upstream_model.sticky_session,
|
||||
created_by: upstream_model.created_by,
|
||||
created_at: upstream_model.created_at,
|
||||
updated_at: upstream_model.updated_at,
|
||||
upstream_targets: upstream_target_models
|
||||
.into_iter()
|
||||
.map(upstream_target_info::UpstreamTargetInfo::from)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UpstreamInfo> for (upstream::ActiveModel, Vec<upstream_target::ActiveModel>) {
|
||||
fn from(val: UpstreamInfo) -> Self {
|
||||
(
|
||||
upstream::ActiveModel {
|
||||
id: sea_orm::ActiveValue::Set(val.id),
|
||||
name: sea_orm::ActiveValue::Set(val.name),
|
||||
protocol: sea_orm::ActiveValue::Set(val.protocol),
|
||||
algorithm: sea_orm::ActiveValue::Set(val.algorithm),
|
||||
sticky_session: sea_orm::ActiveValue::Set(val.sticky_session),
|
||||
created_by: sea_orm::ActiveValue::Set(val.created_by),
|
||||
created_at: sea_orm::ActiveValue::Set(val.created_at),
|
||||
updated_at: sea_orm::ActiveValue::Set(val.updated_at),
|
||||
},
|
||||
val.upstream_targets
|
||||
.into_iter()
|
||||
.map(|target| target.into())
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl UpdateUpstreamInfo {
|
||||
pub fn apply_to_model(self, current_model: upstream::Model) -> upstream::ActiveModel {
|
||||
upstream::ActiveModel {
|
||||
id: sea_orm::ActiveValue::Unchanged(current_model.id),
|
||||
name: set_if_some!(self.name),
|
||||
protocol: set_if_some!(self.protocol),
|
||||
algorithm: set_if_some!(self.algorithm),
|
||||
sticky_session: set_if_some!(self.sticky_session),
|
||||
created_by: set_if_some!(if self.created_by.is_some() {
|
||||
Some(self.created_by)
|
||||
} else {
|
||||
None
|
||||
}),
|
||||
created_at: set_if_some!(self.created_at),
|
||||
updated_at: set_if_some!(self.updated_at),
|
||||
}
|
||||
}
|
||||
}
|
||||
154
apps/api/src/services/nginx/info/upstream_target.rs
Normal file
154
apps/api/src/services/nginx/info/upstream_target.rs
Normal file
@@ -0,0 +1,154 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use optfield::optfield;
|
||||
|
||||
use sea_orm::ActiveValue::{Set, Unchanged};
|
||||
use uuid::Uuid;
|
||||
|
||||
use database::generated::entities::{upstream, upstream_target};
|
||||
|
||||
use crate::{
|
||||
services::nginx::{builder::NginxConfigProvider, traits::indentable::Indentable},
|
||||
set_if_some,
|
||||
};
|
||||
|
||||
#[optfield(pub UpdateUpstreamTargetInfo)]
|
||||
#[derive(Clone)]
|
||||
pub struct UpstreamTargetInfo {
|
||||
pub id: uuid::Uuid,
|
||||
pub target_host: String,
|
||||
pub target_port: i64,
|
||||
pub weight: i64,
|
||||
pub is_backup: bool,
|
||||
pub enabled: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
//
|
||||
pub upstream_id: uuid::Uuid,
|
||||
pub upstream: Option<UpstreamBasicInfo>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct UpstreamBasicInfo {
|
||||
pub id: uuid::Uuid,
|
||||
pub name: String,
|
||||
pub protocol: String,
|
||||
//
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub struct UpstreamTargetCreateInfo {
|
||||
pub target_host: String,
|
||||
pub target_port: i64,
|
||||
pub weight: i64,
|
||||
pub is_backup: bool,
|
||||
pub enabled: bool,
|
||||
//
|
||||
pub upstream_id: uuid::Uuid,
|
||||
}
|
||||
|
||||
impl From<upstream_target::Model> for UpstreamTargetInfo {
|
||||
fn from(model: upstream_target::Model) -> Self {
|
||||
Self {
|
||||
id: model.id,
|
||||
target_host: model.target_host,
|
||||
target_port: model.target_port,
|
||||
weight: model.weight,
|
||||
is_backup: model.is_backup,
|
||||
enabled: model.enabled,
|
||||
created_at: model.created_at,
|
||||
updated_at: model.updated_at,
|
||||
upstream_id: model.upstream_id,
|
||||
upstream: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<(upstream_target::Model, upstream::Model)> for UpstreamTargetInfo {
|
||||
fn from(data: (upstream_target::Model, upstream::Model)) -> Self {
|
||||
let (target_model, up_model) = data;
|
||||
Self {
|
||||
id: target_model.id,
|
||||
target_host: target_model.target_host,
|
||||
target_port: target_model.target_port,
|
||||
weight: target_model.weight,
|
||||
is_backup: target_model.is_backup,
|
||||
enabled: target_model.enabled,
|
||||
created_at: target_model.created_at,
|
||||
updated_at: target_model.updated_at,
|
||||
upstream_id: target_model.upstream_id,
|
||||
upstream: Some(UpstreamBasicInfo {
|
||||
id: up_model.id,
|
||||
name: up_model.name,
|
||||
protocol: up_model.protocol,
|
||||
created_at: up_model.created_at,
|
||||
updated_at: up_model.updated_at,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UpstreamTargetInfo> for upstream_target::ActiveModel {
|
||||
fn from(val: UpstreamTargetInfo) -> Self {
|
||||
upstream_target::ActiveModel {
|
||||
id: Set(val.id),
|
||||
target_host: Set(val.target_host),
|
||||
target_port: Set(val.target_port),
|
||||
weight: Set(val.weight),
|
||||
is_backup: Set(val.is_backup),
|
||||
enabled: Set(val.enabled),
|
||||
created_at: Set(val.created_at),
|
||||
updated_at: Set(val.updated_at),
|
||||
upstream_id: Set(val.upstream_id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UpstreamTargetCreateInfo> for upstream_target::ActiveModel {
|
||||
fn from(val: UpstreamTargetCreateInfo) -> Self {
|
||||
upstream_target::ActiveModel {
|
||||
id: Set(Uuid::new_v4()),
|
||||
target_host: Set(val.target_host),
|
||||
target_port: Set(val.target_port),
|
||||
weight: Set(val.weight),
|
||||
is_backup: Set(val.is_backup),
|
||||
enabled: Set(val.enabled),
|
||||
created_at: Set(chrono::Utc::now()),
|
||||
updated_at: Set(chrono::Utc::now()),
|
||||
upstream_id: Set(val.upstream_id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NginxConfigProvider for UpstreamTargetInfo {
|
||||
fn to_nginx_config(&self, indent: Option<usize>) -> String {
|
||||
format!(
|
||||
"{}:{} weight={}{}{}",
|
||||
self.target_host,
|
||||
self.target_port,
|
||||
self.weight,
|
||||
if self.is_backup { " backup" } else { "" },
|
||||
if !self.enabled { " down" } else { "" },
|
||||
)
|
||||
.indent(indent.unwrap_or(0))
|
||||
}
|
||||
}
|
||||
|
||||
impl UpdateUpstreamTargetInfo {
|
||||
pub fn apply_to_model(
|
||||
self,
|
||||
current_model: upstream_target::Model,
|
||||
) -> upstream_target::ActiveModel {
|
||||
upstream_target::ActiveModel {
|
||||
id: Unchanged(current_model.id),
|
||||
target_host: set_if_some!(self.target_host),
|
||||
target_port: set_if_some!(self.target_port),
|
||||
weight: set_if_some!(self.weight),
|
||||
is_backup: set_if_some!(self.is_backup),
|
||||
enabled: set_if_some!(self.enabled),
|
||||
created_at: set_if_some!(self.created_at),
|
||||
updated_at: set_if_some!(self.updated_at),
|
||||
upstream_id: set_if_some!(self.upstream_id),
|
||||
}
|
||||
}
|
||||
}
|
||||
1
apps/api/src/services/nginx/traits.rs
Normal file
1
apps/api/src/services/nginx/traits.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod indentable;
|
||||
31
apps/api/src/services/nginx/traits/indentable.rs
Normal file
31
apps/api/src/services/nginx/traits/indentable.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
pub trait Indentable<T> {
|
||||
fn indent(&self, spaces: T) -> String;
|
||||
}
|
||||
|
||||
impl Indentable<usize> for &str {
|
||||
fn indent(&self, spaces: usize) -> String {
|
||||
let indent_str = " ".repeat(spaces);
|
||||
self.lines()
|
||||
.map(|line| format!("{}{}", indent_str, line))
|
||||
.collect::<Vec<String>>()
|
||||
.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
impl Indentable<Option<usize>> for String {
|
||||
fn indent(&self, spaces: Option<usize>) -> String {
|
||||
self.as_str().indent(spaces.unwrap_or(0))
|
||||
}
|
||||
}
|
||||
|
||||
impl Indentable<usize> for String {
|
||||
fn indent(&self, spaces: usize) -> String {
|
||||
self.as_str().indent(spaces)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indentable<Option<usize>> for &str {
|
||||
fn indent(&self, spaces: Option<usize>) -> String {
|
||||
self.indent(spaces.unwrap_or(0))
|
||||
}
|
||||
}
|
||||
697
apps/api/src/services/nginx/upstream.rs
Normal file
697
apps/api/src/services/nginx/upstream.rs
Normal file
@@ -0,0 +1,697 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, DatabaseConnection, DatabaseTransaction, EntityTrait,
|
||||
ModelTrait, QueryFilter, QuerySelect, TransactionTrait,
|
||||
};
|
||||
|
||||
use database::generated::entities::{upstream, upstream_target};
|
||||
|
||||
use crate::{
|
||||
errors::service_error::ServiceError,
|
||||
helpers::database::PaginationFilter,
|
||||
services::nginx::info::{
|
||||
upstream::{UpdateUpstreamInfo, UpstreamCreateInfo, UpstreamInfo},
|
||||
upstream_target::{UpdateUpstreamTargetInfo, UpstreamTargetCreateInfo, UpstreamTargetInfo},
|
||||
},
|
||||
with_conn,
|
||||
};
|
||||
|
||||
pub struct UpstreamService {
|
||||
connection: Arc<DatabaseConnection>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct GetUpstreamOptions {
|
||||
pub include_targets: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct GetUpstreamTargetOptions {
|
||||
pub include_upstream: bool,
|
||||
}
|
||||
|
||||
impl UpstreamService {
|
||||
pub fn new(connection: Arc<DatabaseConnection>) -> Self {
|
||||
Self { connection }
|
||||
}
|
||||
//
|
||||
//
|
||||
pub async fn create_upstream(
|
||||
&self,
|
||||
create_info: UpstreamCreateInfo,
|
||||
tx: Option<&mut DatabaseTransaction>,
|
||||
) -> Result<UpstreamInfo, ServiceError> {
|
||||
let (upstream_model, upstream_target_models): (
|
||||
upstream::ActiveModel,
|
||||
Vec<upstream_target::ActiveModel>,
|
||||
) = create_info.into();
|
||||
|
||||
// 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 r = with_conn!(&*self.connection, tx_ref, conn, {
|
||||
let created_upstream = upstream_model.insert(*conn).await?;
|
||||
let created_targets = upstream_target::Entity::insert_many(
|
||||
upstream_target_models
|
||||
.into_iter()
|
||||
.map(|mut model| {
|
||||
model.upstream_id = sea_orm::ActiveValue::Set(created_upstream.id);
|
||||
model
|
||||
})
|
||||
.collect::<Vec<upstream_target::ActiveModel>>(),
|
||||
)
|
||||
.exec_with_returning(*conn)
|
||||
.await?;
|
||||
(created_upstream, created_targets)
|
||||
});
|
||||
|
||||
// Commit only if we created the transaction here (we own it).
|
||||
if let Some(t) = maybe_owned_tx.take() {
|
||||
t.commit().await?;
|
||||
}
|
||||
Ok(r.into())
|
||||
}
|
||||
|
||||
pub async fn get_upstream(
|
||||
&self,
|
||||
upstream_id: uuid::Uuid,
|
||||
options: Option<GetUpstreamOptions>,
|
||||
tx: Option<&mut DatabaseTransaction>,
|
||||
) -> Result<UpstreamInfo, ServiceError> {
|
||||
let concrete_options = options.unwrap_or_default();
|
||||
let info: UpstreamInfo = if concrete_options.include_targets {
|
||||
let (up_model, targets) = with_conn!(&*self.connection, tx, conn, {
|
||||
let up = upstream::Entity::find_by_id(upstream_id)
|
||||
.one(*conn)
|
||||
.await?
|
||||
.ok_or(ServiceError::NotFound(format!(
|
||||
"Upstream with id {} not found",
|
||||
upstream_id
|
||||
)))?;
|
||||
let targets = upstream_target::Entity::find()
|
||||
.filter(upstream_target::Column::UpstreamId.eq(upstream_id))
|
||||
.all(*conn)
|
||||
.await?;
|
||||
(up, targets)
|
||||
});
|
||||
(up_model, targets).into()
|
||||
} else {
|
||||
with_conn!(&*self.connection, tx, conn, {
|
||||
upstream::Entity::find_by_id(upstream_id)
|
||||
.one(*conn)
|
||||
.await?
|
||||
.ok_or(ServiceError::NotFound(format!(
|
||||
"Upstream with id {} not found",
|
||||
upstream_id
|
||||
)))?
|
||||
})
|
||||
.into()
|
||||
};
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
pub async fn get_upstreams(
|
||||
&self,
|
||||
pagination: Option<PaginationFilter>,
|
||||
tx: Option<&mut DatabaseTransaction>,
|
||||
) -> Result<Vec<UpstreamInfo>, ServiceError> {
|
||||
let r = with_conn!(&*self.connection, tx, conn, {
|
||||
let find_query = upstream::Entity::find();
|
||||
let find_query = if let Some(pagination) = pagination {
|
||||
let (offset, limit) = pagination.get_offset_limit();
|
||||
find_query.offset(offset).limit(limit)
|
||||
} else {
|
||||
find_query
|
||||
};
|
||||
find_query.all(*conn).await?
|
||||
});
|
||||
|
||||
Ok(r.into_iter().map(|m| m.into()).collect())
|
||||
}
|
||||
|
||||
pub async fn update_upstream(
|
||||
&self,
|
||||
id: uuid::Uuid,
|
||||
upstream: UpdateUpstreamInfo,
|
||||
tx: Option<&mut DatabaseTransaction>,
|
||||
) -> Result<UpstreamInfo, ServiceError> {
|
||||
let current_model = with_conn!(&*self.connection, tx, conn, {
|
||||
upstream::Entity::find_by_id(id)
|
||||
.one(*conn)
|
||||
.await?
|
||||
.ok_or(ServiceError::NotFound(format!(
|
||||
"Upstream with id {} not found",
|
||||
id
|
||||
)))?
|
||||
});
|
||||
let active_model = upstream.apply_to_model(current_model);
|
||||
|
||||
let r = active_model.update(&*self.connection).await?;
|
||||
Ok(r.into())
|
||||
}
|
||||
|
||||
pub async fn delete_upstream(
|
||||
&self,
|
||||
upstream_id: uuid::Uuid,
|
||||
tx: Option<&mut DatabaseTransaction>,
|
||||
) -> Result<(), ServiceError> {
|
||||
let model = with_conn!(&*self.connection, tx, conn, {
|
||||
upstream::Entity::find_by_id(upstream_id)
|
||||
.one(*conn)
|
||||
.await?
|
||||
.ok_or(ServiceError::NotFound(format!(
|
||||
"Upstream with id {} not found",
|
||||
upstream_id
|
||||
)))?
|
||||
});
|
||||
with_conn!(&*self.connection, tx, conn, {
|
||||
model.delete(*conn).await?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
//
|
||||
//
|
||||
pub async fn create_upstream_target(
|
||||
&self,
|
||||
create_info: UpstreamTargetCreateInfo,
|
||||
tx: Option<&mut DatabaseTransaction>,
|
||||
) -> Result<UpstreamTargetInfo, ServiceError> {
|
||||
let model: upstream_target::ActiveModel = create_info.into();
|
||||
let r = with_conn!(&*self.connection, tx, conn, { model.insert(*conn).await? });
|
||||
Ok(r.into())
|
||||
}
|
||||
|
||||
pub async fn get_upstream_target(
|
||||
&self,
|
||||
target_id: uuid::Uuid,
|
||||
options: Option<GetUpstreamTargetOptions>,
|
||||
tx: Option<&mut DatabaseTransaction>,
|
||||
) -> Result<UpstreamTargetInfo, ServiceError> {
|
||||
let concrete_options = options.unwrap_or_default();
|
||||
let info: UpstreamTargetInfo = if concrete_options.include_upstream {
|
||||
match with_conn!(&*self.connection, tx, conn, {
|
||||
upstream_target::Entity::find_by_id(target_id)
|
||||
.find_also_related(upstream::Entity)
|
||||
.one(*conn)
|
||||
.await?
|
||||
}) {
|
||||
Some((target_model, Some(upstream_model))) => (target_model, upstream_model).into(),
|
||||
Some((_target_model, None)) => {
|
||||
return Err(ServiceError::InternalError(format!(
|
||||
"Inconsistent data: Upstream target with id {} has no associated upstream",
|
||||
target_id
|
||||
)));
|
||||
}
|
||||
None => {
|
||||
return Err(ServiceError::NotFound(format!(
|
||||
"Upstream target with id {} not found",
|
||||
target_id
|
||||
)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
with_conn!(&*self.connection, tx, conn, {
|
||||
upstream_target::Entity::find_by_id(target_id)
|
||||
.one(*conn)
|
||||
.await?
|
||||
.ok_or(ServiceError::NotFound(format!(
|
||||
"Upstream target with id {} not found",
|
||||
target_id
|
||||
)))?
|
||||
})
|
||||
.into()
|
||||
};
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
pub async fn get_upstream_targets_by_upstream(
|
||||
&self,
|
||||
upstream_id: uuid::Uuid,
|
||||
tx: Option<&mut DatabaseTransaction>,
|
||||
) -> Result<Vec<UpstreamTargetInfo>, ServiceError> {
|
||||
let r = with_conn!(&*self.connection, tx, conn, {
|
||||
upstream_target::Entity::find()
|
||||
.filter(upstream_target::Column::UpstreamId.eq(upstream_id))
|
||||
.all(*conn)
|
||||
.await?
|
||||
});
|
||||
Ok(r.into_iter().map(|m| m.into()).collect())
|
||||
}
|
||||
|
||||
pub async fn update_upstream_target(
|
||||
&self,
|
||||
id: uuid::Uuid,
|
||||
target: UpdateUpstreamTargetInfo,
|
||||
tx: Option<&mut DatabaseTransaction>,
|
||||
) -> Result<UpstreamTargetInfo, ServiceError> {
|
||||
let current_model = with_conn!(&*self.connection, tx, conn, {
|
||||
upstream_target::Entity::find_by_id(id)
|
||||
.one(*conn)
|
||||
.await?
|
||||
.ok_or(ServiceError::NotFound(format!(
|
||||
"Upstream target with id {} not found",
|
||||
id
|
||||
)))?
|
||||
});
|
||||
let active_model = target.apply_to_model(current_model);
|
||||
|
||||
let r = active_model.update(&*self.connection).await?;
|
||||
Ok(r.into())
|
||||
}
|
||||
|
||||
pub async fn delete_upstream_target(
|
||||
&self,
|
||||
target_id: uuid::Uuid,
|
||||
tx: Option<&mut DatabaseTransaction>,
|
||||
) -> Result<(), ServiceError> {
|
||||
let model = with_conn!(&*self.connection, tx, conn, {
|
||||
upstream_target::Entity::find_by_id(target_id)
|
||||
.one(*conn)
|
||||
.await?
|
||||
.ok_or(ServiceError::NotFound(format!(
|
||||
"Upstream target with id {} not found",
|
||||
target_id
|
||||
)))?
|
||||
});
|
||||
with_conn!(&*self.connection, tx, conn, {
|
||||
model.delete(*conn).await?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
use sea_orm::MockExecResult;
|
||||
use sea_orm::{DatabaseBackend, MockDatabase};
|
||||
|
||||
use database::generated::entities::{upstream, upstream_target};
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_upstream_returns_info() {
|
||||
let up_model = upstream::Model {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
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)
|
||||
.append_query_results(vec![vec![up_model.clone()]])
|
||||
.into_connection();
|
||||
|
||||
let svc = UpstreamService::new(Arc::new(db));
|
||||
|
||||
let create_info = crate::services::nginx::info::upstream::UpstreamCreateInfo {
|
||||
name: "test_upstream".to_string(),
|
||||
protocol: "http".to_string(),
|
||||
algorithm: "round_robin".to_string(),
|
||||
sticky_session: false,
|
||||
created_by: None,
|
||||
upstream_targets: Vec::new(),
|
||||
};
|
||||
|
||||
let res = svc.create_upstream(create_info, None).await;
|
||||
assert!(res.is_ok());
|
||||
let info = res.expect("Failed to create upstream");
|
||||
assert_eq!(info.name, "test_upstream");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_upstream_with_targets_returns_targets() {
|
||||
let up_id = uuid::Uuid::new_v4();
|
||||
|
||||
let up_model = upstream::Model {
|
||||
id: up_id,
|
||||
name: "with_targets".to_string(),
|
||||
protocol: "http".to_string(),
|
||||
algorithm: "least_conn".to_string(),
|
||||
sticky_session: true,
|
||||
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)
|
||||
// find_by_id -> returns upstream model
|
||||
.append_query_results(vec![vec![up_model.clone()]])
|
||||
// find targets -> returns the target(s)
|
||||
.append_query_results(vec![vec![target_model.clone()]])
|
||||
.into_connection();
|
||||
|
||||
let svc = UpstreamService::new(Arc::new(db));
|
||||
|
||||
let res = svc
|
||||
.get_upstream(
|
||||
up_id,
|
||||
Some(GetUpstreamOptions {
|
||||
include_targets: true,
|
||||
}),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(res.is_ok());
|
||||
let info = res.expect("Failed to get upstream with targets");
|
||||
assert_eq!(info.id, up_id);
|
||||
assert_eq!(info.upstream_targets.len(), 1);
|
||||
assert_eq!(info.upstream_targets[0].target_host, "127.0.0.1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_upstream_not_found_returns_not_found() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![Vec::<sea_orm::MockRow>::new()])
|
||||
.into_connection();
|
||||
|
||||
let svc = UpstreamService::new(Arc::new(db));
|
||||
|
||||
let res = svc.get_upstream(uuid::Uuid::new_v4(), None, None).await;
|
||||
|
||||
assert!(matches!(res, Err(ServiceError::NotFound(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_upstreams_returns_list() {
|
||||
let u1 = upstream::Model {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
name: "u1".to_string(),
|
||||
protocol: "http".to_string(),
|
||||
algorithm: "rr".to_string(),
|
||||
sticky_session: false,
|
||||
created_by: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
let u2 = upstream::Model {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
name: "u2".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 db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![vec![u1.clone(), u2.clone()]])
|
||||
.into_connection();
|
||||
|
||||
let svc = UpstreamService::new(Arc::new(db));
|
||||
|
||||
let res = svc.get_upstreams(None, None).await;
|
||||
assert!(res.is_ok());
|
||||
let list = res.expect("Failed to get upstreams");
|
||||
assert_eq!(list.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_upstream_targets_by_upstream_returns_targets() {
|
||||
let up_id = uuid::Uuid::new_v4();
|
||||
|
||||
let t = upstream_target::Model {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
upstream_id: up_id,
|
||||
target_host: "10.0.0.1".to_string(),
|
||||
target_port: 80,
|
||||
weight: 10,
|
||||
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![t.clone()]])
|
||||
.into_connection();
|
||||
|
||||
let svc = UpstreamService::new(Arc::new(db));
|
||||
|
||||
let res = svc.get_upstream_targets_by_upstream(up_id, None).await;
|
||||
assert!(res.is_ok());
|
||||
let targets = res.expect("Failed to get upstream targets");
|
||||
assert_eq!(targets.len(), 1);
|
||||
assert_eq!(targets[0].target_host, "10.0.0.1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_upstream_success() {
|
||||
let id = uuid::Uuid::new_v4();
|
||||
let existing = upstream::Model {
|
||||
id,
|
||||
name: "old".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 updated = upstream::Model {
|
||||
id,
|
||||
name: "new".to_string(),
|
||||
protocol: "http".to_string(),
|
||||
algorithm: "rr".to_string(),
|
||||
sticky_session: false,
|
||||
created_by: None,
|
||||
created_at: existing.created_at,
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![vec![existing.clone()]]) // find_by_id
|
||||
.append_query_results(vec![vec![updated.clone()]]) // update result
|
||||
.into_connection();
|
||||
|
||||
let svc = UpstreamService::new(Arc::new(db));
|
||||
|
||||
let update_info = crate::services::nginx::info::upstream::UpdateUpstreamInfo {
|
||||
id: None,
|
||||
name: None,
|
||||
protocol: None,
|
||||
algorithm: None,
|
||||
sticky_session: None,
|
||||
created_by: None,
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
upstream_targets: None,
|
||||
};
|
||||
let res = svc.update_upstream(id, update_info, None).await;
|
||||
assert!(res.is_ok());
|
||||
let got = res.expect("Failed to update upstream");
|
||||
assert_eq!(got.name, "new");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_upstream_not_found() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![Vec::<sea_orm::MockRow>::new()])
|
||||
.into_connection();
|
||||
|
||||
let svc = UpstreamService::new(Arc::new(db));
|
||||
|
||||
let res = svc
|
||||
.update_upstream(
|
||||
uuid::Uuid::new_v4(),
|
||||
crate::services::nginx::info::upstream::UpdateUpstreamInfo {
|
||||
id: None,
|
||||
name: None,
|
||||
protocol: None,
|
||||
algorithm: None,
|
||||
sticky_session: None,
|
||||
created_by: None,
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
upstream_targets: None,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(matches!(res, Err(ServiceError::NotFound(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_upstream_success() {
|
||||
let id = uuid::Uuid::new_v4();
|
||||
let existing = upstream::Model {
|
||||
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 db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![vec![existing.clone()]])
|
||||
.append_exec_results(vec![MockExecResult {
|
||||
rows_affected: 1,
|
||||
last_insert_id: 0,
|
||||
}])
|
||||
.into_connection();
|
||||
|
||||
let svc = UpstreamService::new(Arc::new(db));
|
||||
|
||||
let res = svc.delete_upstream(id, None).await;
|
||||
assert!(res.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_upstream_not_found() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![Vec::<sea_orm::MockRow>::new()])
|
||||
.into_connection();
|
||||
|
||||
let svc = UpstreamService::new(Arc::new(db));
|
||||
|
||||
let res = svc.delete_upstream(uuid::Uuid::new_v4(), None).await;
|
||||
assert!(matches!(res, Err(ServiceError::NotFound(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_upstream_target_success() {
|
||||
let id = uuid::Uuid::new_v4();
|
||||
let upstream_id = uuid::Uuid::new_v4();
|
||||
let created = upstream_target::Model {
|
||||
id,
|
||||
upstream_id,
|
||||
target_host: "1.2.3.4".to_string(),
|
||||
target_port: 8080,
|
||||
weight: 5,
|
||||
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![created.clone()]])
|
||||
.into_connection();
|
||||
|
||||
let svc = UpstreamService::new(Arc::new(db));
|
||||
|
||||
let create_info = crate::services::nginx::info::upstream_target::UpstreamTargetCreateInfo {
|
||||
target_host: "1.2.3.4".to_string(),
|
||||
target_port: 8080,
|
||||
weight: 5,
|
||||
is_backup: false,
|
||||
enabled: true,
|
||||
upstream_id,
|
||||
};
|
||||
|
||||
let res = svc.create_upstream_target(create_info, None).await;
|
||||
assert!(res.is_ok());
|
||||
let t = res.expect("Failed to create target");
|
||||
assert_eq!(t.target_host, "1.2.3.4");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_upstream_target_success() {
|
||||
let id = uuid::Uuid::new_v4();
|
||||
let existing = upstream_target::Model {
|
||||
id,
|
||||
upstream_id: uuid::Uuid::new_v4(),
|
||||
target_host: "old".to_string(),
|
||||
target_port: 80,
|
||||
weight: 1,
|
||||
is_backup: false,
|
||||
enabled: true,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
let updated = upstream_target::Model {
|
||||
id,
|
||||
upstream_id: existing.upstream_id,
|
||||
target_host: "new".to_string(),
|
||||
target_port: 80,
|
||||
weight: 1,
|
||||
is_backup: false,
|
||||
enabled: true,
|
||||
created_at: existing.created_at,
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![vec![existing.clone()]])
|
||||
.append_query_results(vec![vec![updated.clone()]])
|
||||
.into_connection();
|
||||
|
||||
let svc = UpstreamService::new(Arc::new(db));
|
||||
|
||||
let update_info = crate::services::nginx::info::upstream_target::UpdateUpstreamTargetInfo {
|
||||
id: None,
|
||||
target_host: None,
|
||||
target_port: None,
|
||||
weight: None,
|
||||
is_backup: 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;
|
||||
assert!(res.is_ok());
|
||||
let got = res.expect("Failed to update target");
|
||||
assert_eq!(got.target_host, "new");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_upstream_target_success() {
|
||||
let id = uuid::Uuid::new_v4();
|
||||
let existing = upstream_target::Model {
|
||||
id,
|
||||
upstream_id: uuid::Uuid::new_v4(),
|
||||
target_host: "del".to_string(),
|
||||
target_port: 80,
|
||||
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,
|
||||
}])
|
||||
.into_connection();
|
||||
|
||||
let svc = UpstreamService::new(Arc::new(db));
|
||||
let res = svc.delete_upstream_target(id, None).await;
|
||||
assert!(res.is_ok());
|
||||
}
|
||||
}
|
||||
30
public/database/src/generated/entities/edit_operation.rs
Normal file
30
public/database/src/generated/entities/edit_operation.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
//! `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 {}
|
||||
23
public/database/src/generated/entities/editing_session.rs
Normal file
23
public/database/src/generated/entities/editing_session.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
//! `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 {}
|
||||
@@ -3,5 +3,9 @@
|
||||
pub mod prelude;
|
||||
|
||||
pub mod config;
|
||||
pub mod edit_operation;
|
||||
pub mod editing_session;
|
||||
pub mod upstream;
|
||||
pub mod upstream_target;
|
||||
pub mod user;
|
||||
pub mod user_identity;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0.0-rc.18
|
||||
|
||||
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_target::Entity as UpstreamTarget;
|
||||
pub use super::user::Entity as User;
|
||||
pub use super::user_identity::Entity as UserIdentity;
|
||||
|
||||
23
public/database/src/generated/entities/upstream.rs
Normal file
23
public/database/src/generated/entities/upstream.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
//! `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 = "upstream")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub protocol: String,
|
||||
pub algorithm: String,
|
||||
pub sticky_session: bool,
|
||||
pub created_by: Option<Uuid>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
#[sea_orm(has_many)]
|
||||
pub upstream_targets: HasMany<super::upstream_target::Entity>,
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -5,25 +5,26 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
#[sea_orm::model]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "session")]
|
||||
#[sea_orm(table_name = "upstream_target")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
#[sea_orm(unique)]
|
||||
pub refresh_token_hash: Option<String>,
|
||||
pub expires_at: DateTimeUtc,
|
||||
pub revoked_at: Option<DateTimeUtc>,
|
||||
pub upstream_id: Uuid,
|
||||
pub target_host: String,
|
||||
pub target_port: i64,
|
||||
pub weight: i64,
|
||||
pub is_backup: bool,
|
||||
pub enabled: bool,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
#[sea_orm(
|
||||
belongs_to,
|
||||
from = "user_id",
|
||||
from = "upstream_id",
|
||||
to = "id",
|
||||
on_update = "Cascade",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
pub user: HasOne<super::user::Entity>,
|
||||
pub upstream: HasOne<super::upstream::Entity>,
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
208
public/migration/doc/nginx-tables.md
Normal file
208
public/migration/doc/nginx-tables.md
Normal file
@@ -0,0 +1,208 @@
|
||||
# Migration Tables → nginx mapping
|
||||
|
||||
This document explains the purpose of each migration table added under `public/migration/src/migrations` and how the rows map to generated nginx configuration (HTTP `http {}` and `stream {}` contexts).
|
||||
|
||||
Summary of tables covered:
|
||||
|
||||
- `upstream`
|
||||
- `upstream_target`
|
||||
- `proxy_host`
|
||||
- `location`
|
||||
- `stream_service`
|
||||
- `access_list`
|
||||
- `access_list_entry`
|
||||
- `audit_log`
|
||||
|
||||
---
|
||||
|
||||
## `upstream`
|
||||
|
||||
Purpose: A named backend pool of servers. Shared by HTTP and stream services.
|
||||
|
||||
Key fields:
|
||||
|
||||
- `id`: UUID primary key
|
||||
- `name`: identifier used when generating nginx `upstream <name> {}`
|
||||
- `protocol`: `http` | `tcp` | `udp` — determines how nginx will use the pool
|
||||
- `algorithm`: load balancing strategy (`round_robin`, `least_conn`, `ip_hash`)
|
||||
- `sticky_session`: whether to enable sticky behavior when supported
|
||||
- `health_check`: optional JSON describing health probes
|
||||
|
||||
nginx mapping (HTTP):
|
||||
|
||||
```nginx
|
||||
upstream <name> {
|
||||
server 10.0.0.5:8080 weight=2;
|
||||
server 10.0.0.6:8080 backup;
|
||||
# optional LB settings generated from `algorithm` and `sticky_session`
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name example.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://<name>;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
nginx mapping (stream):
|
||||
|
||||
```nginx
|
||||
stream {
|
||||
upstream <name> {
|
||||
server 10.0.0.5:3306;
|
||||
server 10.0.0.6:3306 backup;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 3306;
|
||||
proxy_pass <name>;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes: `upstream.protocol` selects which block and directive forms to generate;
|
||||
|
||||
---
|
||||
|
||||
## `upstream_target`
|
||||
|
||||
Purpose: One row per backend server in an `upstream` pool.
|
||||
|
||||
Key fields:
|
||||
|
||||
- `upstream_id`: FK to `upstream`
|
||||
- `target_host`, `target_port`
|
||||
- `weight`, `is_backup`, `enabled`
|
||||
|
||||
nginx mapping: each row becomes a `server` line in the generated `upstream` block (weights and backup flags applied). Disabled targets are omitted.
|
||||
|
||||
Example generated line:
|
||||
|
||||
```nginx
|
||||
server 10.0.0.5:8080 weight=3;
|
||||
server 10.0.0.6:8080 backup;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `proxy_host`
|
||||
|
||||
Purpose: Represents an HTTP(S) host (a top-level `server` block in nginx `http` context).
|
||||
|
||||
Key fields:
|
||||
|
||||
- `domain`: `server_name` value (may be a wildcard)
|
||||
- `listen_port`: port to listen on (80/443)
|
||||
- `scheme`: http|https (informs UI; TLS handled elsewhere)
|
||||
- `forward_host/forward_port` or `default_upstream_id`: host-level forwarding fallback
|
||||
- `preserve_host_header`: whether to forward original `Host` header
|
||||
- `enable_websocket`: toggles websocket header handling
|
||||
- `meta`: JSON for optional host-level settings (timeouts, client_max_body_size, custom snippets)
|
||||
|
||||
nginx mapping (host-level default):
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen <listen_port>;
|
||||
server_name <domain>;
|
||||
|
||||
# host-level fallback if no matching location
|
||||
location / {
|
||||
proxy_pass http://<default_upstream_name>;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If `forward_host`/`forward_port` is set instead of `default_upstream_id`, generate `proxy_pass http://forward_host:forward_port;`.
|
||||
|
||||
`meta` entries are injected into the `server` block (careful: snippets can break reloads).
|
||||
|
||||
---
|
||||
|
||||
## `location`
|
||||
|
||||
Purpose: Path-level routing (`location` blocks inside a `server`). More specific than `proxy_host` default.
|
||||
|
||||
Key fields:
|
||||
|
||||
- `host_id`: FK to `proxy_host`
|
||||
- `path`: `location` match (e.g., `/api`, `~^/assets/`)
|
||||
- `match_type`: `prefix` | `exact` | `regex`
|
||||
- `upstream_id` or `proxy_pass_host`/`proxy_pass_port`
|
||||
- `allowed_methods`: optional method whitelist
|
||||
- `custom_config`: raw nginx snippet inserted inside the `location`
|
||||
|
||||
nginx mapping:
|
||||
|
||||
```nginx
|
||||
location /api {
|
||||
proxy_pass http://api_upstream;
|
||||
# optional custom_config injected here
|
||||
}
|
||||
```
|
||||
|
||||
Ordering and match type produce correct nginx `location` selection semantics; `order` field can break ties for equal specificity.
|
||||
|
||||
---
|
||||
|
||||
## `stream_service`
|
||||
|
||||
Purpose: A TCP/UDP service in nginx `stream` context — corresponds to a `server` block inside `stream {}`.
|
||||
|
||||
Key fields:
|
||||
|
||||
- `listen_host`, `listen_port`
|
||||
- `protocol`: `tcp` | `udp`
|
||||
- `mode`: `direct` | `upstream` (direct forwards to `forward_host:forward_port`, `upstream` uses `upstream_id` pool)
|
||||
- `preserved_client_ip`: whether to enable proxy_protocol or other client-ip forwarding
|
||||
- `meta`: JSON for advanced stream options (ssl_preread, proxy_timeout, buffer sizes)
|
||||
|
||||
nginx mapping (stream):
|
||||
|
||||
```nginx
|
||||
stream {
|
||||
upstream <name> {
|
||||
server 10.0.0.5:3306;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 3306;
|
||||
proxy_pass <name>; # or proxy_pass 10.0.0.10:3306 for direct
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes: Stream services bypass HTTP processing. Use `meta` for `proxy_protocol` and `ssl_preread` toggles.
|
||||
|
||||
---
|
||||
|
||||
## `access_list` and `access_list_entry`
|
||||
|
||||
Purpose: Nameable allow/deny lists for IP/CIDR or other entry types that can be applied to hosts/locations/stream services.
|
||||
|
||||
Key fields (access_list): `id`, `name`, `description`.
|
||||
Key fields (entry): `access_list_id`, `entry_type` (e.g., `allow`, `deny`, `note`), `value` (IP or CIDR), `comment`.
|
||||
|
||||
nginx mapping (HTTP example):
|
||||
|
||||
```nginx
|
||||
location /admin {
|
||||
allow 10.0.0.0/24;
|
||||
deny all;
|
||||
proxy_pass http://admin_upstream;
|
||||
}
|
||||
```
|
||||
|
||||
nginx mapping (stream example):
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 3306;
|
||||
allow 10.0.0.0/24;
|
||||
deny all;
|
||||
proxy_pass backend_pool;
|
||||
}
|
||||
```
|
||||
@@ -13,6 +13,9 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20251011_000001_create_config_table::Migration),
|
||||
Box::new(m20251011_000002_create_user_table::Migration),
|
||||
Box::new(m20251011_000003_create_user_identity_table::Migration),
|
||||
Box::new(m20251223_000004_create_upstream_table::Migration),
|
||||
Box::new(m20251223_000005_create_upstream_target_table::Migration),
|
||||
Box::new(m20251230_000006_create_editing_session_table::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
pub mod m20251011_000001_create_config_table;
|
||||
pub mod m20251011_000002_create_user_table;
|
||||
pub mod m20251011_000003_create_user_identity_table;
|
||||
pub mod m20251223_000004_create_upstream_table;
|
||||
pub mod m20251223_000005_create_upstream_target_table;
|
||||
pub mod m20251230_000006_create_editing_session_table;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
use sea_orm_migration::{prelude::*, schema::*};
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[forbid(dead_code)]
|
||||
#[derive(DeriveIden)]
|
||||
pub enum Upstream {
|
||||
Table,
|
||||
Id,
|
||||
Name,
|
||||
Protocol,
|
||||
Algorithm,
|
||||
StickySession,
|
||||
CreatedBy,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Upstream::Table)
|
||||
.if_not_exists()
|
||||
.col(pk_uuid(Upstream::Id))
|
||||
.col(ColumnDef::new(Upstream::Name).string().not_null())
|
||||
.col(ColumnDef::new(Upstream::Protocol).string().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Upstream::Algorithm)
|
||||
.string()
|
||||
.default("round_robin")
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Upstream::StickySession)
|
||||
.boolean()
|
||||
.default(false)
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(Upstream::CreatedBy).uuid().null())
|
||||
.col(
|
||||
ColumnDef::new(Upstream::CreatedAt)
|
||||
.timestamp()
|
||||
.default(SimpleExpr::Keyword(Keyword::CurrentTimestamp))
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Upstream::UpdatedAt)
|
||||
.timestamp()
|
||||
.default(SimpleExpr::Keyword(Keyword::CurrentTimestamp))
|
||||
.not_null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(Upstream::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
use sea_orm_migration::{prelude::*, schema::*};
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[forbid(dead_code)]
|
||||
#[derive(DeriveIden)]
|
||||
pub enum UpstreamTarget {
|
||||
Table,
|
||||
Id,
|
||||
UpstreamId,
|
||||
TargetHost,
|
||||
TargetPort,
|
||||
Weight,
|
||||
IsBackup,
|
||||
Enabled,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(UpstreamTarget::Table)
|
||||
.if_not_exists()
|
||||
.col(pk_uuid(UpstreamTarget::Id))
|
||||
.col(ColumnDef::new(UpstreamTarget::UpstreamId).uuid().not_null())
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk-upstream-target-upstream-id")
|
||||
.from(UpstreamTarget::Table, UpstreamTarget::UpstreamId)
|
||||
.to(
|
||||
super::m20251223_000004_create_upstream_table::Upstream::Table,
|
||||
super::m20251223_000004_create_upstream_table::Upstream::Id,
|
||||
)
|
||||
.on_delete(ForeignKeyAction::Cascade)
|
||||
.on_update(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(UpstreamTarget::TargetHost)
|
||||
.string()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(UpstreamTarget::TargetPort)
|
||||
.integer()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(UpstreamTarget::Weight)
|
||||
.integer()
|
||||
.default(1)
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(UpstreamTarget::IsBackup)
|
||||
.boolean()
|
||||
.default(false)
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(UpstreamTarget::Enabled)
|
||||
.boolean()
|
||||
.default(true)
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(UpstreamTarget::CreatedAt)
|
||||
.timestamp()
|
||||
.default(SimpleExpr::Keyword(Keyword::CurrentTimestamp))
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(UpstreamTarget::UpdatedAt)
|
||||
.timestamp()
|
||||
.default(SimpleExpr::Keyword(Keyword::CurrentTimestamp))
|
||||
.not_null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(UpstreamTarget::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user