37 Commits

Author SHA1 Message Date
GW_MC
b43f9fcb00 Refactor database schema: Remove access list and related entities, add editing session and edit operation entities
- Deleted `access_list_entry`, `location`, `proxy_host`, `proxy_host_access_list`, `session`, `stream_service`, `stream_service_access_list` entities and their corresponding migration files.
- Introduced `editing_session` and `edit_operation` entities with appropriate fields and relationships.
- Updated `mod.rs` and `prelude.rs` to reflect the changes in the entity structure.
- Adjusted migration files to remove obsolete migrations and include new migrations for the editing session and edit operation tables.
2025-12-30 15:09:33 +08:00
GW_MC
1b251fd89f feat: add add_upstream_target handler and response struct for upstream target management 2025-12-29 21:34:40 +08:00
GW_MC
dd79cbe0bb feat: add create_upstream handler for upstream creation 2025-12-29 20:12:01 +08:00
GW_MC
37f4163870 added mock for require_auth middleware 2025-12-29 19:30:31 +08:00
GW_MC
481abd7f26 fix: AuthenticatedRequestInfo support any state 2025-12-29 18:58:00 +08:00
GW_MC
7a557d6e00 feat: update UpstreamCreateInfo conversion to include upstream targets 2025-12-29 18:45:03 +08:00
GW_MC
0cbc223b4e feat: implement AuthenticatedRequestInfo for user authentication handling 2025-12-29 18:44:52 +08:00
GW_MC
fdfd1c98e0 feat: introduce ApiError for improved error handling in upstream routes 2025-12-29 18:03:09 +08:00
GW_MC
abeea4fad7 refactor: upstream info response structures and module 2025-12-29 18:01:57 +08:00
GW_MC
fea205cba8 chore: delegate path prefix to router 2025-12-29 17:52:29 +08:00
GW_MC
3cc6b40e61 Fix: missing upstream in From 2025-12-29 17:48:44 +08:00
GW_MC
41023939db feat: add tests for upstream and upstream target handlers 2025-12-29 17:44:41 +08:00
GW_MC
6eb0d9060b fix: path not starting with / 2025-12-29 17:24:50 +08:00
GW_MC
7d83838da3 feat: add get_upstream_target endpoint and update router for upstream targets 2025-12-29 16:38:43 +08:00
GW_MC
921165476c feat: enhance UpstreamTargetInfo with upstream details and refactor imports 2025-12-29 16:38:33 +08:00
GW_MC
a0621e2407 test: added test cases for upstream service 2025-12-29 16:37:57 +08:00
GW_MC
f8b13be650 feat: add Nginx upstream read management routes and handlers 2025-12-29 15:27:03 +08:00
GW_MC
238c3db92b feat: implement Nginx service with upstream management and configuration generation 2025-12-29 15:21:02 +08:00
GW_MC
814f76291c fix: update test cases to use mock 2025-12-29 15:19:06 +08:00
GW_MC
7ac3368715 feat: added intoResponse 2025-12-29 15:18:40 +08:00
GW_MC
35fadb46f6 feat: add pagination helper and integrate serde_urlencoded for query extraction 2025-12-29 12:16:09 +08:00
GW_MC
91d0e1cd7c init nginx related database schema 2025-12-29 12:05:22 +08:00
c33e3aa0ca Merge pull request 'feature/agent-client' (#12) from feature/agent-client into master
All checks were successful
Test / lint-frontend (push) Successful in 26s
Test / test-frontend (push) Successful in 25s
Test / frontend-build (push) Successful in 32s
Verify / verify-generated-database-code (push) Successful in 1m8s
Verify / verify-generated-agent-code (push) Successful in 1m11s
Verify / verify-openapi-spec (push) Successful in 1m7s
Verify / verify-frontend-api-client (push) Successful in 8s
Test / test-crates (push) Successful in 52s
Test / lint-crates (push) Successful in 1m8s
Reviewed-on: #12
2025-12-28 19:25:34 +08:00
GW_MC
6f5596dc69 Enforce deny unwrap_used
All checks were successful
Test / test-frontend (pull_request) Successful in 26s
Test / lint-frontend (pull_request) Successful in 28s
Test / frontend-build (pull_request) Successful in 32s
Verify / verify-generated-database-code (pull_request) Successful in 1m7s
Verify / verify-generated-agent-code (pull_request) Successful in 1m10s
Verify / verify-openapi-spec (pull_request) Successful in 1m9s
Verify / verify-frontend-api-client (pull_request) Successful in 7s
Test / test-crates (pull_request) Successful in 54s
Test / lint-crates (pull_request) Successful in 1m10s
2025-12-28 19:07:17 +08:00
GW_MC
96e7f36731 feat: integrate AgentService into app service and enhance configuration handling
All checks were successful
Test / test-frontend (pull_request) Successful in 25s
Test / lint-frontend (pull_request) Successful in 28s
Test / frontend-build (pull_request) Successful in 32s
Verify / verify-generated-database-code (pull_request) Successful in 1m7s
Verify / verify-generated-agent-code (pull_request) Successful in 1m11s
Verify / verify-openapi-spec (pull_request) Successful in 1m13s
Verify / verify-frontend-api-client (pull_request) Successful in 8s
Test / test-crates (pull_request) Successful in 55s
Test / lint-crates (pull_request) Successful in 1m9s
2025-12-28 18:35:53 +08:00
GW_MC
410328a2af refactor app service 2025-12-28 18:28:28 +08:00
GW_MC
9f122566d0 feat: add agent settings configuration and update agent client service 2025-12-28 18:08:55 +08:00
GW_MC
c65dc3af47 feat: Basic agent-client service
Some checks failed
Test / test-frontend (pull_request) Successful in 27s
Test / frontend-build (pull_request) Successful in 33s
Verify / verify-openapi-spec (pull_request) Successful in 7s
Verify / verify-generated-agent-code (pull_request) Successful in 1m7s
Verify / verify-generated-database-code (pull_request) Successful in 2m14s
Verify / verify-frontend-api-client (pull_request) Successful in 6s
Test / lint-crates (pull_request) Failing after 1m28s
Test / test-crates (pull_request) Successful in 2m40s
Test / lint-frontend (pull_request) Successful in 29s
2025-12-28 17:53:27 +08:00
GW_MC
6f395ed1ae rename workflows jobs 2025-12-28 16:42:12 +08:00
GW_MC
e6e85564e7 fix: incorrect pnpm cache
All checks were successful
Test / test-frontend (pull_request) Successful in 1m17s
Test / lint-frontend (pull_request) Successful in 1m19s
Test / frontend-build (pull_request) Successful in 1m55s
Verify / verify-openapi-spec (pull_request) Successful in 31s
Verify / verify-generated-agent-code (pull_request) Successful in 2m54s
Verify / verify-generated-code (pull_request) Successful in 3m57s
Verify / verify-frontend-api-client (pull_request) Successful in 8s
Test / lint (pull_request) Successful in 1m25s
Test / test (pull_request) Successful in 3m26s
2025-12-28 16:29:31 +08:00
GW_MC
c7a090f78a fix: frontend deps not installed 2025-12-28 16:23:10 +08:00
GW_MC
8cc2775fe4 feat: auto format generated code, and ignore clippy lint in agent-client 2025-12-28 16:22:41 +08:00
GW_MC
0b64538754 added verify for untracked generated files 2025-12-28 15:56:04 +08:00
GW_MC
8009ca20ff feat: added verify generated agent code in action workflows 2025-12-28 15:55:30 +08:00
GW_MC
7a5b9471e0 feat: added agent rust client generation 2025-12-28 15:16:47 +08:00
GW_MC
bb55e37b49 feat: added openapi generation for agent 2025-12-28 15:15:42 +08:00
9a264a61ac Merge pull request 'feature/agent' (#11) from feature/agent into master
All checks were successful
Test / test-frontend (push) Successful in 20s
Test / lint-frontend (push) Successful in 24s
Verify / verify-openapi-spec (push) Successful in 4s
Test / frontend-build (push) Successful in 29s
Verify / verify-frontend-api-client (push) Successful in 5s
Test / test (push) Successful in 53s
Verify / verify-generated-code (push) Successful in 1m6s
Test / lint (push) Successful in 1m10s
Reviewed-on: #11
2025-12-22 18:29:26 +08:00
94 changed files with 7972 additions and 1479 deletions

View File

@@ -12,11 +12,8 @@ on:
jobs: jobs:
# setup is now handled by a composite action used by downstream jobs to keep
# the workflow DRY. The composite action performs checkout, cache restore and
# toolchain setup.
test: test-crates:
needs: frontend-build needs: frontend-build
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -39,7 +36,7 @@ jobs:
- name: Run tests - name: Run tests
run: cargo test --all-features run: cargo test --all-features
lint: lint-crates:
needs: frontend-build needs: frontend-build
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -83,13 +80,13 @@ jobs:
with: with:
node-version: 22 node-version: 22
cache: 'pnpm' cache: 'pnpm'
cache-dependency-path: apps/frontend/pnpm-lock.yaml cache-dependency-path: pnpm-lock.yaml
- name: Install frontend dependencies - name: Install frontend dependencies
run: | run: |
cd apps/frontend cd apps/frontend
pnpm install pnpm install
- name: Run frontend linter - name: Run frontend linter
run: | run: |
cd apps/frontend cd apps/frontend
@@ -114,7 +111,7 @@ jobs:
with: with:
node-version: 22 node-version: 22
cache: 'pnpm' cache: 'pnpm'
cache-dependency-path: apps/frontend/pnpm-lock.yaml cache-dependency-path: pnpm-lock.yaml
- name: Install frontend dependencies - name: Install frontend dependencies
run: | run: |
@@ -142,12 +139,12 @@ jobs:
with: with:
node-version: 22 node-version: 22
cache: 'pnpm' cache: 'pnpm'
cache-dependency-path: apps/frontend/pnpm-lock.yaml cache-dependency-path: pnpm-lock.yaml
- name: Install frontend dependencies - name: Install frontend dependencies
run: | run: |
cd apps/frontend cd apps/frontend
pnpm install pnpm install --frozen-lockfile
- name: Build frontend - name: Build frontend
run: | run: |

View File

@@ -11,18 +11,15 @@ on:
jobs: jobs:
# setup is now handled by a composite action used by downstream jobs to keep
# the workflow DRY. The composite action performs checkout, cache restore and
# toolchain setup.
verify-generated-code: verify-generated-database-code:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3 uses: actions/checkout@v3
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Setup Rust, checkout and restore caches - name: Setup Rust, checkout and restore caches
uses: ./.github/actions/setup-rust uses: ./.github/actions/setup-rust
@@ -32,14 +29,62 @@ jobs:
cargo run -- db:migrate_and_generate --output-path ../../public/database/src/generated/entities cargo run -- db:migrate_and_generate --output-path ../../public/database/src/generated/entities
- name: Check for uncommitted changes in /generated/ - name: Check for uncommitted changes in /generated/
run: | run: |
if [[ -n $(git status --porcelain | grep '^ M .*\/generated\/') ]]; then if [[ -n $(git status --porcelain --untracked-files=all | grep '/generated/') ]]; then
echo "Generated code is not up to date. Please run the code generation locally and commit the changes." echo "Generated code is not up to date. Please run the code generation locally and commit the changes."
git status --porcelain | grep '^ M .*\/generated\/' git status --porcelain --untracked-files=all | grep '/generated/'
exit 1 exit 1
else else
echo "Generated code is up to date." echo "Generated code is up to date."
fi fi
verify-generated-agent-code:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '21'
- name: Setup Rust, checkout and restore caches
uses: ./.github/actions/setup-rust
- name: Setup PNPM
uses: pnpm/action-setup@v4
with:
version: 10
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
cache-dependency-path: pnpm-lock.yaml
- name: Install root dependencies
run: |
pnpm install --frozen-lockfile
- name: generate agent client code
run: |
pnpm just generate-agent-client
- name: Check for uncommitted changes in agent client code
run: |
if [[ -n $(git status --porcelain --untracked-files=all | grep 'public/agent-client/') ]]; then
echo "Agent client code is not up to date. Please run the agent client code generation locally and commit the changes."
git status --porcelain --untracked-files=all | grep 'public/agent-client/'
exit 1
else
echo "Agent client code is up to date."
fi
verify-openapi-spec: verify-openapi-spec:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -83,7 +128,7 @@ jobs:
- name: Setup Rust, checkout and restore caches - name: Setup Rust, checkout and restore caches
if: steps.check_changes.outputs.changed == 'true' if: steps.check_changes.outputs.changed == 'true'
uses: ./.github/actions/setup-rust uses: ./.github/actions/setup-rust
- name: Generate dummy frontend build (to satisfy dependencies) - name: Generate dummy frontend build (to satisfy dependencies)
if: steps.check_changes.outputs.changed == 'true' if: steps.check_changes.outputs.changed == 'true'
run: | run: |
@@ -95,13 +140,13 @@ jobs:
run: | run: |
cd apps/api cd apps/api
cargo run -- generate:openapi --output-path ./swagger.json cargo run -- generate:openapi --output-path ./swagger.json
- name: Check for uncommitted changes in swagger.json - name: Check for uncommitted changes in swagger.json
if: steps.check_changes.outputs.changed == 'true' if: steps.check_changes.outputs.changed == 'true'
run: | run: |
if [[ -n $(git status --porcelain | grep '^ M apps/api/swagger.json') ]]; then if [[ -n $(git status --porcelain --untracked-files=all | grep 'apps/api/swagger.json') ]]; then
echo "OpenAPI spec is not up to date. Please run the OpenAPI generation locally and commit the changes." echo "OpenAPI spec is not up to date. Please run the OpenAPI generation locally and commit the changes."
git status --porcelain | grep '^ M apps/api/swagger.json' git status --porcelain --untracked-files=all | grep 'apps/api/swagger.json'
exit 1 exit 1
else else
echo "OpenAPI spec is up to date." echo "OpenAPI spec is up to date."
@@ -165,14 +210,14 @@ jobs:
with: with:
node-version: 22 node-version: 22
cache: 'pnpm' cache: 'pnpm'
cache-dependency-path: apps/frontend/pnpm-lock.yaml cache-dependency-path: pnpm-lock.yaml
- name: Install frontend dependencies - name: Install frontend dependencies
if: steps.check_swagger_changes.outputs.changed == 'true' if: steps.check_swagger_changes.outputs.changed == 'true'
run: | run: |
cd apps/frontend cd apps/frontend
pnpm install pnpm install
- name: Generate frontend API client - name: Generate frontend API client
if: steps.check_swagger_changes.outputs.changed == 'true' if: steps.check_swagger_changes.outputs.changed == 'true'
run: | run: |
@@ -182,14 +227,14 @@ jobs:
- name: Check for uncommitted changes in frontend API client - name: Check for uncommitted changes in frontend API client
if: steps.check_swagger_changes.outputs.changed == 'true' if: steps.check_swagger_changes.outputs.changed == 'true'
run: | run: |
if [[ -n $(git status --porcelain | grep '^ M apps/frontend/app/generated/api-client') ]]; then if [[ -n $(git status --porcelain --untracked-files=all | grep 'apps/frontend/app/generated/api-client') ]]; then
echo "Frontend API client is not up to date. Please run the API client generation locally and commit the changes." echo "Frontend API client is not up to date. Please run the API client generation locally and commit the changes."
git status --porcelain | grep '^ M apps/frontend/app/generated/api-client' git status --porcelain --untracked-files=all | grep 'apps/frontend/app/generated/api-client'
exit 1 exit 1
else else
echo "Frontend API client is up to date." echo "Frontend API client is up to date."
fi fi
- name: Skip frontend API client generation (no relevant changes) - name: Skip frontend API client generation (no relevant changes)
if: steps.check_swagger_changes.outputs.changed == 'false' if: steps.check_swagger_changes.outputs.changed == 'false'
run: echo "No changes in apps/api/swagger.json nor apps/frontend/app/generated/api-client, skipping frontend API client generation verification." run: echo "No changes in apps/api/swagger.json nor apps/frontend/app/generated/api-client, skipping frontend API client generation verification."

2
.gitignore vendored
View File

@@ -27,3 +27,5 @@ target
.env.generated .env.generated
generated-config.yaml generated-config.yaml
node_modules/

665
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ members = [
"apps/container", "apps/container",
"apps/cli", "apps/cli",
"apps/agent", "apps/agent",
"public/agent-client",
"public/shared", "public/shared",
"public/database", "public/database",
"public/migration" "public/migration"

View File

@@ -13,3 +13,4 @@ serde = { version = "1.0.228", features = ["std", "derive"] }
tokio-cron-scheduler = { version = "0.15.1", features = ["signal"] } tokio-cron-scheduler = { version = "0.15.1", features = ["signal"] }
clap = { version = "4", features = ["derive", "env"] } clap = { version = "4", features = ["derive", "env"] }
nix = { version = "0.30.1", features = ["user", "fs"] } nix = { version = "0.30.1", features = ["user", "fs"] }
utoipa = { version = "5.4.0", features = ["macros", "axum_extras", "chrono", "decimal", "uuid", "time", "openapi_extensions"] }

View File

@@ -1,6 +1,7 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
mod commands; mod commands;
mod openapi;
mod routes; mod routes;
use axum::routing::get; use axum::routing::get;
@@ -13,6 +14,7 @@ use tokio::net::UnixListener;
use tracing::{error, info, warn}; use tracing::{error, info, warn};
use crate::commands::NginxService; use crate::commands::NginxService;
use crate::openapi::{GenerateOpenapiArgs, generate_openapi_doc};
use crate::routes::{status, validate, validate_and_reload, write_config}; use crate::routes::{status, validate, validate_and_reload, write_config};
const SOCK_ENV: &str = "YANPM_AGENT_SOCK"; const SOCK_ENV: &str = "YANPM_AGENT_SOCK";
@@ -43,6 +45,19 @@ struct Args {
/// GID to set on the unix socket, default: current user's primary group /// GID to set on the unix socket, default: current user's primary group
#[arg(long, default_value_t = String::from(SOCK_GID_DEFAULT), env = SOCK_GID_ENV)] #[arg(long, default_value_t = String::from(SOCK_GID_DEFAULT), env = SOCK_GID_ENV)]
sock_gid: String, sock_gid: String,
#[command(subcommand)]
command: Option<SubCommand>,
}
#[derive(clap::Subcommand, Debug)]
pub enum SubCommand {
/// Generate OpenAPI spec to file or stdout
GenerateOpenapi {
/// Output file path.
#[arg(short = 'o', long)]
output: String,
},
} }
#[tokio::main] #[tokio::main]
@@ -59,6 +74,18 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let args = Args::parse(); let args = Args::parse();
if let Some(cmd) = &args.command {
match cmd {
SubCommand::GenerateOpenapi { output } => {
generate_openapi_doc(&GenerateOpenapiArgs {
output: output.clone(),
})
.await?;
return Ok(());
}
}
}
let (sock, nginx_config_dir, sock_perm, sock_gid) = get_args(&args).await?; let (sock, nginx_config_dir, sock_perm, sock_gid) = get_args(&args).await?;
let path = PathBuf::from(&sock); let path = PathBuf::from(&sock);

45
apps/agent/src/openapi.rs Normal file
View File

@@ -0,0 +1,45 @@
use tracing::info;
use utoipa::OpenApi;
pub mod tag {
/// nginx
pub const NGINX_TAG: &str = "Nginx Agent";
}
#[derive(utoipa::OpenApi)]
#[openapi(
paths(
crate::routes::status,
crate::routes::validate,
crate::routes::validate_and_reload,
crate::routes::write_config,
),
components(
schemas(crate::routes::StatusResp),
schemas(crate::routes::ValidateAndReloadResp),
schemas(crate::routes::ValidateBody),
schemas(crate::routes::WriteConfigBody),
schemas(crate::routes::ValidateAndReloadBody),
),
tags(
(name = tag::NGINX_TAG, description = "Nginx Agent API"),
)
)]
struct ApiDoc;
pub struct GenerateOpenapiArgs {
pub output: String,
}
pub async fn generate_openapi_doc(
args: &GenerateOpenapiArgs,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
info!("Generating OpenAPI documentation...");
let doc = ApiDoc::openapi();
let json = doc
.to_pretty_json()
.expect("Failed to serialize OpenAPI doc to JSON");
std::fs::write(&args.output, json).expect("Failed to write OpenAPI doc to file");
info!("OpenAPI documentation generated at {}", args.output);
Ok(())
}

View File

@@ -9,28 +9,46 @@ use tracing::warn;
use crate::commands::NginxService; use crate::commands::NginxService;
#[derive(Serialize)] #[derive(Serialize, utoipa::ToSchema)]
pub struct StatusResp { pub struct StatusResp {
pub ok: bool, pub ok: bool,
} }
/// Health check endpoint
#[utoipa::path(
get,
path = "/status",
responses(
(status = 200, description = "Status response", body = StatusResp)
),
tag = crate::openapi::tag::NGINX_TAG
)]
pub async fn status() -> impl IntoResponse { pub async fn status() -> impl IntoResponse {
let resp = StatusResp { ok: true }; let resp = StatusResp { ok: true };
(axum::http::StatusCode::OK, axum::Json(resp)) (axum::http::StatusCode::OK, axum::Json(resp))
} }
#[derive(Serialize)] #[derive(Serialize, utoipa::ToSchema)]
pub struct ValidateAndReloadResp { pub struct ValidateAndReloadResp {
pub rc: i32, pub rc: i32,
pub ro: String, pub ro: String,
} }
#[derive(Deserialize)] #[derive(Deserialize, utoipa::ToSchema)]
pub struct ValidateBody { pub struct ValidateBody {
config_name: String, config_name: String,
timestamp: u64, timestamp: u64,
} }
#[utoipa::path(
post,
path = "/validate",
request_body = ValidateBody,
responses(
(status = 200, description = "Validation response", body = serde_json::Value)
),
tag = crate::openapi::tag::NGINX_TAG
)]
pub async fn validate( pub async fn validate(
State(nginx_controller): State<Arc<NginxService>>, State(nginx_controller): State<Arc<NginxService>>,
Json(payload): Json<Value>, Json(payload): Json<Value>,
@@ -57,12 +75,21 @@ pub async fn validate(
(axum::http::StatusCode::OK, axum::Json(resp)).into_response() (axum::http::StatusCode::OK, axum::Json(resp)).into_response()
} }
#[derive(Deserialize)] #[derive(Deserialize, utoipa::ToSchema)]
pub struct ValidateAndReloadBody { pub struct ValidateAndReloadBody {
config_name: String, config_name: String,
timestamp: u64, timestamp: u64,
} }
#[utoipa::path(
post,
path = "/validate_and_reload",
request_body = ValidateAndReloadBody,
responses(
(status = 200, description = "Validate and reload response", body = ValidateAndReloadResp)
),
tag = crate::openapi::tag::NGINX_TAG
)]
pub async fn validate_and_reload( pub async fn validate_and_reload(
State(nginx_controller): State<Arc<NginxService>>, State(nginx_controller): State<Arc<NginxService>>,
Json(payload): Json<Value>, Json(payload): Json<Value>,
@@ -96,13 +123,23 @@ pub async fn validate_and_reload(
(axum::http::StatusCode::OK, axum::Json(resp)).into_response() (axum::http::StatusCode::OK, axum::Json(resp)).into_response()
} }
#[derive(Deserialize)] #[derive(Deserialize, utoipa::ToSchema)]
pub struct WriteConfigBody { pub struct WriteConfigBody {
config_name: String, config_name: String,
timestamp: u64, timestamp: u64,
content: String, content: String,
} }
#[utoipa::path(
post,
path = "/write_config",
request_body = WriteConfigBody,
responses(
(status = 200, description = "Write config response"),
(status = 500, description = "Internal server error", body = serde_json::Value)
),
tag = crate::openapi::tag::NGINX_TAG
)]
pub async fn write_config( pub async fn write_config(
State(nginx_controller): State<Arc<NginxService>>, State(nginx_controller): State<Arc<NginxService>>,
Json(payload): Json<Value>, Json(payload): Json<Value>,

215
apps/agent/swagger.json Normal file
View File

@@ -0,0 +1,215 @@
{
"openapi": "3.1.0",
"info": {
"title": "yanpm-agent",
"description": "",
"license": {
"name": ""
},
"version": "0.1.0"
},
"paths": {
"/status": {
"get": {
"tags": [
"Nginx Agent"
],
"summary": "Health check endpoint",
"operationId": "status",
"responses": {
"200": {
"description": "Status response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StatusResp"
}
}
}
}
}
}
},
"/validate": {
"post": {
"tags": [
"Nginx Agent"
],
"operationId": "validate",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidateBody"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Validation response",
"content": {
"application/json": {
"schema": {}
}
}
}
}
}
},
"/validate_and_reload": {
"post": {
"tags": [
"Nginx Agent"
],
"operationId": "validate_and_reload",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidateAndReloadBody"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Validate and reload response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidateAndReloadResp"
}
}
}
}
}
}
},
"/write_config": {
"post": {
"tags": [
"Nginx Agent"
],
"operationId": "write_config",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WriteConfigBody"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Write config response"
},
"500": {
"description": "Internal server error",
"content": {
"application/json": {
"schema": {}
}
}
}
}
}
}
},
"components": {
"schemas": {
"StatusResp": {
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"type": "boolean"
}
}
},
"ValidateAndReloadBody": {
"type": "object",
"required": [
"config_name",
"timestamp"
],
"properties": {
"config_name": {
"type": "string"
},
"timestamp": {
"type": "integer",
"format": "int64",
"minimum": 0
}
}
},
"ValidateAndReloadResp": {
"type": "object",
"required": [
"rc",
"ro"
],
"properties": {
"rc": {
"type": "integer",
"format": "int32"
},
"ro": {
"type": "string"
}
}
},
"ValidateBody": {
"type": "object",
"required": [
"config_name",
"timestamp"
],
"properties": {
"config_name": {
"type": "string"
},
"timestamp": {
"type": "integer",
"format": "int64",
"minimum": 0
}
}
},
"WriteConfigBody": {
"type": "object",
"required": [
"config_name",
"timestamp",
"content"
],
"properties": {
"config_name": {
"type": "string"
},
"content": {
"type": "string"
},
"timestamp": {
"type": "integer",
"format": "int64",
"minimum": 0
}
}
}
}
},
"tags": [
{
"name": "Nginx Agent",
"description": "Nginx Agent API"
}
]
}

View File

@@ -6,6 +6,7 @@ edition = "2024"
[dependencies] [dependencies]
database = { path = "../../public/database" } database = { path = "../../public/database" }
migration = { path = "../../public/migration" } migration = { path = "../../public/migration" }
agent_client = { path = "../../public/agent-client" }
axum = { version = "0.8.7", features = ["form", "http1", "http2", "json", "matched-path", "original-uri", "query", "tokio", "tower-log", "tracing", "macros"] } axum = { version = "0.8.7", features = ["form", "http1", "http2", "json", "matched-path", "original-uri", "query", "tokio", "tower-log", "tracing", "macros"] }
axum-extra = { version = "0.12.2", features = ["cookie"] } axum-extra = { version = "0.12.2", features = ["cookie"] }
@@ -28,4 +29,13 @@ argon2 = { version = "0.5.3", features = ["std"] }
jsonwebtoken = { version = "10.2.0", features = ["rust_crypto"] } jsonwebtoken = { version = "10.2.0", features = ["rust_crypto"] }
uuid = { version = "1.19.0", features = ["v4", "serde", "fast-rng"] } uuid = { version = "1.19.0", features = ["v4", "serde", "fast-rng"] }
tower-http = { version = "0.6.8", features = ["cors"] } tower-http = { version = "0.6.8", features = ["cors"] }
reqwest = { version = "^0.12", features = ["json", "multipart", "stream"] }
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"

View File

@@ -28,7 +28,7 @@ fn action(
_matches: &clap::ArgMatches, _matches: &clap::ArgMatches,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> { ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
let output_path = _matches.get_one::<String>("output_path"); let output_path = _matches.get_one::<String>("output_path");
let output_path = output_path.unwrap().to_string(); let output_path = output_path.expect("output_path is required").to_string();
Box::pin(async move { Box::pin(async move {
tracing::subscriber::with_default(log::make_temporary_subscriber(), || { tracing::subscriber::with_default(log::make_temporary_subscriber(), || {

View File

@@ -11,15 +11,8 @@ use crate::{
cmd::CliCommand, cmd::CliCommand,
configs::{ProgramSettings, get_program_settings, logging::LoggingSettings}, configs::{ProgramSettings, get_program_settings, logging::LoggingSettings},
log, log,
routes::{self, AppService, AppState}, routes::{self, AppState},
services::{ services::get_app_service,
auth::{
authentication::{AuthenticationServiceImpl, strategies::password::PasswordStrategy},
user::UserServiceImpl,
},
server_state::ServerStateService,
settings::SettingsService,
},
tasks, tasks,
}; };
@@ -148,19 +141,7 @@ fn get_app_state(
AppState { AppState {
database_connection: db_connection.clone(), database_connection: db_connection.clone(),
config: Arc::new(settings.clone()), config: Arc::new(settings.clone()),
service: Arc::new(AppService { service: Arc::new(get_app_service(db_connection, settings)),
server_state: Arc::new(ServerStateService::new(db_connection.clone())),
settings: Arc::new(SettingsService::new(db_connection.clone())),
auth_state: routes::AuthState {
strategy: routes::AuthStrategy {
password: Arc::new(PasswordStrategy::new(db_connection.clone())),
},
authentication: Arc::new(AuthenticationServiceImpl::new(
settings.auth.jwt_secret.clone(),
)),
},
user: Arc::new(UserServiceImpl::new(db_connection.clone())),
}),
} }
} }

View File

@@ -1,3 +1,4 @@
pub mod agent;
pub mod auth; pub mod auth;
pub mod database; pub mod database;
pub mod logging; pub mod logging;
@@ -21,6 +22,7 @@ pub struct ProgramSettings {
pub database: database::DatabaseSettings, pub database: database::DatabaseSettings,
pub server: server::ServerSettings, pub server: server::ServerSettings,
pub auth: auth::AuthSettings, pub auth: auth::AuthSettings,
pub agent: agent::AgentSettings,
} }
impl FromConfig for ProgramSettings { impl FromConfig for ProgramSettings {
@@ -30,6 +32,7 @@ impl FromConfig for ProgramSettings {
database: database::DatabaseSettings::from_config(_config)?, database: database::DatabaseSettings::from_config(_config)?,
server: server::ServerSettings::from_config(_config)?, server: server::ServerSettings::from_config(_config)?,
auth: auth::AuthSettings::from_config(_config)?, auth: auth::AuthSettings::from_config(_config)?,
agent: agent::AgentSettings::from_config(_config)?,
}; };
config.validate()?; config.validate()?;
Ok(config) Ok(config)
@@ -50,6 +53,7 @@ impl FromConfig for ProgramSettings {
database: database::DatabaseSettings::mock(), database: database::DatabaseSettings::mock(),
server: server::ServerSettings::mock(), server: server::ServerSettings::mock(),
auth: auth::AuthSettings::mock(), auth: auth::AuthSettings::mock(),
agent: agent::AgentSettings::mock(),
} }
} }
} }

View File

@@ -0,0 +1,58 @@
use config::Config;
use tracing::error;
use crate::configs::key::AGENT_SOCK_PATH_KEY;
use super::FromConfig;
#[derive(Debug, Clone)]
pub struct AgentSettings {
pub socket_path: String,
}
impl FromConfig for AgentSettings {
fn from_config(_config: &Config) -> Result<Self, String> {
Ok(AgentSettings {
socket_path: _config.get_string(AGENT_SOCK_PATH_KEY).map_err(|err| {
format!(
"Failed to get {} from configuration. Err: {}",
AGENT_SOCK_PATH_KEY, err
)
})?,
})
}
fn validate(&self) -> Result<(), String> {
// ensure socket_path exists and is readable and writable
if !std::path::Path::new(&self.socket_path).exists() {
let msg = format!("Agent socket path '{}' does not exist", self.socket_path);
error!("{}", msg);
return Err(msg);
}
if std::path::Path::new(&self.socket_path)
.metadata()
.map(|meta| {
let permissions = meta.permissions();
// Check read and write permissions for the owner
!permissions.readonly()
})
.unwrap_or(false)
{
Ok(())
} else {
let msg = format!(
"Agent socket path '{}' is not readable/writable",
self.socket_path
);
error!("{}", msg);
Err(msg)
}
}
#[cfg(test)]
fn mock() -> Self {
AgentSettings {
socket_path: "/tmp/agent.sock".to_string(),
}
}
}

View File

@@ -14,3 +14,5 @@ pub(crate) const DATABASE_MIGRATE_ON_STARTUP_KEY: &str = "DATABASE.MIGRATION.MIG
pub(crate) const AUTH_JWT_SECRET_KEY: &str = "AUTH.JWT_SECRET"; pub(crate) const AUTH_JWT_SECRET_KEY: &str = "AUTH.JWT_SECRET";
pub(crate) const AUTH_DEFAULT_ADMIN_USERNAME_KEY: &str = "AUTH.DEFAULT_ADMIN_USERNAME"; pub(crate) const AUTH_DEFAULT_ADMIN_USERNAME_KEY: &str = "AUTH.DEFAULT_ADMIN_USERNAME";
pub(crate) const AUTH_DEFAULT_ADMIN_PASSWORD_KEY: &str = "AUTH.DEFAULT_ADMIN_PASSWORD"; pub(crate) const AUTH_DEFAULT_ADMIN_PASSWORD_KEY: &str = "AUTH.DEFAULT_ADMIN_PASSWORD";
//
pub(crate) const AGENT_SOCK_PATH_KEY: &str = "AGENT.SOCK.PATH";

View File

@@ -121,7 +121,7 @@ impl FromConfig for ServerSettings {
#[cfg(test)] #[cfg(test)]
fn mock() -> Self { fn mock() -> Self {
ServerSettings { ServerSettings {
address: "0.0.0.0".parse().unwrap(), address: "0.0.0.0".parse().expect("Failed to parse mock IP address"),
port: 8080, port: 8080,
serve_openapi: false, serve_openapi: false,
cors: CORSSettings { cors: CORSSettings {

View File

@@ -1 +1,2 @@
pub mod api_error;
pub mod service_error; pub mod service_error;

View 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(),
}
}
}

View File

@@ -1,3 +1,4 @@
use axum::response::IntoResponse;
use sea_orm::DbErr; use sea_orm::DbErr;
#[derive(Debug)] #[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()
}
}

View File

@@ -1,2 +1,3 @@
pub mod constants; pub mod constants;
pub mod database; pub mod database;
pub mod macros;

View File

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

View 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,
}
};
}

View File

@@ -1,6 +1,34 @@
use axum::{extract::FromRequestParts, http::StatusCode};
use uuid::Uuid; use uuid::Uuid;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct RequestInfo { pub struct RequestInfo {
pub user_id: Option<Uuid>, 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)
}
}
}

View File

@@ -68,3 +68,42 @@ async fn handle_unauthenticated() -> Result<Response, StatusCode> {
// TODO: log unauthenticated access attempts // TODO: log unauthenticated access attempts
Err(StatusCode::UNAUTHORIZED) 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)
}
}

View File

@@ -12,12 +12,8 @@ use crate::{
configs::{ProgramSettings, server::CORSSettings}, configs::{ProgramSettings, server::CORSSettings},
middlewares, middlewares,
services::{ services::{
auth::{ AppService, ServiceState,
authentication::{AuthenticationService, strategies::password::PasswordStrategy}, auth::authentication::{AuthenticationService, strategies::password::PasswordStrategy},
user::UserService,
},
server_state::ServerStateStore,
settings::SettingsStore,
}, },
}; };
@@ -28,8 +24,6 @@ pub struct AppState {
pub config: Arc<ProgramSettings>, pub config: Arc<ProgramSettings>,
} }
pub type ServiceState<T> = Arc<T>;
pub struct AuthStrategy { pub struct AuthStrategy {
pub password: ServiceState<PasswordStrategy>, pub password: ServiceState<PasswordStrategy>,
} }
@@ -39,13 +33,6 @@ pub struct AuthState {
pub authentication: ServiceState<dyn AuthenticationService>, pub authentication: ServiceState<dyn AuthenticationService>,
} }
pub struct AppService {
pub settings: ServiceState<dyn SettingsStore>,
pub auth_state: AuthState,
pub user: ServiceState<dyn UserService>,
pub server_state: ServiceState<dyn ServerStateStore>,
}
pub fn get_root_router( pub fn get_root_router(
state: impl Into<Arc<AppState>>, state: impl Into<Arc<AppState>>,
cors_settings: Arc<CORSSettings>, cors_settings: Arc<CORSSettings>,

View File

@@ -1,5 +1,6 @@
mod auth; mod auth;
mod health; mod health;
mod helper;
mod openapi; mod openapi;
mod restricted; mod restricted;

View File

@@ -79,6 +79,8 @@ pub async fn get_health_info(
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use crate::configs::FromConfig; use crate::configs::FromConfig;
use crate::services::agent_client::AgentService;
use crate::services::get_app_service;
use crate::{ use crate::{
routes::{AppState, api::health::state::HealthState}, routes::{AppState, api::health::state::HealthState},
services::{ services::{
@@ -94,6 +96,7 @@ mod test {
}; };
use super::*; use super::*;
use agent_client::apis::configuration::Configuration;
use axum::body::to_bytes; use axum::body::to_bytes;
use axum::{ use axum::{
Router, Router,
@@ -114,17 +117,10 @@ mod test {
let app_state = Arc::new(AppState { let app_state = Arc::new(AppState {
database_connection: db.clone(), database_connection: db.clone(),
config: Arc::new(crate::configs::ProgramSettings::mock()), config: Arc::new(crate::configs::ProgramSettings::mock()),
service: Arc::new(crate::routes::AppService { service: Arc::new(get_app_service(
settings: Arc::new(SettingsService::new(db.clone())), &db.clone(),
auth_state: crate::routes::AuthState { &crate::configs::ProgramSettings::mock(),
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())),
}),
}); });
let app = Router::new() let app = Router::new()
@@ -135,13 +131,21 @@ mod test {
})); }));
let response = app let response = app
.oneshot(Request::builder().uri("/info").body(Body::empty()).unwrap()) .oneshot(
Request::builder()
.uri("/info")
.body(Body::empty())
.expect("Failed to build request"),
)
.await .await
.unwrap(); .unwrap();
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), 1024 * 1024).await.unwrap(); // Set limit to 1 MB let body = to_bytes(response.into_body(), 1024 * 1024)
let health_info: HealthInfo = serde_json::from_slice(&body).unwrap(); .await
.expect("Failed to read response body"); // Set limit to 1 MB
let health_info: HealthInfo =
serde_json::from_slice(&body).expect("Failed to deserialize response body");
assert_eq!(health_info.status, STATUS_HEALTHY); assert_eq!(health_info.status, STATUS_HEALTHY);
assert_eq!(health_info.version, env!("CARGO_PKG_VERSION")); assert_eq!(health_info.version, env!("CARGO_PKG_VERSION"));
assert!(health_info.errors.is_none()); assert!(health_info.errors.is_none());

View File

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

View 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))
}
}

View File

@@ -1,3 +1,4 @@
pub mod nginx;
pub mod user; pub mod user;
use std::sync::Arc; 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 { pub fn get_restricted_router(state: Arc<AppState>) -> Router {
Router::new() Router::new()
.nest("/user", user::get_user_router(state.clone())) .nest("/user", user::get_user_router(state.clone()))
.nest("/nginx", nginx::get_nginx_router(state.clone()))
.layer(axum::middleware::from_fn_with_state( .layer(axum::middleware::from_fn_with_state(
state.clone(), state.clone(),
require_auth, require_auth,

View 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()))
}

View 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)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,59 @@
pub mod agent_client;
pub mod auth; pub mod auth;
pub mod nginx;
pub mod server_state; pub mod server_state;
pub mod settings; pub mod settings;
use std::sync::Arc;
use ::agent_client::apis::configuration::Configuration;
use crate::{
configs::ProgramSettings,
routes::{self, AuthState},
services::{
auth::{
authentication::{AuthenticationServiceImpl, strategies::password::PasswordStrategy},
user::{UserService, UserServiceImpl},
},
nginx::NginxService,
server_state::{ServerStateService, ServerStateStore},
settings::{SettingsService, SettingsStore},
},
};
pub type ServiceState<T> = Arc<T>;
pub struct AppService {
pub settings: ServiceState<dyn SettingsStore>,
pub auth_state: AuthState,
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>,
}
pub fn get_app_service(
db_connection: &Arc<sea_orm::DatabaseConnection>,
settings: &ProgramSettings,
) -> AppService {
AppService {
server_state: Arc::new(ServerStateService::new(db_connection.clone())),
settings: Arc::new(SettingsService::new(db_connection.clone())),
auth_state: routes::AuthState {
strategy: routes::AuthStrategy {
password: Arc::new(PasswordStrategy::new(db_connection.clone())),
},
authentication: Arc::new(AuthenticationServiceImpl::new(
settings.auth.jwt_secret.clone(),
)),
},
user: Arc::new(UserServiceImpl::new(db_connection.clone())),
nginx: Arc::new(NginxService::new(db_connection.clone())),
agent_client: Arc::new(agent_client::AgentService::new(Configuration::from(
settings.agent.clone(),
))),
}
}

View File

@@ -0,0 +1,114 @@
use std::sync::Arc;
use agent_client::apis::{ApiClient, configuration::Configuration};
use tracing::warn;
use crate::configs::agent::AgentSettings;
pub struct AgentService {
client: Arc<ApiClient>,
}
impl From<AgentSettings> for Configuration {
fn from(settings: AgentSettings) -> Self {
let mut config = Configuration::default();
let mut builder = reqwest::Client::builder();
let url = settings.socket_path;
if url.starts_with("unix://") {
builder = builder.unix_socket(url.to_string());
config.client = builder.build().expect("Failed to build reqwest client");
} else {
warn!("AgentSettings contains a non-unix socket path: {}", url);
config.base_path = url;
}
config
}
}
impl AgentService {
pub fn new(config: impl Into<Arc<Configuration>>) -> Self {
let client = ApiClient::new(config.into());
AgentService {
client: Arc::new(client),
}
}
#[allow(dead_code)]
pub fn get_client(&self) -> Arc<ApiClient> {
Arc::clone(&self.client)
}
}
#[cfg(test)]
mod tests {
use super::*;
use agent_client::{
apis::{Api, nginx_agent_api::StatusSuccess},
models::StatusResp,
};
use axum::{http::StatusCode, response::Json};
use std::time::Duration;
use tempfile::tempdir;
use tokio::time::sleep;
#[test]
fn test_agent_service_creation() {
let config = Configuration::default();
let service = AgentService::new(config);
let client = service.get_client();
assert!(Arc::ptr_eq(&client, &service.client));
}
#[tokio::test]
async fn test_agent_socket_support() {
// create temporary socket path
let dir = tempdir().expect("Failed to create temp dir");
let socket_path = dir.path().join("agent.sock");
// create axum app with a simple /status route
let app = axum::Router::new().route(
"/status",
axum::routing::get(|| async {
let result: (StatusCode, StatusResp) = (StatusCode::OK, StatusResp { ok: true });
(result.0, Json(result.1))
}),
);
// bind tokio unix listener and serve in background
let listener =
tokio::net::UnixListener::bind(&socket_path).expect("Failed to bind to unix socket");
let server_fut = axum::serve::serve(listener, app);
let _srv = tokio::spawn(async move {
let _ = server_fut.await;
});
// give server a moment to start
sleep(Duration::from_millis(50)).await;
let client: ApiClient = ApiClient::new(Arc::new(Configuration {
base_path: "http://localhost".to_string(),
client: reqwest::Client::builder()
.unix_socket(socket_path.clone())
.build()
.expect("Failed to build reqwest client"),
..Default::default()
}));
let res = client
.nginx_agent_api()
.status()
.await
.expect("Failed to get status");
let body = res.entity.expect("Response entity is missing");
assert!(res.status.is_success());
if let StatusSuccess::Status200(body) = body {
assert!(body.ok);
} else {
panic!("Unexpected response body");
}
}
}

View File

@@ -197,14 +197,17 @@ mod tests {
let (token, _) = service let (token, _) = service
.generate_jwt(user_id, 60) .generate_jwt(user_id, 60)
.await .await
.expect("generate jwt"); .expect("Failed to generate jwt");
let valid = service let valid = service
.is_valid_jwt(&token, None) .is_valid_jwt(&token, None)
.await .await
.expect("validate jwt"); .expect("Failed to validate jwt");
assert!(valid.is_some(), "Generated token should be valid"); assert!(valid.is_some(), "Generated token should be valid");
let claims = service.parse_jwt(&token).await.expect("parse jwt"); let claims = service
.parse_jwt(&token)
.await
.expect("Failed to parse jwt");
assert_eq!(claims.sub, user_id.to_string()); assert_eq!(claims.sub, user_id.to_string());
} }
@@ -213,10 +216,16 @@ mod tests {
let service = AuthenticationServiceImpl::new(Some("secret".to_string())); let service = AuthenticationServiceImpl::new(Some("secret".to_string()));
let user_id = Uuid::new_v4(); let user_id = Uuid::new_v4();
let (token, _) = service.generate_jwt(user_id, 60).await.unwrap(); let (token, _) = service
.generate_jwt(user_id, 60)
.await
.expect("Failed to generate jwt");
let other_sub = Uuid::new_v4().to_string(); let other_sub = Uuid::new_v4().to_string();
let valid = service.is_valid_jwt(&token, Some(other_sub)).await.unwrap(); let valid = service
.is_valid_jwt(&token, Some(other_sub))
.await
.expect("jwt is not valid");
assert!( assert!(
valid.is_none(), valid.is_none(),
"Token should be invalid for a different subject" "Token should be invalid for a different subject"
@@ -236,10 +245,19 @@ mod tests {
let service = AuthenticationServiceImpl::new(Some("secret".to_string())); let service = AuthenticationServiceImpl::new(Some("secret".to_string()));
let user_id = Uuid::new_v4(); let user_id = Uuid::new_v4();
let (token, _) = service.generate_jwt(user_id, 60).await.unwrap(); let (token, _) = service
let new_token = service.refresh_jwt(&token, 120).await.unwrap(); .generate_jwt(user_id, 60)
.await
.expect("Failed to generate jwt");
let new_token = service
.refresh_jwt(&token, 120)
.await
.expect("Failed to refresh jwt");
let claims = service.parse_jwt(&new_token).await.unwrap(); let claims = service
.parse_jwt(&new_token)
.await
.expect("Failed to parse refreshed jwt");
assert_eq!(claims.sub, user_id.to_string()); assert_eq!(claims.sub, user_id.to_string());
assert_eq!(claims.exp - claims.iat, 120); assert_eq!(claims.exp - claims.iat, 120);
} }
@@ -249,10 +267,16 @@ mod tests {
let service = AuthenticationServiceImpl::new(Some("secret".to_string())); let service = AuthenticationServiceImpl::new(Some("secret".to_string()));
let user_id = Uuid::new_v4(); let user_id = Uuid::new_v4();
let (token, claims) = service.generate_jwt(user_id, 1).await.unwrap(); let (token, claims) = service
.generate_jwt(user_id, 1)
.await
.expect("Failed to generate jwt");
sleep(Duration::from_secs(2)).await; sleep(Duration::from_secs(2)).await;
let valid = service.is_valid_jwt(&token, None).await.unwrap(); let valid = service
.is_valid_jwt(&token, None)
.await
.expect("Failed to validate jwt");
assert!( assert!(
valid.is_none(), valid.is_none(),
"Token should be expired and thus invalid. Current time: {:?}. Diff: {}", "Token should be expired and thus invalid. Current time: {:?}. Diff: {}",
@@ -266,9 +290,15 @@ mod tests {
let service = AuthenticationServiceImpl::new(Some("secret".to_string())); let service = AuthenticationServiceImpl::new(Some("secret".to_string()));
let user_id = Uuid::new_v4(); let user_id = Uuid::new_v4();
let (token, _) = service.generate_jwt(user_id, 1).await.unwrap(); let (token, _) = service
.generate_jwt(user_id, 1)
.await
.expect("Failed to generate jwt");
service.invalidate_jwt(&token).await.unwrap(); service
.invalidate_jwt(&token)
.await
.expect("Failed to invalidate jwt");
// ensure entry is present // ensure entry is present
{ {

View File

@@ -236,7 +236,7 @@ mod test {
"CorrectPassword".as_bytes(), "CorrectPassword".as_bytes(),
&SaltString::generate(&mut OsRng), &SaltString::generate(&mut OsRng),
) )
.unwrap() .expect("Failed to hash password")
.to_string(); .to_string();
let db = MockDatabase::new(sea_orm::DatabaseBackend::Sqlite) let db = MockDatabase::new(sea_orm::DatabaseBackend::Sqlite)
.append_query_results(vec![vec![user::Model { .append_query_results(vec![vec![user::Model {
@@ -281,7 +281,7 @@ mod test {
"CorrectPassword".as_bytes(), "CorrectPassword".as_bytes(),
&SaltString::generate(&mut OsRng), &SaltString::generate(&mut OsRng),
) )
.unwrap() .expect("Failed to hash password")
.to_string(); .to_string();
let db = MockDatabase::new(sea_orm::DatabaseBackend::Sqlite) let db = MockDatabase::new(sea_orm::DatabaseBackend::Sqlite)
.append_query_results(vec![vec![user::Model { .append_query_results(vec![vec![user::Model {

View 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()
}
}

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

View File

@@ -0,0 +1,2 @@
pub mod upstream;
pub mod upstream_target;

View 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),
}
}
}

View 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),
}
}
}

View File

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

View 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))
}
}

View 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());
}
}

View File

@@ -50,9 +50,31 @@ generate-openapi:
# Generate API client for frontend # Generate API client for frontend
cd apps/frontend && \ cd apps/frontend && \
pnpm generate:openapi pnpm generate:openapi
# Generate API client for agent
cd apps/agent && \
cargo run -- generate-openapi --output swagger.json
generate-agent-client:
# Generate API client for agent
pnpm openapi-generator-cli generate \
-g rust --skip-validate-spec \
-o ./public/agent-client -i ./apps/agent/swagger.json \
--additional-properties=library=reqwest-trait \
--additional-properties=mockall=true \
--additional-properties=packageName=agent_client \
--additional-properties=packageVersion=0.1.0 \
--additional-properties=supportAsync=true \
--additional-properties=supportMultipleResponses=true \
--additional-properties=topLevelApiClient=true \
--additional-properties=useSingleRequestParameter=true
# format generated code
cd public/agent-client && \
cargo fmt
# append lint allows/forbids to the end of Cargo.toml to disable warnings in generated code and forbid unsafe code
cd public/agent-client && \
echo '\n[lints.clippy]\nall = "allow"\n[lints.rust]\nunsafe_code = "forbid"\n' >> Cargo.toml
generate-all: generate-entity generate-openapi generate-all: generate-entity generate-openapi generate-agent-client
build-frontend: build-frontend:
# build frontend assets # build frontend assets

7
openapitools.json Normal file
View File

@@ -0,0 +1,7 @@
{
"$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json",
"spaces": 2,
"generator-cli": {
"version": "7.18.0"
}
}

6
package.json Normal file
View File

@@ -0,0 +1,6 @@
{
"devDependencies": {
"@openapitools/openapi-generator-cli": "^2.26.0",
"rust-just": "^1.44.0"
}
}

File diff suppressed because it is too large Load Diff

7
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,7 @@
packages:
- apps/frontend
onlyBuiltDependencies:
- '@nestjs/core'
- '@openapitools/openapi-generator-cli'
- esbuild

3
public/agent-client/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/target/
**/*.rs.bk
Cargo.lock

View File

@@ -0,0 +1,23 @@
# OpenAPI Generator Ignore
# Generated by openapi-generator https://github.com/openapitools/openapi-generator
# Use this file to prevent files from being overwritten by the generator.
# The patterns follow closely to .gitignore or .dockerignore.
# As an example, the C# client generator defines ApiClient.cs.
# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line:
#ApiClient.cs
# You can match any string of characters against a directory, file or extension with a single asterisk (*):
#foo/*/qux
# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux
# You can recursively match patterns against a directory, file or extension with a double asterisk (**):
#foo/**/qux
# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux
# You can also negate patterns with an exclamation (!).
# For example, you can ignore all files in a docs folder with the file extension .md:
#docs/*.md
# Then explicitly reverse the ignore rule for a single file:
#!docs/README.md

View File

@@ -0,0 +1,21 @@
.gitignore
.travis.yml
Cargo.toml
README.md
docs/NginxAgentApi.md
docs/StatusResp.md
docs/ValidateAndReloadBody.md
docs/ValidateAndReloadResp.md
docs/ValidateBody.md
docs/WriteConfigBody.md
git_push.sh
src/apis/configuration.rs
src/apis/mod.rs
src/apis/nginx_agent_api.rs
src/lib.rs
src/models/mod.rs
src/models/status_resp.rs
src/models/validate_and_reload_body.rs
src/models/validate_and_reload_resp.rs
src/models/validate_body.rs
src/models/write_config_body.rs

View File

@@ -0,0 +1 @@
7.18.0

View File

@@ -0,0 +1 @@
language: rust

View File

@@ -0,0 +1,27 @@
[package]
name = "agent_client"
version = "0.1.0"
authors = ["OpenAPI Generator team and contributors"]
description = "No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)"
license = ""
edition = "2021"
[dependencies]
serde = { version = "^1.0", features = ["derive"] }
serde_json = "^1.0"
serde_repr = "^0.1"
url = "^2.5"
async-trait = "^0.1"
reqwest = { version = "^0.12", default-features = false, features = ["json", "multipart", "stream"] }
mockall = { version = "^0.13", optional = true}
[features]
default = ["native-tls"]
native-tls = ["reqwest/native-tls"]
rustls-tls = ["reqwest/rustls-tls"]
mockall = ["dep:mockall"]
[lints.clippy]
all = "allow"
[lints.rust]
unsafe_code = "forbid"

View File

@@ -0,0 +1,53 @@
# Rust API client for agent_client
No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
## Overview
This API client was generated by the [OpenAPI Generator](https://openapi-generator.tech) project. By using the [openapi-spec](https://openapis.org) from a remote server, you can easily generate an API client.
- API version: 0.1.0
- Package version: 0.1.0
- Generator version: 7.18.0
- Build package: `org.openapitools.codegen.languages.RustClientCodegen`
## Installation
Put the package under your project folder in a directory named `agent_client` and add the following to `Cargo.toml` under `[dependencies]`:
```
agent_client = { path = "./agent_client" }
```
## Documentation for API Endpoints
All URIs are relative to *http://localhost*
Class | Method | HTTP request | Description
------------ | ------------- | ------------- | -------------
*NginxAgentApi* | [**status**](docs/NginxAgentApi.md#status) | **GET** /status | Health check endpoint
*NginxAgentApi* | [**validate**](docs/NginxAgentApi.md#validate) | **POST** /validate |
*NginxAgentApi* | [**validate_and_reload**](docs/NginxAgentApi.md#validate_and_reload) | **POST** /validate_and_reload |
*NginxAgentApi* | [**write_config**](docs/NginxAgentApi.md#write_config) | **POST** /write_config |
## Documentation For Models
- [StatusResp](docs/StatusResp.md)
- [ValidateAndReloadBody](docs/ValidateAndReloadBody.md)
- [ValidateAndReloadResp](docs/ValidateAndReloadResp.md)
- [ValidateBody](docs/ValidateBody.md)
- [WriteConfigBody](docs/WriteConfigBody.md)
To get access to the crate's generated documentation, use:
```
cargo doc --open
```
## Author

View File

@@ -0,0 +1,121 @@
# \NginxAgentApi
All URIs are relative to *http://localhost*
Method | HTTP request | Description
------------- | ------------- | -------------
[**status**](NginxAgentApi.md#status) | **GET** /status | Health check endpoint
[**validate**](NginxAgentApi.md#validate) | **POST** /validate |
[**validate_and_reload**](NginxAgentApi.md#validate_and_reload) | **POST** /validate_and_reload |
[**write_config**](NginxAgentApi.md#write_config) | **POST** /write_config |
## status
> models::StatusResp status()
Health check endpoint
### Parameters
This endpoint does not need any parameter.
### Return type
[**models::StatusResp**](StatusResp.md)
### Authorization
No authorization required
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
## validate
> serde_json::Value validate(validate_body)
### Parameters
Name | Type | Description | Required | Notes
------------- | ------------- | ------------- | ------------- | -------------
**validate_body** | [**ValidateBody**](ValidateBody.md) | | [required] |
### Return type
[**serde_json::Value**](serde_json::Value.md)
### Authorization
No authorization required
### HTTP request headers
- **Content-Type**: application/json
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
## validate_and_reload
> models::ValidateAndReloadResp validate_and_reload(validate_and_reload_body)
### Parameters
Name | Type | Description | Required | Notes
------------- | ------------- | ------------- | ------------- | -------------
**validate_and_reload_body** | [**ValidateAndReloadBody**](ValidateAndReloadBody.md) | | [required] |
### Return type
[**models::ValidateAndReloadResp**](ValidateAndReloadResp.md)
### Authorization
No authorization required
### HTTP request headers
- **Content-Type**: application/json
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
## write_config
> write_config(write_config_body)
### Parameters
Name | Type | Description | Required | Notes
------------- | ------------- | ------------- | ------------- | -------------
**write_config_body** | [**WriteConfigBody**](WriteConfigBody.md) | | [required] |
### Return type
(empty response body)
### Authorization
No authorization required
### HTTP request headers
- **Content-Type**: application/json
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)

View File

@@ -0,0 +1,11 @@
# StatusResp
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**ok** | **bool** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -0,0 +1,121 @@
# \TagNginxtagApi
All URIs are relative to *http://localhost*
Method | HTTP request | Description
------------- | ------------- | -------------
[**status**](TagNginxtagApi.md#status) | **GET** /status | Health check endpoint
[**validate**](TagNginxtagApi.md#validate) | **POST** /validate |
[**validate_and_reload**](TagNginxtagApi.md#validate_and_reload) | **POST** /validate_and_reload |
[**write_config**](TagNginxtagApi.md#write_config) | **POST** /write_config |
## status
> models::StatusResp status()
Health check endpoint
### Parameters
This endpoint does not need any parameter.
### Return type
[**models::StatusResp**](StatusResp.md)
### Authorization
No authorization required
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
## validate
> serde_json::Value validate(validate_body)
### Parameters
Name | Type | Description | Required | Notes
------------- | ------------- | ------------- | ------------- | -------------
**validate_body** | [**ValidateBody**](ValidateBody.md) | | [required] |
### Return type
[**serde_json::Value**](serde_json::Value.md)
### Authorization
No authorization required
### HTTP request headers
- **Content-Type**: application/json
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
## validate_and_reload
> models::ValidateAndReloadResp validate_and_reload(validate_and_reload_body)
### Parameters
Name | Type | Description | Required | Notes
------------- | ------------- | ------------- | ------------- | -------------
**validate_and_reload_body** | [**ValidateAndReloadBody**](ValidateAndReloadBody.md) | | [required] |
### Return type
[**models::ValidateAndReloadResp**](ValidateAndReloadResp.md)
### Authorization
No authorization required
### HTTP request headers
- **Content-Type**: application/json
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
## write_config
> write_config(write_config_body)
### Parameters
Name | Type | Description | Required | Notes
------------- | ------------- | ------------- | ------------- | -------------
**write_config_body** | [**WriteConfigBody**](WriteConfigBody.md) | | [required] |
### Return type
(empty response body)
### Authorization
No authorization required
### HTTP request headers
- **Content-Type**: application/json
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)

View File

@@ -0,0 +1,12 @@
# ValidateAndReloadBody
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**config_name** | **String** | |
**timestamp** | **i64** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -0,0 +1,12 @@
# ValidateAndReloadResp
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**rc** | **i32** | |
**ro** | **String** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -0,0 +1,12 @@
# ValidateBody
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**config_name** | **String** | |
**timestamp** | **i64** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -0,0 +1,13 @@
# WriteConfigBody
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**config_name** | **String** | |
**content** | **String** | |
**timestamp** | **i64** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -0,0 +1,57 @@
#!/bin/sh
# ref: https://help.github.com/articles/adding-an-existing-project-to-github-using-the-command-line/
#
# Usage example: /bin/sh ./git_push.sh wing328 openapi-petstore-perl "minor update" "gitlab.com"
git_user_id=$1
git_repo_id=$2
release_note=$3
git_host=$4
if [ "$git_host" = "" ]; then
git_host="github.com"
echo "[INFO] No command line input provided. Set \$git_host to $git_host"
fi
if [ "$git_user_id" = "" ]; then
git_user_id="GIT_USER_ID"
echo "[INFO] No command line input provided. Set \$git_user_id to $git_user_id"
fi
if [ "$git_repo_id" = "" ]; then
git_repo_id="GIT_REPO_ID"
echo "[INFO] No command line input provided. Set \$git_repo_id to $git_repo_id"
fi
if [ "$release_note" = "" ]; then
release_note="Minor update"
echo "[INFO] No command line input provided. Set \$release_note to $release_note"
fi
# Initialize the local directory as a Git repository
git init
# Adds the files in the local repository and stages them for commit.
git add .
# Commits the tracked changes and prepares them to be pushed to a remote repository.
git commit -m "$release_note"
# Sets the new remote
git_remote=$(git remote)
if [ "$git_remote" = "" ]; then # git remote not defined
if [ "$GIT_TOKEN" = "" ]; then
echo "[INFO] \$GIT_TOKEN (environment variable) is not set. Using the git credential in your environment."
git remote add origin https://${git_host}/${git_user_id}/${git_repo_id}.git
else
git remote add origin https://${git_user_id}:"${GIT_TOKEN}"@${git_host}/${git_user_id}/${git_repo_id}.git
fi
fi
git pull origin master
# Pushes (Forces) the changes in the local repository up to the remote repository
echo "Git pushing to https://${git_host}/${git_user_id}/${git_repo_id}.git"
git push origin master 2>&1 | grep -v 'To https'

View File

@@ -0,0 +1,48 @@
/*
* yanpm-agent
*
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 0.1.0
*
* Generated by: https://openapi-generator.tech
*/
#[derive(Debug, Clone)]
pub struct Configuration {
pub base_path: String,
pub user_agent: Option<String>,
pub client: reqwest::Client,
pub basic_auth: Option<BasicAuth>,
pub oauth_access_token: Option<String>,
pub bearer_access_token: Option<String>,
pub api_key: Option<ApiKey>,
}
pub type BasicAuth = (String, Option<String>);
#[derive(Debug, Clone)]
pub struct ApiKey {
pub prefix: Option<String>,
pub key: String,
}
impl Configuration {
pub fn new() -> Configuration {
Configuration::default()
}
}
impl Default for Configuration {
fn default() -> Self {
Configuration {
base_path: "http://localhost".to_owned(),
user_agent: Some("OpenAPI-Generator/0.1.0/rust".to_owned()),
client: reqwest::Client::new(),
basic_auth: None,
oauth_access_token: None,
bearer_access_token: None,
api_key: None,
}
}
}

View File

@@ -0,0 +1,165 @@
use std::error;
use std::fmt;
#[derive(Debug, Clone)]
pub struct ResponseContent<T> {
pub status: reqwest::StatusCode,
pub content: String,
pub entity: Option<T>,
}
#[derive(Debug)]
pub enum Error<T> {
Reqwest(reqwest::Error),
Serde(serde_json::Error),
Io(std::io::Error),
ResponseError(ResponseContent<T>),
}
impl<T> fmt::Display for Error<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let (module, e) = match self {
Error::Reqwest(e) => ("reqwest", e.to_string()),
Error::Serde(e) => ("serde", e.to_string()),
Error::Io(e) => ("IO", e.to_string()),
Error::ResponseError(e) => ("response", format!("status code {}", e.status)),
};
write!(f, "error in {}: {}", module, e)
}
}
impl<T: fmt::Debug> error::Error for Error<T> {
fn source(&self) -> Option<&(dyn error::Error + 'static)> {
Some(match self {
Error::Reqwest(e) => e,
Error::Serde(e) => e,
Error::Io(e) => e,
Error::ResponseError(_) => return None,
})
}
}
impl<T> From<reqwest::Error> for Error<T> {
fn from(e: reqwest::Error) -> Self {
Error::Reqwest(e)
}
}
impl<T> From<serde_json::Error> for Error<T> {
fn from(e: serde_json::Error) -> Self {
Error::Serde(e)
}
}
impl<T> From<std::io::Error> for Error<T> {
fn from(e: std::io::Error) -> Self {
Error::Io(e)
}
}
pub fn urlencode<T: AsRef<str>>(s: T) -> String {
::url::form_urlencoded::byte_serialize(s.as_ref().as_bytes()).collect()
}
pub fn parse_deep_object(prefix: &str, value: &serde_json::Value) -> Vec<(String, String)> {
if let serde_json::Value::Object(object) = value {
let mut params = vec![];
for (key, value) in object {
match value {
serde_json::Value::Object(_) => params.append(&mut parse_deep_object(
&format!("{}[{}]", prefix, key),
value,
)),
serde_json::Value::Array(array) => {
for (i, value) in array.iter().enumerate() {
params.append(&mut parse_deep_object(
&format!("{}[{}][{}]", prefix, key, i),
value,
));
}
}
serde_json::Value::String(s) => {
params.push((format!("{}[{}]", prefix, key), s.clone()))
}
_ => params.push((format!("{}[{}]", prefix, key), value.to_string())),
}
}
return params;
}
unimplemented!("Only objects are supported with style=deepObject")
}
/// Internal use only
/// A content type supported by this client.
#[allow(dead_code)]
enum ContentType {
Json,
Text,
Unsupported(String),
}
impl From<&str> for ContentType {
fn from(content_type: &str) -> Self {
if content_type.starts_with("application") && content_type.contains("json") {
return Self::Json;
} else if content_type.starts_with("text/plain") {
return Self::Text;
} else {
return Self::Unsupported(content_type.to_string());
}
}
}
pub mod nginx_agent_api;
pub mod configuration;
use std::sync::Arc;
pub trait Api {
fn nginx_agent_api(&self) -> &dyn nginx_agent_api::NginxAgentApi;
}
pub struct ApiClient {
nginx_agent_api: Box<dyn nginx_agent_api::NginxAgentApi>,
}
impl ApiClient {
pub fn new(configuration: Arc<configuration::Configuration>) -> Self {
Self {
nginx_agent_api: Box::new(nginx_agent_api::NginxAgentApiClient::new(
configuration.clone(),
)),
}
}
}
impl Api for ApiClient {
fn nginx_agent_api(&self) -> &dyn nginx_agent_api::NginxAgentApi {
self.nginx_agent_api.as_ref()
}
}
#[cfg(feature = "mockall")]
pub struct MockApiClient {
pub nginx_agent_api_mock: nginx_agent_api::MockNginxAgentApi,
}
#[cfg(feature = "mockall")]
impl MockApiClient {
pub fn new() -> Self {
Self {
nginx_agent_api_mock: nginx_agent_api::MockNginxAgentApi::new(),
}
}
}
#[cfg(feature = "mockall")]
impl Api for MockApiClient {
fn nginx_agent_api(&self) -> &dyn nginx_agent_api::NginxAgentApi {
&self.nginx_agent_api_mock
}
}

View File

@@ -0,0 +1,329 @@
/*
* yanpm-agent
*
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 0.1.0
*
* Generated by: https://openapi-generator.tech
*/
use super::{configuration, Error};
use crate::apis::ContentType;
use crate::{apis::ResponseContent, models};
use async_trait::async_trait;
#[cfg(feature = "mockall")]
use mockall::automock;
use reqwest;
use serde::{de::Error as _, Deserialize, Serialize};
use std::sync::Arc;
#[cfg_attr(feature = "mockall", automock)]
#[async_trait]
pub trait NginxAgentApi: Send + Sync {
/// GET /status
///
///
async fn status(&self) -> Result<ResponseContent<StatusSuccess>, Error<StatusError>>;
/// POST /validate
///
///
async fn validate(
&self,
params: ValidateParams,
) -> Result<ResponseContent<ValidateSuccess>, Error<ValidateError>>;
/// POST /validate_and_reload
///
///
async fn validate_and_reload(
&self,
params: ValidateAndReloadParams,
) -> Result<ResponseContent<ValidateAndReloadSuccess>, Error<ValidateAndReloadError>>;
/// POST /write_config
///
///
async fn write_config(
&self,
params: WriteConfigParams,
) -> Result<ResponseContent<WriteConfigSuccess>, Error<WriteConfigError>>;
}
pub struct NginxAgentApiClient {
configuration: Arc<configuration::Configuration>,
}
impl NginxAgentApiClient {
pub fn new(configuration: Arc<configuration::Configuration>) -> Self {
Self { configuration }
}
}
/// struct for passing parameters to the method [`NginxAgentApi::validate`]
#[derive(Clone, Debug)]
pub struct ValidateParams {
pub validate_body: models::ValidateBody,
}
/// struct for passing parameters to the method [`NginxAgentApi::validate_and_reload`]
#[derive(Clone, Debug)]
pub struct ValidateAndReloadParams {
pub validate_and_reload_body: models::ValidateAndReloadBody,
}
/// struct for passing parameters to the method [`NginxAgentApi::write_config`]
#[derive(Clone, Debug)]
pub struct WriteConfigParams {
pub write_config_body: models::WriteConfigBody,
}
#[async_trait]
impl NginxAgentApi for NginxAgentApiClient {
async fn status(&self) -> Result<ResponseContent<StatusSuccess>, Error<StatusError>> {
let local_var_configuration = &self.configuration;
let local_var_client = &local_var_configuration.client;
let local_var_uri_str = format!("{}/status", local_var_configuration.base_path);
let mut local_var_req_builder =
local_var_client.request(reqwest::Method::GET, local_var_uri_str.as_str());
if let Some(ref local_var_user_agent) = local_var_configuration.user_agent {
local_var_req_builder = local_var_req_builder
.header(reqwest::header::USER_AGENT, local_var_user_agent.clone());
}
let local_var_req = local_var_req_builder.build()?;
let local_var_resp = local_var_client.execute(local_var_req).await?;
let local_var_status = local_var_resp.status();
let local_var_content = local_var_resp.text().await?;
if !local_var_status.is_client_error() && !local_var_status.is_server_error() {
let local_var_entity: Option<StatusSuccess> =
serde_json::from_str(&local_var_content).ok();
let local_var_result = ResponseContent {
status: local_var_status,
content: local_var_content,
entity: local_var_entity,
};
Ok(local_var_result)
} else {
let local_var_entity: Option<StatusError> =
serde_json::from_str(&local_var_content).ok();
let local_var_error = ResponseContent {
status: local_var_status,
content: local_var_content,
entity: local_var_entity,
};
Err(Error::ResponseError(local_var_error))
}
}
async fn validate(
&self,
params: ValidateParams,
) -> Result<ResponseContent<ValidateSuccess>, Error<ValidateError>> {
let ValidateParams { validate_body } = params;
let local_var_configuration = &self.configuration;
let local_var_client = &local_var_configuration.client;
let local_var_uri_str = format!("{}/validate", local_var_configuration.base_path);
let mut local_var_req_builder =
local_var_client.request(reqwest::Method::POST, local_var_uri_str.as_str());
if let Some(ref local_var_user_agent) = local_var_configuration.user_agent {
local_var_req_builder = local_var_req_builder
.header(reqwest::header::USER_AGENT, local_var_user_agent.clone());
}
local_var_req_builder = local_var_req_builder.json(&validate_body);
let local_var_req = local_var_req_builder.build()?;
let local_var_resp = local_var_client.execute(local_var_req).await?;
let local_var_status = local_var_resp.status();
let local_var_content = local_var_resp.text().await?;
if !local_var_status.is_client_error() && !local_var_status.is_server_error() {
let local_var_entity: Option<ValidateSuccess> =
serde_json::from_str(&local_var_content).ok();
let local_var_result = ResponseContent {
status: local_var_status,
content: local_var_content,
entity: local_var_entity,
};
Ok(local_var_result)
} else {
let local_var_entity: Option<ValidateError> =
serde_json::from_str(&local_var_content).ok();
let local_var_error = ResponseContent {
status: local_var_status,
content: local_var_content,
entity: local_var_entity,
};
Err(Error::ResponseError(local_var_error))
}
}
async fn validate_and_reload(
&self,
params: ValidateAndReloadParams,
) -> Result<ResponseContent<ValidateAndReloadSuccess>, Error<ValidateAndReloadError>> {
let ValidateAndReloadParams {
validate_and_reload_body,
} = params;
let local_var_configuration = &self.configuration;
let local_var_client = &local_var_configuration.client;
let local_var_uri_str =
format!("{}/validate_and_reload", local_var_configuration.base_path);
let mut local_var_req_builder =
local_var_client.request(reqwest::Method::POST, local_var_uri_str.as_str());
if let Some(ref local_var_user_agent) = local_var_configuration.user_agent {
local_var_req_builder = local_var_req_builder
.header(reqwest::header::USER_AGENT, local_var_user_agent.clone());
}
local_var_req_builder = local_var_req_builder.json(&validate_and_reload_body);
let local_var_req = local_var_req_builder.build()?;
let local_var_resp = local_var_client.execute(local_var_req).await?;
let local_var_status = local_var_resp.status();
let local_var_content = local_var_resp.text().await?;
if !local_var_status.is_client_error() && !local_var_status.is_server_error() {
let local_var_entity: Option<ValidateAndReloadSuccess> =
serde_json::from_str(&local_var_content).ok();
let local_var_result = ResponseContent {
status: local_var_status,
content: local_var_content,
entity: local_var_entity,
};
Ok(local_var_result)
} else {
let local_var_entity: Option<ValidateAndReloadError> =
serde_json::from_str(&local_var_content).ok();
let local_var_error = ResponseContent {
status: local_var_status,
content: local_var_content,
entity: local_var_entity,
};
Err(Error::ResponseError(local_var_error))
}
}
async fn write_config(
&self,
params: WriteConfigParams,
) -> Result<ResponseContent<WriteConfigSuccess>, Error<WriteConfigError>> {
let WriteConfigParams { write_config_body } = params;
let local_var_configuration = &self.configuration;
let local_var_client = &local_var_configuration.client;
let local_var_uri_str = format!("{}/write_config", local_var_configuration.base_path);
let mut local_var_req_builder =
local_var_client.request(reqwest::Method::POST, local_var_uri_str.as_str());
if let Some(ref local_var_user_agent) = local_var_configuration.user_agent {
local_var_req_builder = local_var_req_builder
.header(reqwest::header::USER_AGENT, local_var_user_agent.clone());
}
local_var_req_builder = local_var_req_builder.json(&write_config_body);
let local_var_req = local_var_req_builder.build()?;
let local_var_resp = local_var_client.execute(local_var_req).await?;
let local_var_status = local_var_resp.status();
let local_var_content = local_var_resp.text().await?;
if !local_var_status.is_client_error() && !local_var_status.is_server_error() {
let local_var_entity: Option<WriteConfigSuccess> =
serde_json::from_str(&local_var_content).ok();
let local_var_result = ResponseContent {
status: local_var_status,
content: local_var_content,
entity: local_var_entity,
};
Ok(local_var_result)
} else {
let local_var_entity: Option<WriteConfigError> =
serde_json::from_str(&local_var_content).ok();
let local_var_error = ResponseContent {
status: local_var_status,
content: local_var_content,
entity: local_var_entity,
};
Err(Error::ResponseError(local_var_error))
}
}
}
/// struct for typed successes of method [`NginxAgentApi::status`]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum StatusSuccess {
Status200(models::StatusResp),
UnknownValue(serde_json::Value),
}
/// struct for typed successes of method [`NginxAgentApi::validate`]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ValidateSuccess {
Status200(serde_json::Value),
UnknownValue(serde_json::Value),
}
/// struct for typed successes of method [`NginxAgentApi::validate_and_reload`]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ValidateAndReloadSuccess {
Status200(models::ValidateAndReloadResp),
UnknownValue(serde_json::Value),
}
/// struct for typed successes of method [`NginxAgentApi::write_config`]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum WriteConfigSuccess {
Status200(),
UnknownValue(serde_json::Value),
}
/// struct for typed errors of method [`NginxAgentApi::status`]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum StatusError {
UnknownValue(serde_json::Value),
}
/// struct for typed errors of method [`NginxAgentApi::validate`]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ValidateError {
UnknownValue(serde_json::Value),
}
/// struct for typed errors of method [`NginxAgentApi::validate_and_reload`]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ValidateAndReloadError {
UnknownValue(serde_json::Value),
}
/// struct for typed errors of method [`NginxAgentApi::write_config`]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum WriteConfigError {
Status500(serde_json::Value),
UnknownValue(serde_json::Value),
}

View File

@@ -0,0 +1,280 @@
/*
* yanpm-agent
*
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 0.1.0
*
* Generated by: https://openapi-generator.tech
*/
use async_trait::async_trait;
#[cfg(feature = "mockall")]
use mockall::automock;
use reqwest;
use std::sync::Arc;
use serde::{Deserialize, Serialize, de::Error as _};
use crate::{apis::ResponseContent, models};
use super::{Error, configuration};
use crate::apis::ContentType;
#[cfg_attr(feature = "mockall", automock)]
#[async_trait]
pub trait TagNginxtagApi: Send + Sync {
/// GET /status
///
///
async fn status(&self, ) -> Result<ResponseContent<StatusSuccess>, Error<StatusError>>;
/// POST /validate
///
///
async fn validate(&self, params: ValidateParams ) -> Result<ResponseContent<ValidateSuccess>, Error<ValidateError>>;
/// POST /validate_and_reload
///
///
async fn validate_and_reload(&self, params: ValidateAndReloadParams ) -> Result<ResponseContent<ValidateAndReloadSuccess>, Error<ValidateAndReloadError>>;
/// POST /write_config
///
///
async fn write_config(&self, params: WriteConfigParams ) -> Result<ResponseContent<WriteConfigSuccess>, Error<WriteConfigError>>;
}
pub struct TagNginxtagApiClient {
configuration: Arc<configuration::Configuration>
}
impl TagNginxtagApiClient {
pub fn new(configuration: Arc<configuration::Configuration>) -> Self {
Self { configuration }
}
}
/// struct for passing parameters to the method [`TagNginxtagApi::validate`]
#[derive(Clone, Debug)]
pub struct ValidateParams {
pub validate_body: models::ValidateBody
}
/// struct for passing parameters to the method [`TagNginxtagApi::validate_and_reload`]
#[derive(Clone, Debug)]
pub struct ValidateAndReloadParams {
pub validate_and_reload_body: models::ValidateAndReloadBody
}
/// struct for passing parameters to the method [`TagNginxtagApi::write_config`]
#[derive(Clone, Debug)]
pub struct WriteConfigParams {
pub write_config_body: models::WriteConfigBody
}
#[async_trait]
impl TagNginxtagApi for TagNginxtagApiClient {
async fn status(&self, ) -> Result<ResponseContent<StatusSuccess>, Error<StatusError>> {
let local_var_configuration = &self.configuration;
let local_var_client = &local_var_configuration.client;
let local_var_uri_str = format!("{}/status", local_var_configuration.base_path);
let mut local_var_req_builder = local_var_client.request(reqwest::Method::GET, local_var_uri_str.as_str());
if let Some(ref local_var_user_agent) = local_var_configuration.user_agent {
local_var_req_builder = local_var_req_builder.header(reqwest::header::USER_AGENT, local_var_user_agent.clone());
}
let local_var_req = local_var_req_builder.build()?;
let local_var_resp = local_var_client.execute(local_var_req).await?;
let local_var_status = local_var_resp.status();
let local_var_content = local_var_resp.text().await?;
if !local_var_status.is_client_error() && !local_var_status.is_server_error() {
let local_var_entity: Option<StatusSuccess> = serde_json::from_str(&local_var_content).ok();
let local_var_result = ResponseContent { status: local_var_status, content: local_var_content, entity: local_var_entity };
Ok(local_var_result)
} else {
let local_var_entity: Option<StatusError> = serde_json::from_str(&local_var_content).ok();
let local_var_error = ResponseContent { status: local_var_status, content: local_var_content, entity: local_var_entity };
Err(Error::ResponseError(local_var_error))
}
}
async fn validate(&self, params: ValidateParams ) -> Result<ResponseContent<ValidateSuccess>, Error<ValidateError>> {
let ValidateParams {
validate_body,
} = params;
let local_var_configuration = &self.configuration;
let local_var_client = &local_var_configuration.client;
let local_var_uri_str = format!("{}/validate", local_var_configuration.base_path);
let mut local_var_req_builder = local_var_client.request(reqwest::Method::POST, local_var_uri_str.as_str());
if let Some(ref local_var_user_agent) = local_var_configuration.user_agent {
local_var_req_builder = local_var_req_builder.header(reqwest::header::USER_AGENT, local_var_user_agent.clone());
}
local_var_req_builder = local_var_req_builder.json(&validate_body);
let local_var_req = local_var_req_builder.build()?;
let local_var_resp = local_var_client.execute(local_var_req).await?;
let local_var_status = local_var_resp.status();
let local_var_content = local_var_resp.text().await?;
if !local_var_status.is_client_error() && !local_var_status.is_server_error() {
let local_var_entity: Option<ValidateSuccess> = serde_json::from_str(&local_var_content).ok();
let local_var_result = ResponseContent { status: local_var_status, content: local_var_content, entity: local_var_entity };
Ok(local_var_result)
} else {
let local_var_entity: Option<ValidateError> = serde_json::from_str(&local_var_content).ok();
let local_var_error = ResponseContent { status: local_var_status, content: local_var_content, entity: local_var_entity };
Err(Error::ResponseError(local_var_error))
}
}
async fn validate_and_reload(&self, params: ValidateAndReloadParams ) -> Result<ResponseContent<ValidateAndReloadSuccess>, Error<ValidateAndReloadError>> {
let ValidateAndReloadParams {
validate_and_reload_body,
} = params;
let local_var_configuration = &self.configuration;
let local_var_client = &local_var_configuration.client;
let local_var_uri_str = format!("{}/validate_and_reload", local_var_configuration.base_path);
let mut local_var_req_builder = local_var_client.request(reqwest::Method::POST, local_var_uri_str.as_str());
if let Some(ref local_var_user_agent) = local_var_configuration.user_agent {
local_var_req_builder = local_var_req_builder.header(reqwest::header::USER_AGENT, local_var_user_agent.clone());
}
local_var_req_builder = local_var_req_builder.json(&validate_and_reload_body);
let local_var_req = local_var_req_builder.build()?;
let local_var_resp = local_var_client.execute(local_var_req).await?;
let local_var_status = local_var_resp.status();
let local_var_content = local_var_resp.text().await?;
if !local_var_status.is_client_error() && !local_var_status.is_server_error() {
let local_var_entity: Option<ValidateAndReloadSuccess> = serde_json::from_str(&local_var_content).ok();
let local_var_result = ResponseContent { status: local_var_status, content: local_var_content, entity: local_var_entity };
Ok(local_var_result)
} else {
let local_var_entity: Option<ValidateAndReloadError> = serde_json::from_str(&local_var_content).ok();
let local_var_error = ResponseContent { status: local_var_status, content: local_var_content, entity: local_var_entity };
Err(Error::ResponseError(local_var_error))
}
}
async fn write_config(&self, params: WriteConfigParams ) -> Result<ResponseContent<WriteConfigSuccess>, Error<WriteConfigError>> {
let WriteConfigParams {
write_config_body,
} = params;
let local_var_configuration = &self.configuration;
let local_var_client = &local_var_configuration.client;
let local_var_uri_str = format!("{}/write_config", local_var_configuration.base_path);
let mut local_var_req_builder = local_var_client.request(reqwest::Method::POST, local_var_uri_str.as_str());
if let Some(ref local_var_user_agent) = local_var_configuration.user_agent {
local_var_req_builder = local_var_req_builder.header(reqwest::header::USER_AGENT, local_var_user_agent.clone());
}
local_var_req_builder = local_var_req_builder.json(&write_config_body);
let local_var_req = local_var_req_builder.build()?;
let local_var_resp = local_var_client.execute(local_var_req).await?;
let local_var_status = local_var_resp.status();
let local_var_content = local_var_resp.text().await?;
if !local_var_status.is_client_error() && !local_var_status.is_server_error() {
let local_var_entity: Option<WriteConfigSuccess> = serde_json::from_str(&local_var_content).ok();
let local_var_result = ResponseContent { status: local_var_status, content: local_var_content, entity: local_var_entity };
Ok(local_var_result)
} else {
let local_var_entity: Option<WriteConfigError> = serde_json::from_str(&local_var_content).ok();
let local_var_error = ResponseContent { status: local_var_status, content: local_var_content, entity: local_var_entity };
Err(Error::ResponseError(local_var_error))
}
}
}
/// struct for typed successes of method [`TagNginxtagApi::status`]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum StatusSuccess {
Status200(models::StatusResp),
UnknownValue(serde_json::Value),
}
/// struct for typed successes of method [`TagNginxtagApi::validate`]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ValidateSuccess {
Status200(serde_json::Value),
UnknownValue(serde_json::Value),
}
/// struct for typed successes of method [`TagNginxtagApi::validate_and_reload`]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ValidateAndReloadSuccess {
Status200(models::ValidateAndReloadResp),
UnknownValue(serde_json::Value),
}
/// struct for typed successes of method [`TagNginxtagApi::write_config`]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum WriteConfigSuccess {
Status200(),
UnknownValue(serde_json::Value),
}
/// struct for typed errors of method [`TagNginxtagApi::status`]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum StatusError {
UnknownValue(serde_json::Value),
}
/// struct for typed errors of method [`TagNginxtagApi::validate`]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ValidateError {
UnknownValue(serde_json::Value),
}
/// struct for typed errors of method [`TagNginxtagApi::validate_and_reload`]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ValidateAndReloadError {
UnknownValue(serde_json::Value),
}
/// struct for typed errors of method [`TagNginxtagApi::write_config`]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum WriteConfigError {
Status500(serde_json::Value),
UnknownValue(serde_json::Value),
}

View File

@@ -0,0 +1,10 @@
#![allow(unused_imports)]
#![allow(clippy::too_many_arguments)]
extern crate serde;
extern crate serde_json;
extern crate serde_repr;
extern crate url;
pub mod apis;
pub mod models;

View File

@@ -0,0 +1,10 @@
pub mod status_resp;
pub use self::status_resp::StatusResp;
pub mod validate_and_reload_body;
pub use self::validate_and_reload_body::ValidateAndReloadBody;
pub mod validate_and_reload_resp;
pub use self::validate_and_reload_resp::ValidateAndReloadResp;
pub mod validate_body;
pub use self::validate_body::ValidateBody;
pub mod write_config_body;
pub use self::write_config_body::WriteConfigBody;

View File

@@ -0,0 +1,24 @@
/*
* yanpm-agent
*
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 0.1.0
*
* Generated by: https://openapi-generator.tech
*/
use crate::models;
use serde::{Deserialize, Serialize};
#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct StatusResp {
#[serde(rename = "ok")]
pub ok: bool,
}
impl StatusResp {
pub fn new(ok: bool) -> StatusResp {
StatusResp { ok }
}
}

View File

@@ -0,0 +1,29 @@
/*
* yanpm-agent
*
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 0.1.0
*
* Generated by: https://openapi-generator.tech
*/
use crate::models;
use serde::{Deserialize, Serialize};
#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct ValidateAndReloadBody {
#[serde(rename = "config_name")]
pub config_name: String,
#[serde(rename = "timestamp")]
pub timestamp: i64,
}
impl ValidateAndReloadBody {
pub fn new(config_name: String, timestamp: i64) -> ValidateAndReloadBody {
ValidateAndReloadBody {
config_name,
timestamp,
}
}
}

View File

@@ -0,0 +1,26 @@
/*
* yanpm-agent
*
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 0.1.0
*
* Generated by: https://openapi-generator.tech
*/
use crate::models;
use serde::{Deserialize, Serialize};
#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct ValidateAndReloadResp {
#[serde(rename = "rc")]
pub rc: i32,
#[serde(rename = "ro")]
pub ro: String,
}
impl ValidateAndReloadResp {
pub fn new(rc: i32, ro: String) -> ValidateAndReloadResp {
ValidateAndReloadResp { rc, ro }
}
}

View File

@@ -0,0 +1,29 @@
/*
* yanpm-agent
*
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 0.1.0
*
* Generated by: https://openapi-generator.tech
*/
use crate::models;
use serde::{Deserialize, Serialize};
#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct ValidateBody {
#[serde(rename = "config_name")]
pub config_name: String,
#[serde(rename = "timestamp")]
pub timestamp: i64,
}
impl ValidateBody {
pub fn new(config_name: String, timestamp: i64) -> ValidateBody {
ValidateBody {
config_name,
timestamp,
}
}
}

View File

@@ -0,0 +1,32 @@
/*
* yanpm-agent
*
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 0.1.0
*
* Generated by: https://openapi-generator.tech
*/
use crate::models;
use serde::{Deserialize, Serialize};
#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct WriteConfigBody {
#[serde(rename = "config_name")]
pub config_name: String,
#[serde(rename = "content")]
pub content: String,
#[serde(rename = "timestamp")]
pub timestamp: i64,
}
impl WriteConfigBody {
pub fn new(config_name: String, content: String, timestamp: i64) -> WriteConfigBody {
WriteConfigBody {
config_name,
content,
timestamp,
}
}
}

View 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 {}

View 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 {}

View File

@@ -3,5 +3,9 @@
pub mod prelude; pub mod prelude;
pub mod config; pub mod config;
pub mod edit_operation;
pub mod editing_session;
pub mod upstream;
pub mod upstream_target;
pub mod user; pub mod user;
pub mod user_identity; pub mod user_identity;

View File

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

View 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 {}

View File

@@ -5,25 +5,26 @@ use serde::{Deserialize, Serialize};
#[sea_orm::model] #[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "session")] #[sea_orm(table_name = "upstream_target")]
pub struct Model { pub struct Model {
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid, pub id: Uuid,
pub user_id: Uuid, pub upstream_id: Uuid,
#[sea_orm(unique)] pub target_host: String,
pub refresh_token_hash: Option<String>, pub target_port: i64,
pub expires_at: DateTimeUtc, pub weight: i64,
pub revoked_at: Option<DateTimeUtc>, pub is_backup: bool,
pub enabled: bool,
pub created_at: DateTimeUtc, pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc, pub updated_at: DateTimeUtc,
#[sea_orm( #[sea_orm(
belongs_to, belongs_to,
from = "user_id", from = "upstream_id",
to = "id", to = "id",
on_update = "Cascade", on_update = "Cascade",
on_delete = "Cascade" on_delete = "Cascade"
)] )]
pub user: HasOne<super::user::Entity>, pub upstream: HasOne<super::upstream::Entity>,
} }
impl ActiveModelBehavior for ActiveModel {} impl ActiveModelBehavior for ActiveModel {}

View 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;
}
```

View File

@@ -13,6 +13,9 @@ impl MigratorTrait for Migrator {
Box::new(m20251011_000001_create_config_table::Migration), Box::new(m20251011_000001_create_config_table::Migration),
Box::new(m20251011_000002_create_user_table::Migration), Box::new(m20251011_000002_create_user_table::Migration),
Box::new(m20251011_000003_create_user_identity_table::Migration), Box::new(m20251011_000003_create_user_identity_table::Migration),
Box::new(m20251223_000004_create_upstream_table::Migration),
Box::new(m20251223_000005_create_upstream_target_table::Migration),
Box::new(m20251230_000006_create_editing_session_table::Migration),
] ]
} }
} }

View File

@@ -1,3 +1,6 @@
pub mod m20251011_000001_create_config_table; pub mod m20251011_000001_create_config_table;
pub mod m20251011_000002_create_user_table; pub mod m20251011_000002_create_user_table;
pub mod m20251011_000003_create_user_identity_table; pub mod m20251011_000003_create_user_identity_table;
pub mod m20251223_000004_create_upstream_table;
pub mod m20251223_000005_create_upstream_target_table;
pub mod m20251230_000006_create_editing_session_table;

View File

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

View File

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

View File

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