Compare commits
71 Commits
feature/ag
...
1efc4ffc5a
| Author | SHA1 | Date | |
|---|---|---|---|
| 1efc4ffc5a | |||
| 3216cbbd5e | |||
| 7f0040b668 | |||
| d67a9f6f0d | |||
|
|
4fe03b245e | ||
|
|
b4a36dbe4c | ||
|
|
7ae76f622c | ||
|
|
9b8232d94d | ||
|
|
83e02acb22 | ||
|
|
eb1afc87cc | ||
|
|
1c0053207c | ||
|
|
ab840126b3 | ||
| 1ed065e08e | |||
|
|
d21459802c | ||
|
|
5e1a8364c7 | ||
|
|
3be9ecc4c1 | ||
|
|
545bc66f8c | ||
|
|
75097a661b | ||
|
|
9860dddf60 | ||
|
|
c4634b18f9 | ||
|
|
a0b4df745e | ||
|
|
46801fba99 | ||
|
|
cb65d4e9f7 | ||
|
|
10cc8f9d97 | ||
|
|
d184261027 | ||
|
|
6a30a03e59 | ||
|
|
9c3f775a67 | ||
|
|
331b4e1e96 | ||
|
|
4f85d88380 | ||
|
|
d81e5fe48d | ||
|
|
dff560019f | ||
|
|
b2a322ed79 | ||
|
|
f05544267c | ||
|
|
f4db47daf2 | ||
|
|
b43f9fcb00 | ||
|
|
1b251fd89f | ||
|
|
dd79cbe0bb | ||
|
|
37f4163870 | ||
|
|
481abd7f26 | ||
|
|
7a557d6e00 | ||
|
|
0cbc223b4e | ||
|
|
fdfd1c98e0 | ||
|
|
abeea4fad7 | ||
|
|
fea205cba8 | ||
|
|
3cc6b40e61 | ||
|
|
41023939db | ||
|
|
6eb0d9060b | ||
|
|
7d83838da3 | ||
|
|
921165476c | ||
|
|
a0621e2407 | ||
|
|
f8b13be650 | ||
|
|
238c3db92b | ||
|
|
814f76291c | ||
|
|
7ac3368715 | ||
|
|
35fadb46f6 | ||
|
|
91d0e1cd7c | ||
| c33e3aa0ca | |||
|
|
6f5596dc69 | ||
|
|
96e7f36731 | ||
|
|
410328a2af | ||
|
|
9f122566d0 | ||
|
|
c65dc3af47 | ||
|
|
6f395ed1ae | ||
|
|
e6e85564e7 | ||
|
|
c7a090f78a | ||
|
|
8cc2775fe4 | ||
|
|
0b64538754 | ||
|
|
8009ca20ff | ||
|
|
7a5b9471e0 | ||
|
|
bb55e37b49 | ||
| 9a264a61ac |
80
.devcontainer/devcontainer.json
Normal file
80
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,80 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/rust
|
||||
{
|
||||
"name": "YANPM",
|
||||
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
|
||||
"image": "mcr.microsoft.com/devcontainers/rust:2-1-trixie",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/java:1": {
|
||||
"version": "latest",
|
||||
"jdkDistro": "open",
|
||||
"gradleVersion": "latest",
|
||||
"mavenVersion": "latest",
|
||||
"antVersion": "latest",
|
||||
"groovyVersion": "latest"
|
||||
},
|
||||
"ghcr.io/dhoeric/features/act:1": {},
|
||||
"ghcr.io/guiyomh/features/just:0": {
|
||||
"version": "latest"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"nodeGypDependencies": true,
|
||||
"version": "22",
|
||||
"pnpmVersion": "latest",
|
||||
"nvmVersion": "latest"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
||||
"moby": false,
|
||||
"azureDnsAutoDetection": true,
|
||||
"installDockerBuildx": true,
|
||||
"installDockerComposeSwitch": true,
|
||||
"disableIp6tables": true,
|
||||
"version": "latest",
|
||||
"dockerDashComposeVersion": "v2"
|
||||
}
|
||||
},
|
||||
|
||||
// Use 'mounts' to make the cargo cache persistent in a Docker Volume.
|
||||
// "mounts": [
|
||||
// {
|
||||
// "source": "devcontainer-cargo-cache-${devcontainerId}",
|
||||
// "target": "/usr/local/cargo",
|
||||
// "type": "volume"
|
||||
// }
|
||||
// ]
|
||||
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {},
|
||||
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// "forwardPorts": [],
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
"postCreateCommand": "bash .devcontainer/start.sh",
|
||||
|
||||
// Configure tool-specific properties.
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"mikestead.dotenv",
|
||||
"usernamehw.errorlens",
|
||||
"streetsidesoftware.code-spell-checker",
|
||||
"mhutchie.git-graph",
|
||||
"yzhang.markdown-all-in-one",
|
||||
"christian-kohler.npm-intellisense",
|
||||
"42Crunch.vscode-openapi",
|
||||
"christian-kohler.path-intellisense",
|
||||
"esbenp.prettier-vscode",
|
||||
"rust-lang.rust-analyzer",
|
||||
"nefrob.vscode-just-syntax",
|
||||
"redhat.vscode-yaml",
|
||||
// optional
|
||||
"SirTori.indenticator",
|
||||
"oderwat.indent-rainbow"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
// "remoteUser": "root"
|
||||
}
|
||||
19
.devcontainer/start.sh
Normal file
19
.devcontainer/start.sh
Normal file
@@ -0,0 +1,19 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
echo "Starting devcontainer setup..."
|
||||
|
||||
echo "Installing dependencies..."
|
||||
# install dependencies
|
||||
pnpm install --frozen-lockfile
|
||||
cargo install sea-orm-cli
|
||||
|
||||
echo "building agent image..."
|
||||
# build agent image
|
||||
cd apps/agent
|
||||
just build-docker
|
||||
cd -
|
||||
echo "Agent image built."
|
||||
|
||||
echo "Devcontainer setup complete."
|
||||
17
.github/workflows/test.yml
vendored
17
.github/workflows/test.yml
vendored
@@ -12,11 +12,8 @@ on:
|
||||
|
||||
|
||||
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
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -39,7 +36,7 @@ jobs:
|
||||
- name: Run tests
|
||||
run: cargo test --all-features
|
||||
|
||||
lint:
|
||||
lint-crates:
|
||||
needs: frontend-build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -83,13 +80,13 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: apps/frontend/pnpm-lock.yaml
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: |
|
||||
cd apps/frontend
|
||||
pnpm install
|
||||
|
||||
|
||||
- name: Run frontend linter
|
||||
run: |
|
||||
cd apps/frontend
|
||||
@@ -114,7 +111,7 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: apps/frontend/pnpm-lock.yaml
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: |
|
||||
@@ -142,12 +139,12 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: apps/frontend/pnpm-lock.yaml
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: |
|
||||
cd apps/frontend
|
||||
pnpm install
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build frontend
|
||||
run: |
|
||||
|
||||
79
.github/workflows/verify.yml
vendored
79
.github/workflows/verify.yml
vendored
@@ -11,18 +11,15 @@ on:
|
||||
|
||||
|
||||
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
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
- name: Setup Rust, checkout and restore caches
|
||||
uses: ./.github/actions/setup-rust
|
||||
|
||||
@@ -32,14 +29,62 @@ jobs:
|
||||
cargo run -- db:migrate_and_generate --output-path ../../public/database/src/generated/entities
|
||||
- name: Check for uncommitted changes in /generated/
|
||||
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."
|
||||
git status --porcelain | grep '^ M .*\/generated\/'
|
||||
git status --porcelain --untracked-files=all | grep '/generated/'
|
||||
exit 1
|
||||
else
|
||||
echo "Generated code is up to date."
|
||||
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:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -83,7 +128,7 @@ jobs:
|
||||
- name: Setup Rust, checkout and restore caches
|
||||
if: steps.check_changes.outputs.changed == 'true'
|
||||
uses: ./.github/actions/setup-rust
|
||||
|
||||
|
||||
- name: Generate dummy frontend build (to satisfy dependencies)
|
||||
if: steps.check_changes.outputs.changed == 'true'
|
||||
run: |
|
||||
@@ -95,13 +140,13 @@ jobs:
|
||||
run: |
|
||||
cd apps/api
|
||||
cargo run -- generate:openapi --output-path ./swagger.json
|
||||
|
||||
|
||||
- name: Check for uncommitted changes in swagger.json
|
||||
if: steps.check_changes.outputs.changed == 'true'
|
||||
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."
|
||||
git status --porcelain | grep '^ M apps/api/swagger.json'
|
||||
git status --porcelain --untracked-files=all | grep 'apps/api/swagger.json'
|
||||
exit 1
|
||||
else
|
||||
echo "OpenAPI spec is up to date."
|
||||
@@ -165,14 +210,14 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: apps/frontend/pnpm-lock.yaml
|
||||
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
- name: Install frontend dependencies
|
||||
if: steps.check_swagger_changes.outputs.changed == 'true'
|
||||
run: |
|
||||
cd apps/frontend
|
||||
pnpm install
|
||||
|
||||
|
||||
- name: Generate frontend API client
|
||||
if: steps.check_swagger_changes.outputs.changed == 'true'
|
||||
run: |
|
||||
@@ -182,14 +227,14 @@ jobs:
|
||||
- name: Check for uncommitted changes in frontend API client
|
||||
if: steps.check_swagger_changes.outputs.changed == 'true'
|
||||
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."
|
||||
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
|
||||
else
|
||||
echo "Frontend API client is up to date."
|
||||
fi
|
||||
|
||||
|
||||
- name: Skip frontend API client generation (no relevant changes)
|
||||
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."
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -27,3 +27,6 @@ target
|
||||
.env.generated
|
||||
|
||||
generated-config.yaml
|
||||
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
|
||||
680
Cargo.lock
generated
680
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ members = [
|
||||
"apps/container",
|
||||
"apps/cli",
|
||||
"apps/agent",
|
||||
"public/agent-client",
|
||||
"public/shared",
|
||||
"public/database",
|
||||
"public/migration"
|
||||
|
||||
@@ -13,3 +13,4 @@ serde = { version = "1.0.228", features = ["std", "derive"] }
|
||||
tokio-cron-scheduler = { version = "0.15.1", features = ["signal"] }
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
nix = { version = "0.30.1", features = ["user", "fs"] }
|
||||
utoipa = { version = "5.4.0", features = ["macros", "axum_extras", "chrono", "decimal", "uuid", "time", "openapi_extensions"] }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod commands;
|
||||
mod openapi;
|
||||
mod routes;
|
||||
|
||||
use axum::routing::get;
|
||||
@@ -13,6 +14,7 @@ use tokio::net::UnixListener;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::commands::NginxService;
|
||||
use crate::openapi::{GenerateOpenapiArgs, generate_openapi_doc};
|
||||
use crate::routes::{status, validate, validate_and_reload, write_config};
|
||||
|
||||
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
|
||||
#[arg(long, default_value_t = String::from(SOCK_GID_DEFAULT), env = SOCK_GID_ENV)]
|
||||
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]
|
||||
@@ -59,6 +74,18 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
|
||||
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 path = PathBuf::from(&sock);
|
||||
|
||||
45
apps/agent/src/openapi.rs
Normal file
45
apps/agent/src/openapi.rs
Normal 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(())
|
||||
}
|
||||
@@ -9,28 +9,46 @@ use tracing::warn;
|
||||
|
||||
use crate::commands::NginxService;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, utoipa::ToSchema)]
|
||||
pub struct StatusResp {
|
||||
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 {
|
||||
let resp = StatusResp { ok: true };
|
||||
(axum::http::StatusCode::OK, axum::Json(resp))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, utoipa::ToSchema)]
|
||||
pub struct ValidateAndReloadResp {
|
||||
pub rc: i32,
|
||||
pub ro: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
pub struct ValidateBody {
|
||||
config_name: String,
|
||||
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(
|
||||
State(nginx_controller): State<Arc<NginxService>>,
|
||||
Json(payload): Json<Value>,
|
||||
@@ -57,12 +75,21 @@ pub async fn validate(
|
||||
(axum::http::StatusCode::OK, axum::Json(resp)).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
pub struct ValidateAndReloadBody {
|
||||
config_name: String,
|
||||
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(
|
||||
State(nginx_controller): State<Arc<NginxService>>,
|
||||
Json(payload): Json<Value>,
|
||||
@@ -96,13 +123,23 @@ pub async fn validate_and_reload(
|
||||
(axum::http::StatusCode::OK, axum::Json(resp)).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
pub struct WriteConfigBody {
|
||||
config_name: String,
|
||||
timestamp: u64,
|
||||
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(
|
||||
State(nginx_controller): State<Arc<NginxService>>,
|
||||
Json(payload): Json<Value>,
|
||||
|
||||
215
apps/agent/swagger.json
Normal file
215
apps/agent/swagger.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,6 +6,7 @@ edition = "2024"
|
||||
[dependencies]
|
||||
database = { path = "../../public/database" }
|
||||
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-extra = { version = "0.12.2", features = ["cookie"] }
|
||||
@@ -28,4 +29,14 @@ argon2 = { version = "0.5.3", features = ["std"] }
|
||||
jsonwebtoken = { version = "10.2.0", features = ["rust_crypto"] }
|
||||
uuid = { version = "1.19.0", features = ["v4", "serde", "fast-rng"] }
|
||||
tower-http = { version = "0.6.8", features = ["cors"] }
|
||||
reqwest = { version = "^0.12", features = ["json", "multipart", "stream"] }
|
||||
serde_urlencoded = { version = "0.7.1" }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
axum-test = "18.4.1"
|
||||
agent_client = { path = "../../public/agent-client", features = ["mockall"] }
|
||||
mockall = { version = "0.14.0", features = [] }
|
||||
|
||||
[lints.clippy]
|
||||
unwrap_used = "deny"
|
||||
|
||||
@@ -28,7 +28,7 @@ fn action(
|
||||
_matches: &clap::ArgMatches,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
|
||||
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 {
|
||||
tracing::subscriber::with_default(log::make_temporary_subscriber(), || {
|
||||
|
||||
@@ -11,15 +11,8 @@ use crate::{
|
||||
cmd::CliCommand,
|
||||
configs::{ProgramSettings, get_program_settings, logging::LoggingSettings},
|
||||
log,
|
||||
routes::{self, AppService, AppState},
|
||||
services::{
|
||||
auth::{
|
||||
authentication::{AuthenticationServiceImpl, strategies::password::PasswordStrategy},
|
||||
user::UserServiceImpl,
|
||||
},
|
||||
server_state::ServerStateService,
|
||||
settings::SettingsService,
|
||||
},
|
||||
routes::{self, AppState},
|
||||
services::get_app_service,
|
||||
tasks,
|
||||
};
|
||||
|
||||
@@ -148,19 +141,7 @@ fn get_app_state(
|
||||
AppState {
|
||||
database_connection: db_connection.clone(),
|
||||
config: Arc::new(settings.clone()),
|
||||
service: Arc::new(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())),
|
||||
}),
|
||||
service: Arc::new(get_app_service(db_connection, settings)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod agent;
|
||||
pub mod auth;
|
||||
pub mod database;
|
||||
pub mod logging;
|
||||
@@ -21,6 +22,7 @@ pub struct ProgramSettings {
|
||||
pub database: database::DatabaseSettings,
|
||||
pub server: server::ServerSettings,
|
||||
pub auth: auth::AuthSettings,
|
||||
pub agent: agent::AgentSettings,
|
||||
}
|
||||
|
||||
impl FromConfig for ProgramSettings {
|
||||
@@ -30,6 +32,7 @@ impl FromConfig for ProgramSettings {
|
||||
database: database::DatabaseSettings::from_config(_config)?,
|
||||
server: server::ServerSettings::from_config(_config)?,
|
||||
auth: auth::AuthSettings::from_config(_config)?,
|
||||
agent: agent::AgentSettings::from_config(_config)?,
|
||||
};
|
||||
config.validate()?;
|
||||
Ok(config)
|
||||
@@ -50,6 +53,7 @@ impl FromConfig for ProgramSettings {
|
||||
database: database::DatabaseSettings::mock(),
|
||||
server: server::ServerSettings::mock(),
|
||||
auth: auth::AuthSettings::mock(),
|
||||
agent: agent::AgentSettings::mock(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
58
apps/api/src/configs/agent.rs
Normal file
58
apps/api/src/configs/agent.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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_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 AGENT_SOCK_PATH_KEY: &str = "AGENT.SOCK.PATH";
|
||||
|
||||
@@ -121,7 +121,7 @@ impl FromConfig for ServerSettings {
|
||||
#[cfg(test)]
|
||||
fn mock() -> Self {
|
||||
ServerSettings {
|
||||
address: "0.0.0.0".parse().unwrap(),
|
||||
address: "0.0.0.0".parse().expect("Failed to parse mock IP address"),
|
||||
port: 8080,
|
||||
serve_openapi: false,
|
||||
cors: CORSSettings {
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
pub mod api_error;
|
||||
pub mod service_error;
|
||||
|
||||
32
apps/api/src/errors/api_error.rs
Normal file
32
apps/api/src/errors/api_error.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use axum::response::IntoResponse;
|
||||
use sea_orm::DbErr;
|
||||
use tracing::error;
|
||||
|
||||
use crate::errors::service_error::ServiceError;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ApiError {
|
||||
ServiceError(ServiceError),
|
||||
}
|
||||
|
||||
impl From<ServiceError> for ApiError {
|
||||
fn from(err: ServiceError) -> Self {
|
||||
error!("Service error occurred: {:?}", err);
|
||||
ApiError::ServiceError(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DbErr> for ApiError {
|
||||
fn from(err: DbErr) -> Self {
|
||||
ServiceError::from(err).into()
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
error!("API error occurred: {:?}", self);
|
||||
match self {
|
||||
ApiError::ServiceError(service_error) => service_error.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use axum::response::IntoResponse;
|
||||
use sea_orm::DbErr;
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -37,3 +38,23 @@ impl From<DbErr> for ServiceError {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ServiceError {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
let (status, message) = match &self {
|
||||
ServiceError::NotFound(msg) => (axum::http::StatusCode::NOT_FOUND, msg.clone()),
|
||||
ServiceError::DatabaseError(msg) => {
|
||||
(axum::http::StatusCode::INTERNAL_SERVER_ERROR, msg.clone())
|
||||
}
|
||||
ServiceError::Unauthorized(msg) => (axum::http::StatusCode::UNAUTHORIZED, msg.clone()),
|
||||
ServiceError::InternalError(msg) => {
|
||||
(axum::http::StatusCode::INTERNAL_SERVER_ERROR, msg.clone())
|
||||
}
|
||||
ServiceError::BadRequest(msg) => (axum::http::StatusCode::BAD_REQUEST, msg.clone()),
|
||||
};
|
||||
let body = axum::Json(serde_json::json!({
|
||||
"error": message,
|
||||
}));
|
||||
(status, body).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod constants;
|
||||
pub mod database;
|
||||
pub mod macros;
|
||||
|
||||
@@ -11,3 +11,16 @@ macro_rules! with_conn {
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
pub struct PaginationFilter {
|
||||
pub page: u64,
|
||||
pub per_page: u64,
|
||||
}
|
||||
|
||||
impl PaginationFilter {
|
||||
pub fn get_offset_limit(&self) -> (u64, u64) {
|
||||
let offset = (self.page - 1) * self.per_page;
|
||||
let limit = self.per_page;
|
||||
(offset, limit)
|
||||
}
|
||||
}
|
||||
|
||||
9
apps/api/src/helpers/macros.rs
Normal file
9
apps/api/src/helpers/macros.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
#[macro_export]
|
||||
macro_rules! set_if_some {
|
||||
($field:expr) => {
|
||||
match $field {
|
||||
Some(value) => sea_orm::ActiveValue::Set(value),
|
||||
None => sea_orm::ActiveValue::NotSet,
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -9,7 +9,7 @@ use axum::{
|
||||
http::{HeaderValue, Method, StatusCode, Uri},
|
||||
};
|
||||
use tower::{ServiceBuilder, timeout::TimeoutLayer};
|
||||
use tower_http::cors::{AllowHeaders, AllowOrigin, CorsLayer};
|
||||
use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::{configs::server::CORSSettings, routes::AppState};
|
||||
@@ -34,6 +34,7 @@ pub fn apply_root_middleware(
|
||||
pub fn get_cors_layer(cors_settings: Arc<CORSSettings>) -> CorsLayer {
|
||||
let mut cors_layer = CorsLayer::new()
|
||||
.allow_credentials(true)
|
||||
.allow_methods(AllowMethods::mirror_request())
|
||||
.allow_headers(AllowHeaders::mirror_request());
|
||||
|
||||
let allowed_origins = &cors_settings.allowed_origins;
|
||||
|
||||
@@ -1,6 +1,34 @@
|
||||
use axum::{extract::FromRequestParts, http::StatusCode};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RequestInfo {
|
||||
pub user_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
pub struct AuthenticatedRequestInfo {
|
||||
pub user_id: Uuid,
|
||||
}
|
||||
|
||||
impl<S> FromRequestParts<S> for AuthenticatedRequestInfo
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = StatusCode;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut axum::http::request::Parts,
|
||||
_state: &S,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let request_info = parts
|
||||
.extensions
|
||||
.get::<RequestInfo>()
|
||||
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
if let Some(user_id) = request_info.user_id {
|
||||
Ok(AuthenticatedRequestInfo { user_id })
|
||||
} else {
|
||||
Err(StatusCode::UNAUTHORIZED)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,3 +68,42 @@ async fn handle_unauthenticated() -> Result<Response, StatusCode> {
|
||||
// TODO: log unauthenticated access attempts
|
||||
Err(StatusCode::UNAUTHORIZED)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod mock {
|
||||
|
||||
use super::*;
|
||||
|
||||
pub const REQUEST_AUTH_USER_ID_HEADER: &str = "x-mock-authenticated-user-id";
|
||||
pub const REQUEST_AUTH_USER_INVALID_HEADER: &str = "x-mock-authenticated-invalid";
|
||||
|
||||
pub async fn mock_require_auth(
|
||||
req: Request<axum::body::Body>,
|
||||
next: Next,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let mut req = req;
|
||||
let invalid_present = req
|
||||
.headers()
|
||||
.get(REQUEST_AUTH_USER_INVALID_HEADER)
|
||||
.is_some();
|
||||
let user_id_header = req.headers().get(REQUEST_AUTH_USER_ID_HEADER).cloned();
|
||||
|
||||
if invalid_present {
|
||||
return handle_unauthenticated().await;
|
||||
}
|
||||
|
||||
let user = req
|
||||
.extensions_mut()
|
||||
.get_or_insert_with(|| RequestInfo { user_id: None });
|
||||
user.user_id = Some(if let Some(user_id_header) = user_id_header {
|
||||
let user_id_str = user_id_header
|
||||
.to_str()
|
||||
.map_err(|_| StatusCode::UNAUTHORIZED)?;
|
||||
Uuid::parse_str(user_id_str).map_err(|_| StatusCode::UNAUTHORIZED)?
|
||||
} else {
|
||||
Uuid::new_v4()
|
||||
});
|
||||
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,12 +12,8 @@ use crate::{
|
||||
configs::{ProgramSettings, server::CORSSettings},
|
||||
middlewares,
|
||||
services::{
|
||||
auth::{
|
||||
authentication::{AuthenticationService, strategies::password::PasswordStrategy},
|
||||
user::UserService,
|
||||
},
|
||||
server_state::ServerStateStore,
|
||||
settings::SettingsStore,
|
||||
AppService, ServiceState,
|
||||
auth::authentication::{AuthenticationService, strategies::password::PasswordStrategy},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -28,8 +24,6 @@ pub struct AppState {
|
||||
pub config: Arc<ProgramSettings>,
|
||||
}
|
||||
|
||||
pub type ServiceState<T> = Arc<T>;
|
||||
|
||||
pub struct AuthStrategy {
|
||||
pub password: ServiceState<PasswordStrategy>,
|
||||
}
|
||||
@@ -39,13 +33,6 @@ pub struct AuthState {
|
||||
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(
|
||||
state: impl Into<Arc<AppState>>,
|
||||
cors_settings: Arc<CORSSettings>,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
mod auth;
|
||||
mod health;
|
||||
mod helper;
|
||||
mod openapi;
|
||||
mod restricted;
|
||||
|
||||
|
||||
@@ -78,22 +78,12 @@ pub async fn get_health_info(
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use crate::configs::FromConfig;
|
||||
use crate::{
|
||||
routes::{AppState, api::health::state::HealthState},
|
||||
services::{
|
||||
auth::{
|
||||
authentication::{
|
||||
AuthenticationServiceImpl, strategies::password::PasswordStrategy,
|
||||
},
|
||||
user::UserServiceImpl,
|
||||
},
|
||||
server_state::ServerStateService,
|
||||
settings::SettingsService,
|
||||
},
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
use crate::configs::FromConfig;
|
||||
use crate::routes::{AppState, api::health::state::HealthState};
|
||||
use crate::services::get_app_service;
|
||||
|
||||
use axum::body::to_bytes;
|
||||
use axum::{
|
||||
Router,
|
||||
@@ -114,17 +104,10 @@ mod test {
|
||||
let app_state = Arc::new(AppState {
|
||||
database_connection: db.clone(),
|
||||
config: Arc::new(crate::configs::ProgramSettings::mock()),
|
||||
service: Arc::new(crate::routes::AppService {
|
||||
settings: Arc::new(SettingsService::new(db.clone())),
|
||||
auth_state: crate::routes::AuthState {
|
||||
strategy: crate::routes::AuthStrategy {
|
||||
password: Arc::new(PasswordStrategy::new(db.clone())),
|
||||
},
|
||||
authentication: Arc::new(AuthenticationServiceImpl::new(None)),
|
||||
},
|
||||
user: Arc::new(UserServiceImpl::new(db.clone())),
|
||||
server_state: Arc::new(ServerStateService::new(db.clone())),
|
||||
}),
|
||||
service: Arc::new(get_app_service(
|
||||
&db.clone(),
|
||||
&crate::configs::ProgramSettings::mock(),
|
||||
)),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
@@ -135,13 +118,21 @@ mod test {
|
||||
}));
|
||||
|
||||
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
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), 1024 * 1024).await.unwrap(); // Set limit to 1 MB
|
||||
let health_info: HealthInfo = serde_json::from_slice(&body).unwrap();
|
||||
let body = to_bytes(response.into_body(), 1024 * 1024)
|
||||
.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.version, env!("CARGO_PKG_VERSION"));
|
||||
assert!(health_info.errors.is_none());
|
||||
|
||||
1
apps/api/src/routes/api/helper.rs
Normal file
1
apps/api/src/routes/api/helper.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod pagination;
|
||||
76
apps/api/src/routes/api/helper/pagination.rs
Normal file
76
apps/api/src/routes/api/helper/pagination.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
use axum::{
|
||||
extract::FromRequestParts,
|
||||
http::{StatusCode, request::Parts},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::helpers::database::PaginationFilter;
|
||||
|
||||
#[derive(Serialize, Deserialize, utoipa::ToSchema, Clone)]
|
||||
/// Pagination parameters for API requests
|
||||
pub struct Pagination {
|
||||
/// Page number (1-based)
|
||||
pub page: u32,
|
||||
/// Items per page
|
||||
pub per_page: u32,
|
||||
}
|
||||
|
||||
impl Default for Pagination {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
page: 1,
|
||||
per_page: 20,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Pagination> for PaginationFilter {
|
||||
fn from(pagination: Pagination) -> Self {
|
||||
Self {
|
||||
page: pagination.page as u64,
|
||||
per_page: pagination.per_page as u64,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
|
||||
/// Pagination information included in API responses
|
||||
pub struct PaginationInfo {
|
||||
/// Total number of items
|
||||
pub total_items: u64,
|
||||
/// Total number of pages
|
||||
pub total_pages: u32,
|
||||
/// Current page number
|
||||
pub current_page: u32,
|
||||
/// Items per page
|
||||
pub per_page: u32,
|
||||
}
|
||||
|
||||
/// Extractor for pagination parameters from query string
|
||||
pub struct ExtractPagination(pub Pagination);
|
||||
|
||||
impl<S> FromRequestParts<S> for ExtractPagination
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = (StatusCode, &'static str);
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
let query = parts.uri.query().unwrap_or("");
|
||||
let pagination: Pagination = serde_urlencoded::from_str(query).unwrap_or_default();
|
||||
|
||||
// validation
|
||||
if pagination.page == 0 {
|
||||
return Err((StatusCode::BAD_REQUEST, "page must be greater than 0"));
|
||||
}
|
||||
|
||||
if pagination.per_page < 1 || pagination.per_page > 100 {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"per_page must be between 1 and 100",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(ExtractPagination(pagination))
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ pub mod tag {
|
||||
pub const HEALTH_TAG: &str = "Health";
|
||||
pub const AUTH_TAG: &str = "Authentication";
|
||||
pub const USER_TAG: &str = "User";
|
||||
pub const NGINX_TAG: &str = "Nginx";
|
||||
}
|
||||
|
||||
#[derive(utoipa::OpenApi)]
|
||||
@@ -14,6 +15,27 @@ pub mod tag {
|
||||
crate::routes::api::auth::init_admin::init_admin,
|
||||
// User management paths
|
||||
crate::routes::api::restricted::user::me::get_user_info,
|
||||
// Nginx upstream management
|
||||
crate::routes::api::restricted::nginx::upstream::create_upstream::create_upstream,
|
||||
crate::routes::api::restricted::nginx::upstream::create_upstream_target::add_upstream_target,
|
||||
crate::routes::api::restricted::nginx::upstream::get_upstream::get_upstream_list,
|
||||
crate::routes::api::restricted::nginx::upstream::get_upstream::get_upstream,
|
||||
crate::routes::api::restricted::nginx::upstream::get_upstream_target::get_upstream_target,
|
||||
crate::routes::api::restricted::nginx::upstream::update_upstream::update_upstream,
|
||||
crate::routes::api::restricted::nginx::upstream::update_upstream_target::update_upstream_target,
|
||||
crate::routes::api::restricted::nginx::upstream::remove_upstream::remove_upstream,
|
||||
crate::routes::api::restricted::nginx::upstream::remove_upstream_target::remove_upstream_target,
|
||||
// Nginx proxy host management
|
||||
crate::routes::api::restricted::nginx::proxy_host::get_proxy::get_proxy_list,
|
||||
crate::routes::api::restricted::nginx::proxy_host::get_proxy::get_proxy,
|
||||
crate::routes::api::restricted::nginx::proxy_host::create_proxy::create_proxy,
|
||||
crate::routes::api::restricted::nginx::proxy_host::update_proxy::update_proxy,
|
||||
crate::routes::api::restricted::nginx::proxy_host::remove_proxy::remove_proxy,
|
||||
// Proxy host locations
|
||||
crate::routes::api::restricted::nginx::proxy_host::create_location::create_location,
|
||||
crate::routes::api::restricted::nginx::proxy_host::get_location::get_location,
|
||||
crate::routes::api::restricted::nginx::proxy_host::update_location::update_location,
|
||||
crate::routes::api::restricted::nginx::proxy_host::remove_location::remove_location,
|
||||
),
|
||||
components(
|
||||
schemas(crate::routes::api::health::info::HealthInfo),
|
||||
@@ -22,11 +44,34 @@ pub mod tag {
|
||||
schemas(crate::routes::api::auth::init_admin::AdminInitRequest),
|
||||
// User management schemas
|
||||
schemas(crate::routes::api::restricted::user::me::UserInfo),
|
||||
// Nginx upstream schemas
|
||||
schemas(crate::routes::api::restricted::nginx::upstream::create_upstream::CreateUpstreamRequestBody),
|
||||
schemas(crate::routes::api::restricted::nginx::upstream::create_upstream_target::CreateUpstreamTargetInfo),
|
||||
schemas(crate::routes::api::restricted::nginx::upstream::get_upstream::GetUpstreamParams),
|
||||
schemas(crate::routes::api::restricted::nginx::upstream::get_upstream_target::GetUpstreamTargetsParams),
|
||||
schemas(crate::routes::api::restricted::nginx::upstream::info::response::UpstreamTargetInfo),
|
||||
schemas(crate::routes::api::restricted::nginx::upstream::info::response::UpstreamInfoResponse),
|
||||
schemas(crate::routes::api::restricted::nginx::upstream::info::response::UpstreamListResponse),
|
||||
schemas(crate::routes::api::restricted::nginx::upstream::info::response::UpstreamTargetInfoResponse),
|
||||
schemas(crate::routes::api::restricted::nginx::upstream::update_upstream::UpdateUpstreamRequestBody),
|
||||
schemas(crate::routes::api::restricted::nginx::upstream::update_upstream_target::UpdateUpstreamTargetRequestBody),
|
||||
schemas(crate::routes::api::restricted::nginx::upstream::info::response::UpdateUpstreamInfoResponse),
|
||||
schemas(crate::routes::api::restricted::nginx::upstream::info::response::UpdateUpstreamTargetInfoResponse),
|
||||
// Nginx proxy host schemas
|
||||
schemas(crate::routes::api::restricted::nginx::proxy_host::create_proxy::CreateLocationReq),
|
||||
schemas(crate::routes::api::restricted::nginx::proxy_host::create_proxy::CreateProxyRequestBody),
|
||||
schemas(crate::routes::api::restricted::nginx::proxy_host::create_location::CreateLocationRequestBody),
|
||||
schemas(crate::routes::api::restricted::nginx::proxy_host::update_proxy::UpdateProxyRequestBody),
|
||||
schemas(crate::routes::api::restricted::nginx::proxy_host::update_location::UpdateLocationRequestBody),
|
||||
schemas(crate::routes::api::restricted::nginx::proxy_host::info::response::LocationInfoResponse),
|
||||
schemas(crate::routes::api::restricted::nginx::proxy_host::info::response::ProxyHostInfoResponse),
|
||||
schemas(crate::routes::api::restricted::nginx::proxy_host::info::response::ProxyListResponse),
|
||||
),
|
||||
tags(
|
||||
(name = tag::HEALTH_TAG, description = "Health information API"),
|
||||
(name = tag::AUTH_TAG, description = "Authentication API"),
|
||||
(name = tag::USER_TAG, description = "User management API")
|
||||
(name = tag::USER_TAG, description = "User management API"),
|
||||
(name = tag::NGINX_TAG, description = "Nginx management API")
|
||||
)
|
||||
)]
|
||||
pub struct ApiDoc;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod nginx;
|
||||
pub mod user;
|
||||
|
||||
use std::sync::Arc;
|
||||
@@ -9,6 +10,7 @@ use crate::{middlewares::require_auth::require_auth, routes::AppState};
|
||||
pub fn get_restricted_router(state: Arc<AppState>) -> Router {
|
||||
Router::new()
|
||||
.nest("/user", user::get_user_router(state.clone()))
|
||||
.nest("/nginx", nginx::get_nginx_router(state.clone()))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_auth,
|
||||
|
||||
14
apps/api/src/routes/api/restricted/nginx.rs
Normal file
14
apps/api/src/routes/api/restricted/nginx.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
pub mod proxy_host;
|
||||
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(proxy_host::get_proxy_router(state.clone()))
|
||||
.merge(upstream::get_upstream_router(state.clone()))
|
||||
}
|
||||
43
apps/api/src/routes/api/restricted/nginx/proxy_host.rs
Normal file
43
apps/api/src/routes/api/restricted/nginx/proxy_host.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
pub mod create_location;
|
||||
pub mod create_proxy;
|
||||
pub mod get_location;
|
||||
pub mod get_proxy;
|
||||
pub mod info;
|
||||
pub mod remove_location;
|
||||
pub mod remove_proxy;
|
||||
pub mod update_location;
|
||||
pub mod update_proxy;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
routing::{get, post},
|
||||
};
|
||||
|
||||
use crate::routes::AppState;
|
||||
|
||||
pub fn get_proxy_router(state: Arc<AppState>) -> Router {
|
||||
Router::new()
|
||||
.route(
|
||||
"/proxy_hosts",
|
||||
get(get_proxy::get_proxy_list).post(create_proxy::create_proxy),
|
||||
)
|
||||
.route(
|
||||
"/proxy_hosts/{proxy_id}",
|
||||
get(get_proxy::get_proxy)
|
||||
.patch(update_proxy::update_proxy)
|
||||
.delete(remove_proxy::remove_proxy),
|
||||
)
|
||||
.route(
|
||||
"/proxy_hosts/{proxy_id}/locations",
|
||||
post(create_location::create_location),
|
||||
)
|
||||
.route(
|
||||
"/locations/{location_id}",
|
||||
get(get_location::get_location)
|
||||
.patch(update_location::update_location)
|
||||
.delete(remove_location::remove_location),
|
||||
)
|
||||
.with_state(state)
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{Json, extract::State, response::Result as AxumResult};
|
||||
use sea_orm::TransactionTrait;
|
||||
|
||||
use crate::{
|
||||
errors::api_error::ApiError,
|
||||
middlewares::request_info::AuthenticatedRequestInfo,
|
||||
routes::{
|
||||
AppState,
|
||||
api::{
|
||||
openapi::tag::NGINX_TAG,
|
||||
restricted::nginx::proxy_host::info::response::LocationInfoResponse,
|
||||
},
|
||||
},
|
||||
services::nginx::info::location::CreateLocationInfo,
|
||||
};
|
||||
|
||||
#[derive(serde::Deserialize, utoipa::ToSchema, serde::Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum CreateLocationRequestBody {
|
||||
// #[serde(rename = "upstream_id")]
|
||||
UpstreamId(CreateLocationRequestBodyByUpstreamId),
|
||||
// #[serde(rename = "proxy_pass")]
|
||||
ProxyPass(CreateLocationRequestBodyByProxyPass),
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, utoipa::ToSchema, serde::Serialize)]
|
||||
pub struct CreateLocationRequestBodyByUpstreamId {
|
||||
pub path: String,
|
||||
pub match_type: String,
|
||||
pub order: i64,
|
||||
pub upstream_id: uuid::Uuid,
|
||||
pub preserve_host_header: Option<bool>,
|
||||
pub allowed_methods: Option<Vec<String>>,
|
||||
pub custom_config: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, utoipa::ToSchema, serde::Serialize)]
|
||||
pub struct CreateLocationRequestBodyByProxyPass {
|
||||
pub path: String,
|
||||
pub match_type: String,
|
||||
pub order: i64,
|
||||
pub proxy_pass_protocol: String,
|
||||
pub proxy_pass_host: String,
|
||||
pub proxy_pass_port: i64,
|
||||
pub preserve_host_header: Option<bool>,
|
||||
pub allowed_methods: Option<Vec<String>>,
|
||||
pub custom_config: Option<String>,
|
||||
}
|
||||
|
||||
impl From<(uuid::Uuid, CreateLocationRequestBody)> for CreateLocationInfo {
|
||||
fn from(val: (uuid::Uuid, CreateLocationRequestBody)) -> Self {
|
||||
match val.1 {
|
||||
CreateLocationRequestBody::UpstreamId(body) => Self::from((val.0, body)),
|
||||
CreateLocationRequestBody::ProxyPass(body) => Self::from((val.0, body)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<(uuid::Uuid, CreateLocationRequestBodyByUpstreamId)> for CreateLocationInfo {
|
||||
fn from((proxy_id, payload): (uuid::Uuid, CreateLocationRequestBodyByUpstreamId)) -> Self {
|
||||
Self {
|
||||
host_id: proxy_id,
|
||||
path: payload.path,
|
||||
match_type: payload.match_type,
|
||||
order: payload.order,
|
||||
upstream_id: Some(payload.upstream_id),
|
||||
proxy_pass_protocol: None,
|
||||
proxy_pass_host: None,
|
||||
proxy_pass_port: None,
|
||||
preserve_host_header: payload.preserve_host_header,
|
||||
allowed_methods: payload.allowed_methods,
|
||||
custom_config: payload.custom_config,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<(uuid::Uuid, CreateLocationRequestBodyByProxyPass)> for CreateLocationInfo {
|
||||
fn from((proxy_id, payload): (uuid::Uuid, CreateLocationRequestBodyByProxyPass)) -> Self {
|
||||
Self {
|
||||
host_id: proxy_id,
|
||||
path: payload.path,
|
||||
match_type: payload.match_type,
|
||||
order: payload.order,
|
||||
upstream_id: None,
|
||||
proxy_pass_protocol: Some(payload.proxy_pass_protocol),
|
||||
proxy_pass_host: Some(payload.proxy_pass_host),
|
||||
proxy_pass_port: Some(payload.proxy_pass_port),
|
||||
preserve_host_header: payload.preserve_host_header,
|
||||
allowed_methods: payload.allowed_methods,
|
||||
custom_config: payload.custom_config,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[axum::debug_handler]
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/nginx/proxy_hosts/{proxy_id}/locations",
|
||||
request_body = CreateLocationRequestBody,
|
||||
responses(
|
||||
(status = 200, description = "Location created", body = LocationInfoResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 422, description = "Invalid request"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
tag = NGINX_TAG,
|
||||
)]
|
||||
pub async fn create_location(
|
||||
_request_info: AuthenticatedRequestInfo,
|
||||
axum::extract::Path(proxy_id): axum::extract::Path<uuid::Uuid>,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(payload): Json<CreateLocationRequestBody>,
|
||||
) -> AxumResult<Json<LocationInfoResponse>, ApiError> {
|
||||
let svc = &state.service.nginx.get_location_service();
|
||||
let create_info: CreateLocationInfo = (proxy_id, payload).into();
|
||||
|
||||
let mut tx = state.database_connection.begin().await?;
|
||||
let info = svc.create_location(create_info, Some(&mut tx)).await?;
|
||||
state
|
||||
.service
|
||||
.nginx
|
||||
.regenerate_and_apply_config(state.service.agent_client.clone(), &Some(&mut tx))
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Json(info.into()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum_test::TestServer;
|
||||
use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, MockRow};
|
||||
|
||||
use database::generated::entities::{location, upstream, upstream_target};
|
||||
|
||||
use crate::{
|
||||
configs::{FromConfig, ProgramSettings},
|
||||
middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER,
|
||||
routes::api::restricted::nginx::proxy_host::{
|
||||
create_location::CreateLocationRequestBodyByProxyPass,
|
||||
create_location::CreateLocationRequestBodyByUpstreamId, get_proxy_router,
|
||||
},
|
||||
services::{agent_client::MockAgentService, get_mock_app_service},
|
||||
};
|
||||
|
||||
fn get_router_with_state(db: DatabaseConnection) -> axum::Router {
|
||||
let program_settings = ProgramSettings::mock();
|
||||
let mut mock = MockAgentService::new();
|
||||
mock.expect_validate().returning(|_cfg| Ok(()));
|
||||
mock.expect_apply().returning(|_cfg| Ok(()));
|
||||
let mock_agent = Arc::new(mock);
|
||||
let app_service =
|
||||
get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent);
|
||||
let state = Arc::new(crate::routes::AppState {
|
||||
database_connection: Arc::new(db),
|
||||
service: Arc::new(app_service),
|
||||
config: Arc::new(program_settings),
|
||||
});
|
||||
get_proxy_router(state).layer(axum::middleware::from_fn(
|
||||
crate::middlewares::require_auth::mock::mock_require_auth,
|
||||
))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_create_location_succeeds_returns_created() {
|
||||
let ph_id = uuid::Uuid::new_v4();
|
||||
let loc_id = uuid::Uuid::new_v4();
|
||||
|
||||
let loc_model = location::Model {
|
||||
id: loc_id,
|
||||
host_id: ph_id,
|
||||
path: "/".to_string(),
|
||||
match_type: "prefix".to_string(),
|
||||
order: 1,
|
||||
upstream_id: None,
|
||||
proxy_pass_protocol: None,
|
||||
proxy_pass_host: None,
|
||||
proxy_pass_port: None,
|
||||
preserve_host_header: None,
|
||||
allowed_methods: None,
|
||||
custom_config: None,
|
||||
enabled: true,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let up_id = uuid::Uuid::new_v4();
|
||||
let up_model = upstream::Model {
|
||||
id: up_id,
|
||||
name: "u1".to_string(),
|
||||
protocol: "http".to_string(),
|
||||
algorithm: "rr".to_string(),
|
||||
sticky_session: false,
|
||||
created_by: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
let target_model = upstream_target::Model {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
upstream_id: up_id,
|
||||
target_host: "127.0.0.1".to_string(),
|
||||
target_port: 8080,
|
||||
weight: 1,
|
||||
is_backup: false,
|
||||
enabled: true,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
let dummy_row: Vec<MockRow> = vec![];
|
||||
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![vec![loc_model.clone()]])
|
||||
// additional query result for regenerate_and_apply_config -> generate_config
|
||||
.append_query_results(vec![vec![(up_model.clone(), Some(target_model.clone()))]])
|
||||
.append_query_results(vec![dummy_row])
|
||||
.into_connection();
|
||||
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let payload = CreateLocationRequestBodyByUpstreamId {
|
||||
path: "/".to_string(),
|
||||
match_type: "prefix".to_string(),
|
||||
order: 1,
|
||||
upstream_id: up_id,
|
||||
preserve_host_header: None,
|
||||
allowed_methods: None,
|
||||
custom_config: None,
|
||||
};
|
||||
|
||||
let res = server
|
||||
.post(&format!("/proxy_hosts/{}/locations", ph_id))
|
||||
.json(&payload)
|
||||
.await;
|
||||
res.assert_status_ok();
|
||||
let body = res.json::<crate::routes::api::restricted::nginx::proxy_host::info::response::LocationInfoResponse>();
|
||||
assert_eq!(body.id, loc_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_create_location_proxy_pass_succeeds_returns_created() {
|
||||
let ph_id = uuid::Uuid::new_v4();
|
||||
let loc_id = uuid::Uuid::new_v4();
|
||||
|
||||
let loc_model = location::Model {
|
||||
id: loc_id,
|
||||
host_id: ph_id,
|
||||
path: "/".to_string(),
|
||||
match_type: "prefix".to_string(),
|
||||
order: 1,
|
||||
upstream_id: None,
|
||||
proxy_pass_protocol: Some("http".to_string()),
|
||||
proxy_pass_host: Some("127.0.0.1".to_string()),
|
||||
proxy_pass_port: Some(8080),
|
||||
preserve_host_header: None,
|
||||
allowed_methods: None,
|
||||
custom_config: None,
|
||||
enabled: true,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let up_id = uuid::Uuid::new_v4();
|
||||
let up_model = upstream::Model {
|
||||
id: up_id,
|
||||
name: "u1".to_string(),
|
||||
protocol: "http".to_string(),
|
||||
algorithm: "rr".to_string(),
|
||||
sticky_session: false,
|
||||
created_by: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
let target_model = upstream_target::Model {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
upstream_id: up_id,
|
||||
target_host: "127.0.0.1".to_string(),
|
||||
target_port: 8080,
|
||||
weight: 1,
|
||||
is_backup: false,
|
||||
enabled: true,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
let dummy_row: Vec<MockRow> = vec![];
|
||||
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![vec![loc_model.clone()]])
|
||||
// additional query result for regenerate_and_apply_config -> generate_config
|
||||
.append_query_results(vec![vec![(up_model.clone(), Some(target_model.clone()))]])
|
||||
.append_query_results(vec![dummy_row])
|
||||
.into_connection();
|
||||
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let payload = CreateLocationRequestBodyByProxyPass {
|
||||
path: "/".to_string(),
|
||||
match_type: "prefix".to_string(),
|
||||
order: 1,
|
||||
proxy_pass_protocol: "http".to_string(),
|
||||
proxy_pass_host: "127.0.0.1".to_string(),
|
||||
proxy_pass_port: 8080,
|
||||
preserve_host_header: None,
|
||||
allowed_methods: None,
|
||||
custom_config: None,
|
||||
};
|
||||
|
||||
let res = server
|
||||
.post(&format!("/proxy_hosts/{}/locations", ph_id))
|
||||
.json(&payload)
|
||||
.await;
|
||||
res.assert_status_ok();
|
||||
let body = res.json::<crate::routes::api::restricted::nginx::proxy_host::info::response::LocationInfoResponse>();
|
||||
assert_eq!(body.id, loc_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_create_location_invalid_payload_returns_bad_request() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection();
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let res = server
|
||||
.post(&format!("/proxy_hosts/{}/locations", uuid::Uuid::new_v4()))
|
||||
.json(&serde_json::json!({}))
|
||||
.await;
|
||||
res.assert_status(StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_create_location_unauthenticated_returns_unauthorized() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection();
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let payload = CreateLocationRequestBodyByUpstreamId {
|
||||
path: "/".to_string(),
|
||||
match_type: "prefix".to_string(),
|
||||
order: 1,
|
||||
upstream_id: uuid::Uuid::new_v4(),
|
||||
preserve_host_header: None,
|
||||
allowed_methods: None,
|
||||
custom_config: None,
|
||||
};
|
||||
|
||||
let res = server
|
||||
.post(&format!("/proxy_hosts/{}/locations", uuid::Uuid::new_v4()))
|
||||
.add_header(REQUEST_AUTH_USER_INVALID_HEADER, "true")
|
||||
.json(&payload)
|
||||
.await;
|
||||
|
||||
res.assert_status(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{Json, extract::State, response::Result as AxumResult};
|
||||
use sea_orm::TransactionTrait;
|
||||
|
||||
use crate::{
|
||||
errors::api_error::ApiError,
|
||||
middlewares::request_info::AuthenticatedRequestInfo,
|
||||
routes::{
|
||||
AppState,
|
||||
api::{
|
||||
openapi::tag::NGINX_TAG,
|
||||
restricted::nginx::proxy_host::info::response::ProxyHostInfoResponse,
|
||||
},
|
||||
},
|
||||
services::nginx::info::proxy_host::ProxyHostCreateInfo,
|
||||
};
|
||||
|
||||
#[derive(serde::Deserialize, utoipa::ToSchema, serde::Serialize)]
|
||||
pub struct CreateLocationReq {
|
||||
pub path: String,
|
||||
pub match_type: String,
|
||||
pub order: i64,
|
||||
pub upstream_id: Option<uuid::Uuid>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum_test::TestServer;
|
||||
use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, MockRow};
|
||||
|
||||
use database::generated::entities::{location, proxy_host, upstream, upstream_target};
|
||||
|
||||
use crate::{
|
||||
configs::{FromConfig, ProgramSettings},
|
||||
middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER,
|
||||
routes::api::restricted::nginx::proxy_host::create_proxy::CreateLocationReq as ReqLocation,
|
||||
routes::api::restricted::nginx::proxy_host::create_proxy::CreateProxyRequestBody,
|
||||
routes::api::restricted::nginx::proxy_host::get_proxy_router,
|
||||
services::{agent_client::MockAgentService, get_mock_app_service},
|
||||
};
|
||||
|
||||
fn get_router_with_state(db: DatabaseConnection) -> axum::Router {
|
||||
let program_settings = ProgramSettings::mock();
|
||||
let mut mock = MockAgentService::new();
|
||||
mock.expect_validate().returning(|_cfg| Ok(()));
|
||||
mock.expect_apply().returning(|_cfg| Ok(()));
|
||||
let mock_agent = Arc::new(mock);
|
||||
let app_service =
|
||||
get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent);
|
||||
let state = Arc::new(crate::routes::AppState {
|
||||
database_connection: Arc::new(db),
|
||||
service: Arc::new(app_service),
|
||||
config: Arc::new(program_settings),
|
||||
});
|
||||
get_proxy_router(state).layer(axum::middleware::from_fn(
|
||||
crate::middlewares::require_auth::mock::mock_require_auth,
|
||||
))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_create_proxy_succeeds_returns_created() {
|
||||
let ph_id = uuid::Uuid::new_v4();
|
||||
|
||||
let ph_model = proxy_host::Model {
|
||||
id: ph_id,
|
||||
name: Some("myproxy".to_string()),
|
||||
domain: "example.com".to_string(),
|
||||
scheme: "http".to_string(),
|
||||
listen_port: 80,
|
||||
forward_scheme: "http".to_string(),
|
||||
forward_host: None,
|
||||
forward_port: None,
|
||||
preserve_host_header: false,
|
||||
enable_websocket: false,
|
||||
enabled: true,
|
||||
meta: None,
|
||||
default_upstream_id: None,
|
||||
created_by: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let loc_id = uuid::Uuid::new_v4();
|
||||
let loc_model = location::Model {
|
||||
id: loc_id,
|
||||
host_id: ph_id,
|
||||
path: "/".to_string(),
|
||||
match_type: "prefix".to_string(),
|
||||
order: 1,
|
||||
upstream_id: None,
|
||||
proxy_pass_protocol: None,
|
||||
proxy_pass_host: None,
|
||||
proxy_pass_port: None,
|
||||
preserve_host_header: None,
|
||||
allowed_methods: None,
|
||||
custom_config: None,
|
||||
enabled: true,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let up_id = uuid::Uuid::new_v4();
|
||||
let up_model = upstream::Model {
|
||||
id: up_id,
|
||||
name: "u1".to_string(),
|
||||
protocol: "http".to_string(),
|
||||
algorithm: "rr".to_string(),
|
||||
sticky_session: false,
|
||||
created_by: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
let target_model = upstream_target::Model {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
upstream_id: up_id,
|
||||
target_host: "127.0.0.1".to_string(),
|
||||
target_port: 8080,
|
||||
weight: 1,
|
||||
is_backup: false,
|
||||
enabled: true,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
let dummy_row: Vec<MockRow> = vec![];
|
||||
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![vec![ph_model.clone()]])
|
||||
.append_query_results(vec![vec![loc_model.clone()]])
|
||||
// additional query result for regenerate_and_apply_config -> generate_config
|
||||
.append_query_results(vec![vec![(up_model.clone(), Some(target_model.clone()))]])
|
||||
.append_query_results(vec![dummy_row])
|
||||
.into_connection();
|
||||
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let payload = CreateProxyRequestBody {
|
||||
name: Some("myproxy".to_string()),
|
||||
domain: "example.com".to_string(),
|
||||
scheme: "http".to_string(),
|
||||
listen_port: 80,
|
||||
forward_scheme: "http".to_string(),
|
||||
forward_host: None,
|
||||
forward_port: None,
|
||||
preserve_host_header: false,
|
||||
enable_websocket: false,
|
||||
enabled: true,
|
||||
meta: None,
|
||||
default_upstream_id: None,
|
||||
locations: vec![ReqLocation {
|
||||
path: "/".to_string(),
|
||||
match_type: "prefix".to_string(),
|
||||
order: 1,
|
||||
upstream_id: None,
|
||||
}],
|
||||
};
|
||||
|
||||
let res = server.post("/proxy_hosts").json(&payload).await;
|
||||
res.assert_status_ok();
|
||||
let body: crate::routes::api::restricted::nginx::proxy_host::info::response::ProxyHostInfoResponse =
|
||||
res.json();
|
||||
assert_eq!(body.id, ph_id);
|
||||
assert_eq!(body.domain, "example.com");
|
||||
assert_eq!(body.locations.len(), 1);
|
||||
assert_eq!(body.locations[0].id, loc_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_create_proxy_invalid_payload_returns_bad_request() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection();
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let res = server
|
||||
.post("/proxy_hosts")
|
||||
.json(&serde_json::json!({}))
|
||||
.await;
|
||||
res.assert_status(StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_create_proxy_unauthenticated_returns_unauthorized() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection();
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let payload = CreateProxyRequestBody {
|
||||
name: Some("myproxy".to_string()),
|
||||
domain: "example.com".to_string(),
|
||||
scheme: "http".to_string(),
|
||||
listen_port: 80,
|
||||
forward_scheme: "http".to_string(),
|
||||
forward_host: None,
|
||||
forward_port: None,
|
||||
preserve_host_header: false,
|
||||
enable_websocket: false,
|
||||
enabled: true,
|
||||
meta: None,
|
||||
default_upstream_id: None,
|
||||
locations: vec![],
|
||||
};
|
||||
|
||||
let res = server
|
||||
.post("/proxy_hosts")
|
||||
.add_header(REQUEST_AUTH_USER_INVALID_HEADER, "true")
|
||||
.json(&payload)
|
||||
.await;
|
||||
|
||||
res.assert_status(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, utoipa::ToSchema, serde::Serialize)]
|
||||
pub struct CreateProxyRequestBody {
|
||||
pub name: Option<String>,
|
||||
pub domain: String,
|
||||
pub scheme: String,
|
||||
pub listen_port: i64,
|
||||
pub forward_scheme: String,
|
||||
pub forward_host: Option<String>,
|
||||
pub forward_port: Option<i64>,
|
||||
pub preserve_host_header: bool,
|
||||
pub enable_websocket: bool,
|
||||
pub enabled: bool,
|
||||
pub meta: Option<serde_json::Value>,
|
||||
pub default_upstream_id: Option<uuid::Uuid>,
|
||||
pub locations: Vec<CreateLocationReq>,
|
||||
}
|
||||
|
||||
impl From<CreateProxyRequestBody> for ProxyHostCreateInfo {
|
||||
fn from(val: CreateProxyRequestBody) -> Self {
|
||||
Self {
|
||||
name: val.name,
|
||||
domain: val.domain,
|
||||
scheme: val.scheme,
|
||||
listen_port: val.listen_port,
|
||||
forward_scheme: val.forward_scheme,
|
||||
forward_host: val.forward_host,
|
||||
forward_port: val.forward_port,
|
||||
preserve_host_header: val.preserve_host_header,
|
||||
enable_websocket: val.enable_websocket,
|
||||
enabled: val.enabled,
|
||||
meta: val.meta,
|
||||
default_upstream_id: val.default_upstream_id,
|
||||
created_by: None,
|
||||
locations: val
|
||||
.locations
|
||||
.into_iter()
|
||||
.map(
|
||||
|l| crate::services::nginx::info::location::CreateLocationInfo {
|
||||
host_id: uuid::Uuid::nil(),
|
||||
path: l.path,
|
||||
match_type: l.match_type,
|
||||
order: l.order,
|
||||
upstream_id: l.upstream_id,
|
||||
proxy_pass_protocol: None,
|
||||
proxy_pass_host: None,
|
||||
proxy_pass_port: None,
|
||||
preserve_host_header: None,
|
||||
allowed_methods: None,
|
||||
custom_config: None,
|
||||
enabled: true,
|
||||
},
|
||||
)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[axum::debug_handler]
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/nginx/proxy_hosts",
|
||||
request_body = CreateProxyRequestBody,
|
||||
responses(
|
||||
(status = 200, description = "Proxy created successfully", body = ProxyHostInfoResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 422, description = "Invalid request"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
tag = NGINX_TAG,
|
||||
)]
|
||||
pub async fn create_proxy(
|
||||
request_info: AuthenticatedRequestInfo,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(payload): Json<CreateProxyRequestBody>,
|
||||
) -> AxumResult<Json<ProxyHostInfoResponse>, ApiError> {
|
||||
let proxy_service = &state.service.nginx.get_proxy_service();
|
||||
let mut create_info: ProxyHostCreateInfo = payload.into();
|
||||
create_info.created_by = Some(request_info.user_id);
|
||||
|
||||
let mut tx = state.database_connection.begin().await?;
|
||||
let info = proxy_service
|
||||
.create_proxy(create_info, &Some(&mut tx))
|
||||
.await?;
|
||||
|
||||
state
|
||||
.service
|
||||
.nginx
|
||||
.regenerate_and_apply_config(state.service.agent_client.clone(), &Some(&mut tx))
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Json(info.into()))
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::services::nginx::location::GetLocationOptions;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
response::Result as AxumResult,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
errors::api_error::ApiError,
|
||||
routes::{
|
||||
AppState,
|
||||
api::{
|
||||
openapi::tag::NGINX_TAG,
|
||||
restricted::nginx::proxy_host::info::response::LocationInfoResponse,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct GetLocationParams {
|
||||
pub include_upstream: Option<bool>,
|
||||
}
|
||||
|
||||
pub struct ConcreteGetLocationParams {
|
||||
pub include_upstream: bool,
|
||||
}
|
||||
|
||||
impl From<GetLocationParams> for ConcreteGetLocationParams {
|
||||
fn from(params: GetLocationParams) -> Self {
|
||||
Self {
|
||||
include_upstream: params.include_upstream.unwrap_or(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/nginx/locations/{location_id}",
|
||||
responses(
|
||||
(status = 200, description = "Get location info", body = LocationInfoResponse),
|
||||
(status = 404, description = "Not found"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
tag = NGINX_TAG,
|
||||
)]
|
||||
#[axum::debug_handler]
|
||||
pub async fn get_location(
|
||||
Path(location_id): Path<uuid::Uuid>,
|
||||
Query(params): Query<GetLocationParams>,
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> AxumResult<Json<LocationInfoResponse>, ApiError> {
|
||||
let concrete_params: ConcreteGetLocationParams = params.into();
|
||||
let svc = &state.service.nginx.get_location_service();
|
||||
let info = if concrete_params.include_upstream {
|
||||
svc.get_location(
|
||||
location_id,
|
||||
Some(GetLocationOptions {
|
||||
include_upstream: true,
|
||||
filter_by_enabled: false,
|
||||
}),
|
||||
None,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
svc.get_location(location_id, None, None).await?
|
||||
};
|
||||
Ok(Json(info.into()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum_test::TestServer;
|
||||
use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase};
|
||||
|
||||
use database::generated::entities::{location, proxy_host};
|
||||
|
||||
use crate::{
|
||||
configs::{FromConfig, ProgramSettings},
|
||||
routes::api::restricted::nginx::proxy_host::get_proxy_router,
|
||||
services::get_app_service,
|
||||
};
|
||||
|
||||
fn get_router_with_state(db: DatabaseConnection) -> axum::Router {
|
||||
let program_settings = ProgramSettings::mock();
|
||||
let app_service = get_app_service(&Arc::new(db.clone()), &program_settings);
|
||||
let state = Arc::new(crate::routes::AppState {
|
||||
database_connection: Arc::new(db),
|
||||
service: Arc::new(app_service),
|
||||
config: Arc::new(program_settings),
|
||||
});
|
||||
get_proxy_router(state)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_get_location_returns_info() {
|
||||
let loc_id = uuid::Uuid::new_v4();
|
||||
let loc_model = location::Model {
|
||||
id: loc_id,
|
||||
host_id: uuid::Uuid::new_v4(),
|
||||
path: "/".to_string(),
|
||||
match_type: "prefix".to_string(),
|
||||
order: 1,
|
||||
upstream_id: None,
|
||||
proxy_pass_protocol: None,
|
||||
proxy_pass_host: None,
|
||||
proxy_pass_port: None,
|
||||
preserve_host_header: None,
|
||||
allowed_methods: None,
|
||||
custom_config: None,
|
||||
enabled: true,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![vec![(
|
||||
loc_model.clone(),
|
||||
Option::<proxy_host::Model>::None,
|
||||
)]])
|
||||
.into_connection();
|
||||
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let res = server.get(&format!("/locations/{}", loc_id)).await;
|
||||
res.assert_status_ok();
|
||||
let body = res.json::<crate::routes::api::restricted::nginx::proxy_host::info::response::LocationInfoResponse>();
|
||||
assert_eq!(body.id, loc_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_get_location_not_found_returns_not_found() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![Vec::<sea_orm::MockRow>::new()])
|
||||
.into_connection();
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let res = server
|
||||
.get(&format!("/locations/{}", uuid::Uuid::new_v4()))
|
||||
.await;
|
||||
res.assert_status(StatusCode::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
281
apps/api/src/routes/api/restricted/nginx/proxy_host/get_proxy.rs
Normal file
281
apps/api/src/routes/api/restricted/nginx/proxy_host/get_proxy.rs
Normal file
@@ -0,0 +1,281 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
response::Result as AxumResult,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::services::nginx::proxy_host::{ProxyHostGetOptions, ProxyHostListOptions};
|
||||
use crate::{
|
||||
errors::{api_error::ApiError, service_error::ServiceError},
|
||||
routes::{
|
||||
AppState,
|
||||
api::restricted::nginx::proxy_host::info::response::{
|
||||
ProxyHostInfoResponse, ProxyListResponse,
|
||||
},
|
||||
api::{
|
||||
helper::pagination::{ExtractPagination, PaginationInfo},
|
||||
openapi::tag::NGINX_TAG,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/nginx/proxy_hosts",
|
||||
responses(
|
||||
(status = 200, description = "List proxies", body = ProxyListResponse),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
tag = NGINX_TAG,
|
||||
)]
|
||||
#[axum::debug_handler]
|
||||
pub async fn get_proxy_list(
|
||||
ExtractPagination(pagination): ExtractPagination,
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> AxumResult<Json<ProxyListResponse>, ServiceError> {
|
||||
let svc = &state.service.nginx.get_proxy_service();
|
||||
|
||||
let (proxies_res, proxies_count_res) = tokio::join!(
|
||||
svc.get_proxies(
|
||||
Some(pagination.clone().into()),
|
||||
Some(ProxyHostListOptions {
|
||||
include_upstream: true,
|
||||
filter_by_enabled: false,
|
||||
}),
|
||||
&None,
|
||||
),
|
||||
svc.get_total_proxies(None, &None),
|
||||
);
|
||||
|
||||
let proxies = proxies_res?;
|
||||
let proxies_count = proxies_count_res?;
|
||||
|
||||
let items: Vec<ProxyHostInfoResponse> = proxies.into_iter().map(|i| i.into()).collect();
|
||||
|
||||
Ok(Json(ProxyListResponse {
|
||||
items,
|
||||
pagination: PaginationInfo {
|
||||
total_items: proxies_count,
|
||||
total_pages: if proxies_count == 0 {
|
||||
0
|
||||
} else {
|
||||
(proxies_count as f32 / pagination.per_page as f32).ceil() as u32
|
||||
},
|
||||
current_page: pagination.page,
|
||||
per_page: pagination.per_page,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum_test::TestServer;
|
||||
use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, Value};
|
||||
|
||||
use database::generated::entities::{location, proxy_host};
|
||||
|
||||
use crate::{
|
||||
configs::{FromConfig, ProgramSettings},
|
||||
routes::api::restricted::nginx::proxy_host::get_proxy_router,
|
||||
services::get_app_service,
|
||||
};
|
||||
|
||||
fn get_router_with_state(db: DatabaseConnection) -> axum::Router {
|
||||
let program_settings = ProgramSettings::mock();
|
||||
let app_service = get_app_service(&Arc::new(db.clone()), &program_settings);
|
||||
let state = Arc::new(crate::routes::AppState {
|
||||
database_connection: Arc::new(db),
|
||||
service: Arc::new(app_service),
|
||||
config: Arc::new(program_settings),
|
||||
});
|
||||
get_proxy_router(state)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_get_proxy_list_returns_list() {
|
||||
let p1 = proxy_host::Model {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
name: Some("p1".to_string()),
|
||||
domain: "a.com".to_string(),
|
||||
scheme: "http".to_string(),
|
||||
listen_port: 80,
|
||||
forward_scheme: "http".to_string(),
|
||||
forward_host: None,
|
||||
forward_port: None,
|
||||
preserve_host_header: false,
|
||||
enable_websocket: false,
|
||||
enabled: true,
|
||||
meta: None,
|
||||
default_upstream_id: None,
|
||||
created_by: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
let p2 = proxy_host::Model {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
name: Some("p2".to_string()),
|
||||
domain: "b.com".to_string(),
|
||||
scheme: "http".to_string(),
|
||||
listen_port: 80,
|
||||
forward_scheme: "http".to_string(),
|
||||
forward_host: None,
|
||||
forward_port: None,
|
||||
preserve_host_header: false,
|
||||
enable_websocket: false,
|
||||
enabled: true,
|
||||
meta: None,
|
||||
default_upstream_id: None,
|
||||
created_by: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![vec![
|
||||
(p1.clone(), None::<location::Model>),
|
||||
(p2.clone(), None::<location::Model>),
|
||||
]])
|
||||
.append_query_results(vec![vec![std::collections::BTreeMap::from([(
|
||||
"count".to_string(),
|
||||
Value::BigInt(Some(2)),
|
||||
)])]])
|
||||
.into_connection();
|
||||
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let res = server.get("/proxy_hosts").await;
|
||||
res.assert_status_ok();
|
||||
let body = res.json::<crate::routes::api::restricted::nginx::proxy_host::info::response::ProxyListResponse>();
|
||||
assert_eq!(body.items.len(), 2);
|
||||
assert_eq!(body.pagination.current_page, 1u32);
|
||||
assert_eq!(body.pagination.total_pages, 1u32);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_get_proxy_with_locations_returns_locations() {
|
||||
let ph_id = uuid::Uuid::new_v4();
|
||||
let ph_model = proxy_host::Model {
|
||||
id: ph_id,
|
||||
name: Some("with_locations".to_string()),
|
||||
domain: "with.com".to_string(),
|
||||
scheme: "http".to_string(),
|
||||
listen_port: 80,
|
||||
forward_scheme: "http".to_string(),
|
||||
forward_host: None,
|
||||
forward_port: None,
|
||||
preserve_host_header: false,
|
||||
enable_websocket: false,
|
||||
enabled: true,
|
||||
meta: None,
|
||||
default_upstream_id: None,
|
||||
created_by: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let loc_model = location::Model {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
host_id: ph_id,
|
||||
path: "/path".to_string(),
|
||||
match_type: "prefix".to_string(),
|
||||
order: 1,
|
||||
upstream_id: None,
|
||||
proxy_pass_protocol: None,
|
||||
proxy_pass_host: None,
|
||||
proxy_pass_port: None,
|
||||
preserve_host_header: None,
|
||||
allowed_methods: None,
|
||||
custom_config: None,
|
||||
enabled: true,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![vec![(ph_model.clone(), Some(loc_model.clone()))]])
|
||||
.into_connection();
|
||||
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let url = format!("/proxy_hosts/{}", ph_id);
|
||||
let res = server.get(&url).await;
|
||||
res.assert_status_ok();
|
||||
let body = res.json::<crate::routes::api::restricted::nginx::proxy_host::info::response::ProxyHostInfoResponse>();
|
||||
assert_eq!(body.id, ph_id);
|
||||
assert_eq!(body.locations.len(), 1);
|
||||
assert_eq!(body.locations[0].path, "/path");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_get_proxy_not_found_returns_not_found() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![Vec::<sea_orm::MockRow>::new()])
|
||||
.into_connection();
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let res = server
|
||||
.get(&format!("/proxy_hosts/{}", uuid::Uuid::new_v4()))
|
||||
.await;
|
||||
res.assert_status(StatusCode::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct GetProxyParams {
|
||||
pub include_upstream: Option<bool>,
|
||||
}
|
||||
|
||||
pub struct ConcreteGetProxyParams {
|
||||
pub include_upstream: bool,
|
||||
}
|
||||
|
||||
impl From<GetProxyParams> for ConcreteGetProxyParams {
|
||||
fn from(params: GetProxyParams) -> Self {
|
||||
Self {
|
||||
include_upstream: params.include_upstream.unwrap_or(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/nginx/proxy_hosts/{proxy_id}",
|
||||
responses(
|
||||
(status = 200, description = "Get proxy info", body = ProxyHostInfoResponse),
|
||||
(status = 404, description = "Not found"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
tag = NGINX_TAG,
|
||||
)]
|
||||
pub async fn get_proxy(
|
||||
Path(proxy_id): Path<Uuid>,
|
||||
Query(params): Query<GetProxyParams>,
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> AxumResult<Json<ProxyHostInfoResponse>, ApiError> {
|
||||
let concrete_params: ConcreteGetProxyParams = params.into();
|
||||
let svc = &state.service.nginx.get_proxy_service();
|
||||
let info = if concrete_params.include_upstream {
|
||||
svc.get_proxy(
|
||||
proxy_id,
|
||||
Some(ProxyHostGetOptions {
|
||||
include_upstream: true,
|
||||
filter_by_enabled: false,
|
||||
}),
|
||||
&None,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
svc.get_proxy(proxy_id, None, &None).await?
|
||||
};
|
||||
Ok(Json(info.into()))
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod response;
|
||||
@@ -0,0 +1,91 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::routes::api::helper::pagination::PaginationInfo;
|
||||
|
||||
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct LocationInfoResponse {
|
||||
pub id: uuid::Uuid,
|
||||
pub host_id: uuid::Uuid,
|
||||
pub path: String,
|
||||
pub match_type: String,
|
||||
pub order: i64,
|
||||
pub upstream_id: Option<uuid::Uuid>,
|
||||
pub enabled: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl From<crate::services::nginx::info::location::LocationInfo> for LocationInfoResponse {
|
||||
fn from(info: crate::services::nginx::info::location::LocationInfo) -> Self {
|
||||
Self {
|
||||
id: info.id,
|
||||
host_id: info.host_id,
|
||||
path: info.path,
|
||||
match_type: info.match_type,
|
||||
order: info.order,
|
||||
upstream_id: info.upstream_id,
|
||||
enabled: info.enabled,
|
||||
created_at: info.created_at,
|
||||
updated_at: info.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct ProxyHostInfoResponse {
|
||||
pub id: uuid::Uuid,
|
||||
pub name: Option<String>,
|
||||
pub domain: String,
|
||||
pub scheme: String,
|
||||
pub listen_port: i64,
|
||||
pub forward_scheme: String,
|
||||
pub forward_host: Option<String>,
|
||||
pub forward_port: Option<i64>,
|
||||
pub preserve_host_header: bool,
|
||||
pub enable_websocket: bool,
|
||||
pub enabled: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub upstream: Option<ProxyHostUpstreamBasic>,
|
||||
pub locations: Vec<LocationInfoResponse>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct ProxyHostUpstreamBasic {
|
||||
pub id: uuid::Uuid,
|
||||
pub name: String,
|
||||
pub protocol: String,
|
||||
}
|
||||
|
||||
impl From<crate::services::nginx::info::proxy_host::ProxyHostInfo> for ProxyHostInfoResponse {
|
||||
fn from(info: crate::services::nginx::info::proxy_host::ProxyHostInfo) -> Self {
|
||||
Self {
|
||||
id: info.id,
|
||||
name: info.name,
|
||||
domain: info.domain,
|
||||
scheme: info.scheme,
|
||||
listen_port: info.listen_port,
|
||||
forward_scheme: info.forward_scheme,
|
||||
forward_host: info.forward_host,
|
||||
forward_port: info.forward_port,
|
||||
preserve_host_header: info.preserve_host_header,
|
||||
enable_websocket: info.enable_websocket,
|
||||
enabled: info.enabled,
|
||||
created_at: info.created_at,
|
||||
updated_at: info.updated_at,
|
||||
upstream: info.upstream.map(|u| ProxyHostUpstreamBasic {
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
protocol: u.protocol,
|
||||
}),
|
||||
locations: info.locations.into_iter().map(|l| l.into()).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct ProxyListResponse {
|
||||
pub items: Vec<ProxyHostInfoResponse>,
|
||||
pub pagination: PaginationInfo,
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
response::Result as AxumResult,
|
||||
};
|
||||
use sea_orm::TransactionTrait;
|
||||
|
||||
use crate::{
|
||||
errors::api_error::ApiError,
|
||||
middlewares::request_info::AuthenticatedRequestInfo,
|
||||
routes::{AppState, api::openapi::tag::NGINX_TAG},
|
||||
};
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/nginx/locations/{location_id}",
|
||||
responses(
|
||||
(status = 200, description = "Location removed successfully", body = ()),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Not found"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
tag = NGINX_TAG,
|
||||
)]
|
||||
#[axum::debug_handler]
|
||||
pub async fn remove_location(
|
||||
_request_info: AuthenticatedRequestInfo,
|
||||
Path(location_id): Path<uuid::Uuid>,
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> AxumResult<Json<()>, ApiError> {
|
||||
let svc = &state.service.nginx.get_location_service();
|
||||
|
||||
let mut tx = state.database_connection.begin().await?;
|
||||
svc.delete_location(location_id, Some(&mut tx)).await?;
|
||||
state
|
||||
.service
|
||||
.nginx
|
||||
.regenerate_and_apply_config(state.service.agent_client.clone(), &Some(&mut tx))
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum_test::TestServer;
|
||||
use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, MockRow};
|
||||
|
||||
use database::generated::entities::{location, upstream, upstream_target};
|
||||
|
||||
use crate::{
|
||||
configs::{FromConfig, ProgramSettings},
|
||||
routes::api::restricted::nginx::proxy_host::get_proxy_router,
|
||||
services::{agent_client::MockAgentService, get_mock_app_service},
|
||||
};
|
||||
|
||||
fn get_router_with_state(db: DatabaseConnection) -> axum::Router {
|
||||
let program_settings = ProgramSettings::mock();
|
||||
let mut mock = MockAgentService::new();
|
||||
mock.expect_validate().returning(|_cfg| Ok(()));
|
||||
mock.expect_apply().returning(|_cfg| Ok(()));
|
||||
let mock_agent = Arc::new(mock);
|
||||
let app_service =
|
||||
get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent);
|
||||
let state = Arc::new(crate::routes::AppState {
|
||||
database_connection: Arc::new(db),
|
||||
service: Arc::new(app_service),
|
||||
config: Arc::new(program_settings),
|
||||
});
|
||||
get_proxy_router(state).layer(axum::middleware::from_fn(
|
||||
crate::middlewares::require_auth::mock::mock_require_auth,
|
||||
))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_remove_location_succeeds_returns_ok() {
|
||||
let loc_id = uuid::Uuid::new_v4();
|
||||
|
||||
let existing = location::Model {
|
||||
id: loc_id,
|
||||
host_id: uuid::Uuid::new_v4(),
|
||||
path: "/".to_string(),
|
||||
match_type: "prefix".to_string(),
|
||||
order: 1,
|
||||
upstream_id: None,
|
||||
proxy_pass_protocol: None,
|
||||
proxy_pass_host: None,
|
||||
proxy_pass_port: None,
|
||||
preserve_host_header: None,
|
||||
allowed_methods: None,
|
||||
custom_config: None,
|
||||
enabled: true,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let up_id = uuid::Uuid::new_v4();
|
||||
let up_model = upstream::Model {
|
||||
id: up_id,
|
||||
name: "u1".to_string(),
|
||||
protocol: "http".to_string(),
|
||||
algorithm: "rr".to_string(),
|
||||
sticky_session: false,
|
||||
created_by: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
let target_model = upstream_target::Model {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
upstream_id: up_id,
|
||||
target_host: "127.0.0.1".to_string(),
|
||||
target_port: 8080,
|
||||
weight: 1,
|
||||
is_backup: false,
|
||||
enabled: true,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
let dummy_row: Vec<MockRow> = vec![];
|
||||
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![vec![existing.clone()]])
|
||||
.append_exec_results(vec![sea_orm::MockExecResult {
|
||||
rows_affected: 1,
|
||||
last_insert_id: 0,
|
||||
}])
|
||||
// additional query result for regenerate_and_apply_config -> generate_config
|
||||
.append_query_results(vec![vec![(up_model.clone(), Some(target_model.clone()))]])
|
||||
.append_query_results(vec![dummy_row])
|
||||
.into_connection();
|
||||
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let res = server.delete(&format!("/locations/{}", loc_id)).await;
|
||||
res.assert_status_ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_remove_location_not_found_returns_not_found() {
|
||||
let empty_results: Vec<Vec<location::Model>> = vec![Vec::<location::Model>::new()];
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(empty_results)
|
||||
.into_connection();
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let res = server
|
||||
.delete(&format!("/locations/{}", uuid::Uuid::new_v4()))
|
||||
.await;
|
||||
res.assert_status(StatusCode::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
response::Result as AxumResult,
|
||||
};
|
||||
use sea_orm::TransactionTrait;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
errors::api_error::ApiError,
|
||||
middlewares::request_info::AuthenticatedRequestInfo,
|
||||
routes::{AppState, api::openapi::tag::NGINX_TAG},
|
||||
};
|
||||
|
||||
#[axum::debug_handler]
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/nginx/proxy_hosts/{proxy_id}",
|
||||
responses(
|
||||
(status = 200, description = "Proxy removed successfully", body = ()),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Not found"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
tag = NGINX_TAG,
|
||||
)]
|
||||
pub async fn remove_proxy(
|
||||
_request_info: AuthenticatedRequestInfo,
|
||||
Path(proxy_id): Path<Uuid>,
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> AxumResult<Json<()>, ApiError> {
|
||||
let svc = &state.service.nginx.get_proxy_service();
|
||||
|
||||
let mut tx = state.database_connection.begin().await?;
|
||||
svc.delete_proxy(proxy_id, &Some(&mut tx)).await?;
|
||||
state
|
||||
.service
|
||||
.nginx
|
||||
.regenerate_and_apply_config(state.service.agent_client.clone(), &Some(&mut tx))
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum_test::TestServer;
|
||||
use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, MockExecResult, MockRow};
|
||||
|
||||
use database::generated::entities::{proxy_host, upstream, upstream_target};
|
||||
|
||||
use crate::{
|
||||
configs::{FromConfig, ProgramSettings},
|
||||
middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER,
|
||||
routes::api::restricted::nginx::proxy_host::get_proxy_router,
|
||||
services::{agent_client::MockAgentService, get_mock_app_service},
|
||||
};
|
||||
|
||||
fn get_router_with_state(db: DatabaseConnection) -> axum::Router {
|
||||
let program_settings = ProgramSettings::mock();
|
||||
let mut mock = MockAgentService::new();
|
||||
mock.expect_validate().returning(|_cfg| Ok(()));
|
||||
mock.expect_apply().returning(|_cfg| Ok(()));
|
||||
let mock_agent = Arc::new(mock);
|
||||
let app_service =
|
||||
get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent);
|
||||
let state = Arc::new(crate::routes::AppState {
|
||||
database_connection: Arc::new(db),
|
||||
service: Arc::new(app_service),
|
||||
config: Arc::new(program_settings),
|
||||
});
|
||||
get_proxy_router(state).layer(axum::middleware::from_fn(
|
||||
crate::middlewares::require_auth::mock::mock_require_auth,
|
||||
))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_remove_proxy_succeeds_returns_ok() {
|
||||
let ph_id = uuid::Uuid::new_v4();
|
||||
|
||||
let existing = proxy_host::Model {
|
||||
id: ph_id,
|
||||
name: Some("todelete".to_string()),
|
||||
domain: "d.com".to_string(),
|
||||
scheme: "http".to_string(),
|
||||
listen_port: 80,
|
||||
forward_scheme: "http".to_string(),
|
||||
forward_host: None,
|
||||
forward_port: None,
|
||||
preserve_host_header: false,
|
||||
enable_websocket: false,
|
||||
enabled: true,
|
||||
meta: None,
|
||||
default_upstream_id: None,
|
||||
created_by: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let up_id = uuid::Uuid::new_v4();
|
||||
let up_model = upstream::Model {
|
||||
id: up_id,
|
||||
name: "u1".to_string(),
|
||||
protocol: "http".to_string(),
|
||||
algorithm: "rr".to_string(),
|
||||
sticky_session: false,
|
||||
created_by: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
let target_model = upstream_target::Model {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
upstream_id: up_id,
|
||||
target_host: "127.0.0.1".to_string(),
|
||||
target_port: 8080,
|
||||
weight: 1,
|
||||
is_backup: false,
|
||||
enabled: true,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
let dummy_row: Vec<MockRow> = vec![];
|
||||
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![vec![existing.clone()]])
|
||||
.append_exec_results(vec![
|
||||
MockExecResult {
|
||||
rows_affected: 1,
|
||||
last_insert_id: 0,
|
||||
},
|
||||
MockExecResult {
|
||||
rows_affected: 1,
|
||||
last_insert_id: 0,
|
||||
},
|
||||
])
|
||||
// additional query result for regenerate_and_apply_config -> generate_config
|
||||
.append_query_results(vec![vec![(up_model.clone(), Some(target_model.clone()))]])
|
||||
.append_query_results(vec![dummy_row])
|
||||
.into_connection();
|
||||
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let res = server.delete(&format!("/proxy_hosts/{}", ph_id)).await;
|
||||
res.assert_status_ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_remove_proxy_unauthenticated_returns_unauthorized() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection();
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let res = server
|
||||
.delete(&format!("/proxy_hosts/{}", uuid::Uuid::new_v4()))
|
||||
.add_header(REQUEST_AUTH_USER_INVALID_HEADER, "true")
|
||||
.await;
|
||||
|
||||
res.assert_status(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_remove_proxy_not_found_returns_not_found() {
|
||||
let empty_results: Vec<Vec<proxy_host::Model>> = vec![Vec::<proxy_host::Model>::new()];
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(empty_results)
|
||||
.into_connection();
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let res = server
|
||||
.delete(&format!("/proxy_hosts/{}", uuid::Uuid::new_v4()))
|
||||
.await;
|
||||
res.assert_status(StatusCode::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
response::Result as AxumResult,
|
||||
};
|
||||
use sea_orm::TransactionTrait;
|
||||
|
||||
use crate::{
|
||||
errors::api_error::ApiError,
|
||||
middlewares::request_info::AuthenticatedRequestInfo,
|
||||
routes::{
|
||||
AppState,
|
||||
api::{
|
||||
openapi::tag::NGINX_TAG,
|
||||
restricted::nginx::proxy_host::info::response::LocationInfoResponse,
|
||||
},
|
||||
},
|
||||
services::nginx::info::location::UpdateLocationInfo,
|
||||
};
|
||||
|
||||
#[derive(serde::Deserialize, utoipa::ToSchema, serde::Serialize)]
|
||||
pub struct UpdateLocationRequestBody {
|
||||
pub path: Option<String>,
|
||||
pub match_type: Option<String>,
|
||||
pub order: Option<i64>,
|
||||
pub upstream_id: Option<Option<uuid::Uuid>>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum_test::TestServer;
|
||||
use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, MockRow};
|
||||
|
||||
use database::generated::entities::{location, upstream, upstream_target};
|
||||
|
||||
use super::UpdateLocationRequestBody;
|
||||
use crate::{
|
||||
configs::{FromConfig, ProgramSettings},
|
||||
routes::api::restricted::nginx::proxy_host::get_proxy_router,
|
||||
services::{agent_client::MockAgentService, get_mock_app_service},
|
||||
};
|
||||
|
||||
fn get_router_with_state(db: DatabaseConnection) -> axum::Router {
|
||||
let program_settings = ProgramSettings::mock();
|
||||
let mut mock = MockAgentService::new();
|
||||
mock.expect_validate().returning(|_cfg| Ok(()));
|
||||
mock.expect_apply().returning(|_cfg| Ok(()));
|
||||
let mock_agent = Arc::new(mock);
|
||||
let app_service =
|
||||
get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent);
|
||||
let state = Arc::new(crate::routes::AppState {
|
||||
database_connection: Arc::new(db),
|
||||
service: Arc::new(app_service),
|
||||
config: Arc::new(program_settings),
|
||||
});
|
||||
get_proxy_router(state).layer(axum::middleware::from_fn(
|
||||
crate::middlewares::require_auth::mock::mock_require_auth,
|
||||
))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_update_location_succeeds_returns_ok() {
|
||||
let loc_id = uuid::Uuid::new_v4();
|
||||
|
||||
let current = location::Model {
|
||||
id: loc_id,
|
||||
host_id: uuid::Uuid::new_v4(),
|
||||
path: "/old".to_string(),
|
||||
match_type: "prefix".to_string(),
|
||||
order: 1,
|
||||
upstream_id: None,
|
||||
proxy_pass_protocol: None,
|
||||
proxy_pass_host: None,
|
||||
proxy_pass_port: None,
|
||||
preserve_host_header: None,
|
||||
allowed_methods: None,
|
||||
custom_config: None,
|
||||
enabled: true,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let updated = location::Model { ..current.clone() };
|
||||
|
||||
let first: Vec<Vec<location::Model>> = vec![vec![current.clone()]];
|
||||
let second: Vec<Vec<location::Model>> = vec![vec![updated.clone()]];
|
||||
let up_id = uuid::Uuid::new_v4();
|
||||
let up_model = upstream::Model {
|
||||
id: up_id,
|
||||
name: "u1".to_string(),
|
||||
protocol: "http".to_string(),
|
||||
algorithm: "rr".to_string(),
|
||||
sticky_session: false,
|
||||
created_by: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
let target_model = upstream_target::Model {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
upstream_id: up_id,
|
||||
target_host: "127.0.0.1".to_string(),
|
||||
target_port: 8080,
|
||||
weight: 1,
|
||||
is_backup: false,
|
||||
enabled: true,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
let dummy_row: Vec<MockRow> = vec![];
|
||||
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(first)
|
||||
.append_query_results(second)
|
||||
// additional query result for regenerate_and_apply_config -> generate_config
|
||||
.append_query_results(vec![vec![(up_model.clone(), Some(target_model.clone()))]])
|
||||
.append_query_results(vec![dummy_row])
|
||||
.into_connection();
|
||||
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let payload = UpdateLocationRequestBody {
|
||||
path: Some("/new".to_string()),
|
||||
match_type: None,
|
||||
order: None,
|
||||
upstream_id: None,
|
||||
};
|
||||
|
||||
let res = server
|
||||
.patch(&format!("/locations/{}", loc_id))
|
||||
.json(&payload)
|
||||
.await;
|
||||
res.assert_status_ok();
|
||||
let body = res.json::<crate::routes::api::restricted::nginx::proxy_host::info::response::LocationInfoResponse>();
|
||||
assert_eq!(body.id, loc_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_update_location_not_found_returns_not_found() {
|
||||
let empty_results: Vec<Vec<location::Model>> = vec![Vec::<location::Model>::new()];
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(empty_results)
|
||||
.into_connection();
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let payload = UpdateLocationRequestBody {
|
||||
path: Some("/new".to_string()),
|
||||
match_type: None,
|
||||
order: None,
|
||||
upstream_id: None,
|
||||
};
|
||||
|
||||
let res = server
|
||||
.patch(&format!("/locations/{}", uuid::Uuid::new_v4()))
|
||||
.json(&payload)
|
||||
.await;
|
||||
res.assert_status(StatusCode::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UpdateLocationRequestBody> for UpdateLocationInfo {
|
||||
fn from(val: UpdateLocationRequestBody) -> Self {
|
||||
Self {
|
||||
path: val.path,
|
||||
match_type: val.match_type,
|
||||
order: val.order,
|
||||
upstream_id: val.upstream_id,
|
||||
proxy_pass_protocol: None,
|
||||
proxy_pass_host: None,
|
||||
proxy_pass_port: None,
|
||||
preserve_host_header: None,
|
||||
allowed_methods: None,
|
||||
custom_config: None,
|
||||
enabled: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[axum::debug_handler]
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/api/nginx/locations/{location_id}",
|
||||
request_body = UpdateLocationRequestBody,
|
||||
responses(
|
||||
(status = 200, description = "Location updated successfully", body = LocationInfoResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Not found"),
|
||||
(status = 422, description = "Invalid request"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
tag = NGINX_TAG,
|
||||
)]
|
||||
pub async fn update_location(
|
||||
_request_info: AuthenticatedRequestInfo,
|
||||
Path(location_id): Path<uuid::Uuid>,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(payload): Json<UpdateLocationRequestBody>,
|
||||
) -> AxumResult<Json<LocationInfoResponse>, ApiError> {
|
||||
let svc = &state.service.nginx.get_location_service();
|
||||
let update: UpdateLocationInfo = payload.into();
|
||||
|
||||
let mut tx = state.database_connection.begin().await?;
|
||||
let info = svc
|
||||
.update_location(location_id, update, Some(&mut tx))
|
||||
.await?;
|
||||
state
|
||||
.service
|
||||
.nginx
|
||||
.regenerate_and_apply_config(state.service.agent_client.clone(), &Some(&mut tx))
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Json(info.into()))
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
response::Result as AxumResult,
|
||||
};
|
||||
use sea_orm::TransactionTrait;
|
||||
|
||||
use crate::{
|
||||
errors::api_error::ApiError,
|
||||
middlewares::request_info::AuthenticatedRequestInfo,
|
||||
routes::{AppState, api::restricted::nginx::proxy_host::info::response::ProxyHostInfoResponse},
|
||||
services::nginx::info::proxy_host::UpdateProxyHostInfo,
|
||||
};
|
||||
|
||||
#[derive(serde::Deserialize, utoipa::ToSchema, serde::Serialize)]
|
||||
pub struct UpdateProxyRequestBody {
|
||||
pub name: Option<Option<String>>,
|
||||
pub domain: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum_test::TestServer;
|
||||
use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, MockRow};
|
||||
|
||||
use database::generated::entities::{proxy_host, upstream, upstream_target};
|
||||
|
||||
use super::UpdateProxyRequestBody;
|
||||
use crate::{
|
||||
configs::{FromConfig, ProgramSettings},
|
||||
middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER,
|
||||
routes::api::restricted::nginx::proxy_host::get_proxy_router,
|
||||
services::{agent_client::MockAgentService, get_mock_app_service},
|
||||
};
|
||||
|
||||
fn get_router_with_state(db: DatabaseConnection) -> axum::Router {
|
||||
let program_settings = ProgramSettings::mock();
|
||||
let mut mock = MockAgentService::new();
|
||||
mock.expect_validate().returning(|_cfg| Ok(()));
|
||||
mock.expect_apply().returning(|_cfg| Ok(()));
|
||||
let mock_agent = Arc::new(mock);
|
||||
let app_service =
|
||||
get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent);
|
||||
let state = Arc::new(crate::routes::AppState {
|
||||
database_connection: Arc::new(db),
|
||||
service: Arc::new(app_service),
|
||||
config: Arc::new(program_settings),
|
||||
});
|
||||
get_proxy_router(state).layer(axum::middleware::from_fn(
|
||||
crate::middlewares::require_auth::mock::mock_require_auth,
|
||||
))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_update_proxy_succeeds_returns_ok() {
|
||||
let ph_id = uuid::Uuid::new_v4();
|
||||
|
||||
let current = proxy_host::Model {
|
||||
id: ph_id,
|
||||
name: Some("oldname".to_string()),
|
||||
domain: "a.com".to_string(),
|
||||
scheme: "http".to_string(),
|
||||
listen_port: 80,
|
||||
forward_scheme: "http".to_string(),
|
||||
forward_host: None,
|
||||
forward_port: None,
|
||||
preserve_host_header: false,
|
||||
enable_websocket: false,
|
||||
enabled: true,
|
||||
meta: None,
|
||||
default_upstream_id: None,
|
||||
created_by: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let updated = proxy_host::Model { ..current.clone() };
|
||||
|
||||
let first: Vec<Vec<proxy_host::Model>> = vec![vec![current.clone()]];
|
||||
let second: Vec<Vec<proxy_host::Model>> = vec![vec![updated.clone()]];
|
||||
let up_id = uuid::Uuid::new_v4();
|
||||
let up_model = upstream::Model {
|
||||
id: up_id,
|
||||
name: "u1".to_string(),
|
||||
protocol: "http".to_string(),
|
||||
algorithm: "rr".to_string(),
|
||||
sticky_session: false,
|
||||
created_by: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
let target_model = upstream_target::Model {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
upstream_id: up_id,
|
||||
target_host: "127.0.0.1".to_string(),
|
||||
target_port: 8080,
|
||||
weight: 1,
|
||||
is_backup: false,
|
||||
enabled: true,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
let dummy_row: Vec<MockRow> = vec![];
|
||||
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(first)
|
||||
.append_query_results(second)
|
||||
// additional query result for regenerate_and_apply_config -> generate_config
|
||||
.append_query_results(vec![vec![(up_model.clone(), Some(target_model.clone()))]])
|
||||
.append_query_results(vec![dummy_row])
|
||||
.into_connection();
|
||||
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let payload = UpdateProxyRequestBody {
|
||||
name: Some(Some("newname".to_string())),
|
||||
domain: Some("a.com".to_string()),
|
||||
};
|
||||
|
||||
let res = server
|
||||
.patch(&format!("/proxy_hosts/{}", ph_id))
|
||||
.json(&payload)
|
||||
.await;
|
||||
res.assert_status_ok();
|
||||
let body: crate::routes::api::restricted::nginx::proxy_host::info::response::ProxyHostInfoResponse = res.json();
|
||||
assert_eq!(body.id, ph_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_update_proxy_unauthenticated_returns_unauthorized() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection();
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let payload = UpdateProxyRequestBody {
|
||||
name: Some(Some("newname".to_string())),
|
||||
domain: None,
|
||||
};
|
||||
|
||||
let res = server
|
||||
.patch(&format!("/proxy_hosts/{}", uuid::Uuid::new_v4()))
|
||||
.add_header(REQUEST_AUTH_USER_INVALID_HEADER, "true")
|
||||
.json(&payload)
|
||||
.await;
|
||||
|
||||
res.assert_status(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_update_proxy_not_found_returns_not_found() {
|
||||
let empty_results: Vec<Vec<proxy_host::Model>> = vec![Vec::<proxy_host::Model>::new()];
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(empty_results)
|
||||
.into_connection();
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let payload = UpdateProxyRequestBody {
|
||||
name: Some(Some("newname".to_string())),
|
||||
domain: None,
|
||||
};
|
||||
|
||||
let res = server
|
||||
.patch(&format!("/proxy_hosts/{}", uuid::Uuid::new_v4()))
|
||||
.json(&payload)
|
||||
.await;
|
||||
res.assert_status(StatusCode::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UpdateProxyRequestBody> for UpdateProxyHostInfo {
|
||||
fn from(val: UpdateProxyRequestBody) -> Self {
|
||||
Self {
|
||||
name: val.name,
|
||||
domain: val.domain,
|
||||
scheme: None,
|
||||
listen_port: None,
|
||||
forward_scheme: None,
|
||||
forward_host: None,
|
||||
forward_port: None,
|
||||
preserve_host_header: None,
|
||||
enable_websocket: None,
|
||||
enabled: None,
|
||||
meta: None,
|
||||
default_upstream_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[axum::debug_handler]
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/api/nginx/proxy_hosts/{proxy_id}",
|
||||
request_body = UpdateProxyRequestBody,
|
||||
responses(
|
||||
(status = 200, description = "Proxy updated successfully", body = ProxyHostInfoResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 422, description = "Invalid request"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
)]
|
||||
pub async fn update_proxy(
|
||||
_request_info: AuthenticatedRequestInfo,
|
||||
Path(proxy_id): Path<uuid::Uuid>,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(payload): Json<UpdateProxyRequestBody>,
|
||||
) -> AxumResult<Json<ProxyHostInfoResponse>, ApiError> {
|
||||
let svc = &state.service.nginx.get_proxy_service();
|
||||
let update: UpdateProxyHostInfo = payload.into();
|
||||
|
||||
let mut tx = state.database_connection.begin().await?;
|
||||
let info = svc.update_proxy(proxy_id, update, &Some(&mut tx)).await?;
|
||||
state
|
||||
.service
|
||||
.nginx
|
||||
.regenerate_and_apply_config(state.service.agent_client.clone(), &Some(&mut tx))
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Json(info.into()))
|
||||
}
|
||||
43
apps/api/src/routes/api/restricted/nginx/upstream.rs
Normal file
43
apps/api/src/routes/api/restricted/nginx/upstream.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
pub mod create_upstream;
|
||||
pub mod create_upstream_target;
|
||||
pub mod get_upstream;
|
||||
pub mod get_upstream_target;
|
||||
pub mod info;
|
||||
pub mod remove_upstream;
|
||||
pub mod remove_upstream_target;
|
||||
pub mod update_upstream;
|
||||
pub mod update_upstream_target;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use 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)
|
||||
.patch(update_upstream::update_upstream)
|
||||
.delete(remove_upstream::remove_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)
|
||||
.patch(update_upstream_target::update_upstream_target)
|
||||
.delete(remove_upstream_target::remove_upstream_target),
|
||||
)
|
||||
.with_state(state)
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{Json, extract::State, response::Result as AxumResult};
|
||||
use sea_orm::TransactionTrait;
|
||||
|
||||
use crate::{
|
||||
errors::api_error::ApiError,
|
||||
middlewares::request_info::AuthenticatedRequestInfo,
|
||||
routes::{
|
||||
AppState,
|
||||
api::{
|
||||
openapi::tag::NGINX_TAG,
|
||||
restricted::nginx::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]
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/nginx/upstreams",
|
||||
request_body = CreateUpstreamRequestBody,
|
||||
responses(
|
||||
(status = 200, description = "Upstream created successfully", body = UpstreamInfoResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 422, description = "Invalid request"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
tag = NGINX_TAG,
|
||||
)]
|
||||
pub async fn create_upstream(
|
||||
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 mut tx = state.database_connection.begin().await?;
|
||||
let upstream_info = upstream_service
|
||||
.create_upstream(create_info, Some(&mut tx))
|
||||
.await?;
|
||||
|
||||
state
|
||||
.service
|
||||
.nginx
|
||||
.regenerate_and_apply_config(state.service.agent_client.clone(), &Some(&mut tx))
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Json(upstream_info.into()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum_test::TestServer;
|
||||
use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, MockRow};
|
||||
|
||||
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::{agent_client::MockAgentService, get_mock_app_service},
|
||||
};
|
||||
|
||||
fn get_router_with_state(db: DatabaseConnection) -> axum::Router {
|
||||
let program_settings = ProgramSettings::mock();
|
||||
let mut mock = MockAgentService::new();
|
||||
mock.expect_validate().returning(|_cfg| Ok(()));
|
||||
mock.expect_apply().returning(|_cfg| Ok(()));
|
||||
let mock_agent = Arc::new(mock);
|
||||
let app_service =
|
||||
get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent);
|
||||
let state = Arc::new(crate::routes::AppState {
|
||||
database_connection: Arc::new(db),
|
||||
service: Arc::new(app_service),
|
||||
config: Arc::new(program_settings),
|
||||
});
|
||||
get_upstream_router(state).layer(axum::middleware::from_fn(
|
||||
crate::middlewares::require_auth::mock::mock_require_auth,
|
||||
))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_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(),
|
||||
};
|
||||
let dummy_row: Vec<MockRow> = vec![];
|
||||
|
||||
// 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()]])
|
||||
// additional query result for regenerate_and_apply_config -> generate_config
|
||||
// `find_with_related` returns rows of `(upstream, Option<target>)` which
|
||||
// the mock DB expects as `(Model, Option<Model>)` per row.
|
||||
.append_query_results(vec![vec![(up_model.clone(), Some(target_model.clone()))]])
|
||||
.append_query_results(vec![dummy_row])
|
||||
.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_agent_error_returns_internal() {
|
||||
let up_id = uuid::Uuid::new_v4();
|
||||
|
||||
let up_model = upstream::Model {
|
||||
id: up_id,
|
||||
name: "new_upstream".to_string(),
|
||||
protocol: "http".to_string(),
|
||||
algorithm: "round_robin".to_string(),
|
||||
sticky_session: false,
|
||||
created_by: Some(uuid::Uuid::new_v4()),
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let target_id = uuid::Uuid::new_v4();
|
||||
let target_model = upstream_target::Model {
|
||||
id: target_id,
|
||||
upstream_id: up_id,
|
||||
target_host: "127.0.0.1".to_string(),
|
||||
target_port: 8080,
|
||||
weight: 1,
|
||||
is_backup: false,
|
||||
enabled: true,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
// configure mock agent to error on apply
|
||||
let mut mock = MockAgentService::new();
|
||||
mock.expect_validate().returning(|_cfg| Ok(()));
|
||||
mock.expect_apply().returning(|_cfg| {
|
||||
Err(
|
||||
crate::services::agent_client::AgentError::ApplicationFailed(
|
||||
"internal".to_string(),
|
||||
"Failed to communicate with the agent.".to_string(),
|
||||
),
|
||||
)
|
||||
});
|
||||
let mock_agent = Arc::new(mock);
|
||||
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![vec![up_model.clone()]])
|
||||
.append_query_results(vec![vec![target_model.clone()]])
|
||||
.append_query_results(vec![vec![(up_model.clone(), Some(target_model.clone()))]])
|
||||
.into_connection();
|
||||
|
||||
let program_settings = ProgramSettings::mock();
|
||||
let app_service =
|
||||
get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent);
|
||||
let state = Arc::new(crate::routes::AppState {
|
||||
database_connection: Arc::new(db),
|
||||
service: Arc::new(app_service),
|
||||
config: Arc::new(program_settings),
|
||||
});
|
||||
|
||||
let router = get_upstream_router(state).layer(axum::middleware::from_fn(
|
||||
crate::middlewares::require_auth::mock::mock_require_auth,
|
||||
));
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let payload = CreateUpstreamRequestBody {
|
||||
name: "new_upstream".to_string(),
|
||||
protocol: "http".to_string(),
|
||||
algorithm: None,
|
||||
sticky_session: None,
|
||||
upstream_targets: vec![ReqTarget {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 8080,
|
||||
weight: None,
|
||||
is_backup: None,
|
||||
enabled: None,
|
||||
}],
|
||||
};
|
||||
|
||||
let res = server.post("/upstreams").json(&payload).await;
|
||||
res.assert_status(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_create_upstream_unauthenticated_returns_unauthorized() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection();
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let payload = CreateUpstreamRequestBody {
|
||||
name: "new_upstream".to_string(),
|
||||
protocol: "http".to_string(),
|
||||
algorithm: None,
|
||||
sticky_session: None,
|
||||
upstream_targets: vec![ReqTarget {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 8080,
|
||||
weight: None,
|
||||
is_backup: None,
|
||||
enabled: None,
|
||||
}],
|
||||
};
|
||||
|
||||
let res = server
|
||||
.post("/upstreams")
|
||||
.add_header(REQUEST_AUTH_USER_INVALID_HEADER, "true")
|
||||
.json(&payload)
|
||||
.await;
|
||||
|
||||
res.assert_status(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{Json, extract::State, response::Result as AxumResult};
|
||||
use sea_orm::TransactionTrait;
|
||||
|
||||
use crate::{
|
||||
errors::api_error::ApiError,
|
||||
middlewares::request_info::AuthenticatedRequestInfo,
|
||||
routes::{
|
||||
AppState,
|
||||
api::{
|
||||
openapi::tag::NGINX_TAG,
|
||||
restricted::nginx::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]
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/nginx/upstreams/{upstream_id}/targets",
|
||||
request_body = CreateUpstreamTargetInfo,
|
||||
responses(
|
||||
(status = 200, description = "Upstream target created successfully", body = UpstreamTargetInfoResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 422, description = "Invalid request"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
tag = NGINX_TAG,
|
||||
)]
|
||||
pub async fn add_upstream_target(
|
||||
_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 mut tx = state.database_connection.begin().await?;
|
||||
let upstream_info = upstream_service
|
||||
.create_upstream_target(create_info, Some(&mut tx))
|
||||
.await?;
|
||||
|
||||
state
|
||||
.service
|
||||
.nginx
|
||||
.regenerate_and_apply_config(state.service.agent_client.clone(), &Some(&mut tx))
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Json(upstream_info.into()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum_test::TestServer;
|
||||
use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, MockRow};
|
||||
|
||||
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_target::CreateUpstreamTargetInfo, get_upstream_router,
|
||||
},
|
||||
services::{agent_client::MockAgentService, get_mock_app_service},
|
||||
};
|
||||
|
||||
fn get_router_with_state(db: DatabaseConnection) -> axum::Router {
|
||||
let program_settings = ProgramSettings::mock();
|
||||
let mut mock = MockAgentService::new();
|
||||
mock.expect_validate().returning(|_cfg| Ok(()));
|
||||
mock.expect_apply().returning(|_cfg| Ok(()));
|
||||
let mock_agent = Arc::new(mock);
|
||||
let app_service =
|
||||
get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent);
|
||||
let state = Arc::new(crate::routes::AppState {
|
||||
database_connection: Arc::new(db),
|
||||
service: Arc::new(app_service),
|
||||
config: Arc::new(program_settings),
|
||||
});
|
||||
get_upstream_router(state).layer(axum::middleware::from_fn(
|
||||
crate::middlewares::require_auth::mock::mock_require_auth,
|
||||
))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_add_upstream_target_agent_error_returns_internal() {
|
||||
let up_id = uuid::Uuid::new_v4();
|
||||
|
||||
let target_id = uuid::Uuid::new_v4();
|
||||
let target_model = upstream_target::Model {
|
||||
id: target_id,
|
||||
upstream_id: up_id,
|
||||
target_host: "127.0.0.1".to_string(),
|
||||
target_port: 8080,
|
||||
weight: 1,
|
||||
is_backup: false,
|
||||
enabled: true,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let up_model = upstream::Model {
|
||||
id: up_id,
|
||||
name: "test_upstream".to_string(),
|
||||
protocol: "http".to_string(),
|
||||
algorithm: "round_robin".to_string(),
|
||||
sticky_session: false,
|
||||
created_by: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
let dummy_row: Vec<MockRow> = vec![];
|
||||
|
||||
// configure mock agent to return an error on apply
|
||||
let mut mock = MockAgentService::new();
|
||||
mock.expect_validate().returning(|_cfg| Ok(()));
|
||||
mock.expect_apply().returning(|_cfg| {
|
||||
Err(
|
||||
crate::services::agent_client::AgentError::ApplicationFailed(
|
||||
"internal".to_string(),
|
||||
"Failed to communicate with the agent.".to_string(),
|
||||
),
|
||||
)
|
||||
});
|
||||
let mock_agent = Arc::new(mock);
|
||||
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![vec![target_model.clone()]])
|
||||
.append_query_results(vec![vec![(up_model.clone(), Some(target_model.clone()))]])
|
||||
.append_query_results(vec![dummy_row])
|
||||
.into_connection();
|
||||
|
||||
let program_settings = ProgramSettings::mock();
|
||||
let app_service =
|
||||
get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent);
|
||||
let state = Arc::new(crate::routes::AppState {
|
||||
database_connection: Arc::new(db),
|
||||
service: Arc::new(app_service),
|
||||
config: Arc::new(program_settings),
|
||||
});
|
||||
|
||||
let router = get_upstream_router(state).layer(axum::middleware::from_fn(
|
||||
crate::middlewares::require_auth::mock::mock_require_auth,
|
||||
));
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let payload = CreateUpstreamTargetInfo {
|
||||
upstream_id: up_id,
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 8080,
|
||||
weight: None,
|
||||
is_backup: None,
|
||||
enabled: None,
|
||||
};
|
||||
|
||||
let res = server
|
||||
.post(&format!("/upstreams/{}/targets", up_id))
|
||||
.json(&payload)
|
||||
.await;
|
||||
|
||||
res.assert_status(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
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 up_model = upstream::Model {
|
||||
id: up_id,
|
||||
name: "test_upstream".to_string(),
|
||||
protocol: "http".to_string(),
|
||||
algorithm: "round_robin".to_string(),
|
||||
sticky_session: false,
|
||||
created_by: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
let dummy_row: Vec<MockRow> = vec![];
|
||||
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![vec![target_model.clone()]])
|
||||
// additional query result for regenerate_and_apply_config -> generate_config
|
||||
.append_query_results(vec![vec![(up_model.clone(), Some(target_model.clone()))]])
|
||||
.append_query_results(vec![dummy_row])
|
||||
.into_connection();
|
||||
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let payload = CreateUpstreamTargetInfo {
|
||||
upstream_id: up_id,
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 8080,
|
||||
weight: None,
|
||||
is_backup: None,
|
||||
enabled: None,
|
||||
};
|
||||
|
||||
let res = server
|
||||
.post(&format!("/upstreams/{}/targets", up_id))
|
||||
.json(&payload)
|
||||
.await;
|
||||
|
||||
res.assert_status_ok();
|
||||
let text = res.text();
|
||||
let body: crate::routes::api::restricted::nginx::upstream::info::response::UpstreamTargetInfoResponse =
|
||||
serde_json::from_str(&text).expect("failed to parse json");
|
||||
|
||||
assert_eq!(body.id, target_id);
|
||||
assert_eq!(body.host, "127.0.0.1");
|
||||
assert_eq!(body.port, 8080);
|
||||
assert_eq!(body.upstream_id, up_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_add_upstream_target_invalid_payload_returns_bad_request() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection();
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let res = server
|
||||
.post(&format!("/upstreams/{}/targets", uuid::Uuid::new_v4()))
|
||||
.json(&serde_json::json!({}))
|
||||
.await;
|
||||
|
||||
res.assert_status(StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_add_upstream_target_unauthenticated_returns_unauthorized() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection();
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let payload = CreateUpstreamTargetInfo {
|
||||
upstream_id: uuid::Uuid::new_v4(),
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 8080,
|
||||
weight: None,
|
||||
is_backup: None,
|
||||
enabled: None,
|
||||
};
|
||||
|
||||
let res = server
|
||||
.post(&format!("/upstreams/{}/targets", payload.upstream_id))
|
||||
.add_header(REQUEST_AUTH_USER_INVALID_HEADER, "true")
|
||||
.json(&payload)
|
||||
.await;
|
||||
|
||||
res.assert_status(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
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},
|
||||
openapi::tag::NGINX_TAG,
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/nginx/upstreams",
|
||||
responses(
|
||||
(status = 200, description = "List upstreams", body = UpstreamListResponse),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
tag = NGINX_TAG,
|
||||
)]
|
||||
pub async fn get_upstream_list(
|
||||
ExtractPagination(pagination): ExtractPagination,
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> AxumResult<Json<UpstreamListResponse>, ServiceError> {
|
||||
let upstream_service = &state.service.nginx.get_upstream_service();
|
||||
|
||||
let (upstreams_res, upstream_count_res) = tokio::join!(
|
||||
upstream_service.get_upstreams(
|
||||
Some(pagination.clone().into()),
|
||||
Some(GetUpstreamOptions {
|
||||
include_targets: true,
|
||||
filter_by_enabled: false,
|
||||
}),
|
||||
None,
|
||||
),
|
||||
upstream_service.get_total_upstreams(None, None),
|
||||
);
|
||||
|
||||
let upstreams = upstreams_res?;
|
||||
let upstream_count = upstream_count_res?;
|
||||
|
||||
//
|
||||
Ok(Json(UpstreamListResponse {
|
||||
items: upstreams.into_iter().map(|u| u.into()).collect(),
|
||||
pagination: PaginationInfo {
|
||||
total_items: upstream_count,
|
||||
total_pages: if upstream_count == 0 {
|
||||
0
|
||||
} else {
|
||||
(upstream_count as f32 / pagination.per_page as f32).ceil() as u32
|
||||
},
|
||||
current_page: pagination.page,
|
||||
per_page: pagination.per_page,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/nginx/upstreams/{upstream_id}",
|
||||
responses(
|
||||
(status = 200, description = "Get upstream info", body = UpstreamInfoResponse),
|
||||
(status = 404, description = "Not found"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
tag = NGINX_TAG,
|
||||
)]
|
||||
pub async fn get_upstream(
|
||||
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,
|
||||
filter_by_enabled: false,
|
||||
}),
|
||||
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::{collections::BTreeMap, sync::Arc};
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum_test::TestServer;
|
||||
use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, Value};
|
||||
|
||||
use database::generated::entities::{upstream, upstream_target};
|
||||
|
||||
use crate::{
|
||||
configs::{FromConfig, ProgramSettings},
|
||||
routes::api::restricted::nginx::upstream::{
|
||||
get_upstream_router, info::response::UpstreamInfoResponse,
|
||||
},
|
||||
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(), None::<upstream_target::Model>),
|
||||
(u2.clone(), None::<upstream_target::Model>),
|
||||
]])
|
||||
.append_query_results(vec![vec![BTreeMap::from([(
|
||||
"count".to_string(),
|
||||
Value::BigInt(Some(2)),
|
||||
)])]])
|
||||
.into_connection();
|
||||
|
||||
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);
|
||||
assert_eq!(body.pagination.total_pages, 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()])
|
||||
.append_query_results(vec![vec![BTreeMap::from([(
|
||||
"count".to_string(),
|
||||
Value::BigInt(Some(0)),
|
||||
)])]])
|
||||
.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()])
|
||||
.append_query_results(vec![vec![BTreeMap::from([(
|
||||
"count".to_string(),
|
||||
Value::BigInt(Some(0)),
|
||||
)])]])
|
||||
.into_connection();
|
||||
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let res = server.get("/upstreams?page=3&per_page=10").await;
|
||||
res.assert_status_ok();
|
||||
let body = res.json::<UpstreamListResponse>();
|
||||
assert_eq!(body.items.len(), 0);
|
||||
assert_eq!(body.pagination.current_page, 3u32);
|
||||
assert_eq!(body.pagination.per_page, 10u32);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
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::{
|
||||
openapi::tag::NGINX_TAG,
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/nginx/upstream_targets/{upstream_target_id}",
|
||||
responses(
|
||||
(status = 200, description = "Get upstream target info", body = UpstreamTargetInfo),
|
||||
(status = 404, description = "Not found"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
tag = NGINX_TAG,
|
||||
)]
|
||||
pub async fn get_upstream_target(
|
||||
Path(upstream_target_id): Path<Uuid>,
|
||||
Query(params): Query<GetUpstreamTargetsParams>,
|
||||
State(_state): State<Arc<AppState>>,
|
||||
) -> AxumResult<Json<UpstreamTargetInfo>, ApiError> {
|
||||
let concrete_params: ConcreteGetUpstreamTargetsParams = params.into();
|
||||
let upstream_service = &_state.service.nginx.get_upstream_service();
|
||||
let upstream_target_info = upstream_service
|
||||
.get_upstream_target(
|
||||
upstream_target_id,
|
||||
if concrete_params.include_upstream {
|
||||
Some(crate::services::nginx::upstream::GetUpstreamTargetOptions {
|
||||
include_upstream: true,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(upstream_target_info.into()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum_test::TestServer;
|
||||
use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase};
|
||||
|
||||
use database::generated::entities::{upstream, upstream_target};
|
||||
|
||||
use crate::configs::{FromConfig, ProgramSettings};
|
||||
|
||||
use crate::routes::api::restricted::nginx::upstream::get_upstream_router;
|
||||
use crate::services::get_app_service;
|
||||
|
||||
fn get_router_with_state(db: DatabaseConnection) -> axum::Router {
|
||||
let program_settings = ProgramSettings::mock();
|
||||
let app_service = get_app_service(&Arc::new(db.clone()), &program_settings);
|
||||
let state = Arc::new(crate::routes::AppState {
|
||||
database_connection: Arc::new(db),
|
||||
service: Arc::new(app_service),
|
||||
config: Arc::new(program_settings),
|
||||
});
|
||||
get_upstream_router(state)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_get_upstream_target_with_upstream_returns_upstream() {
|
||||
let up_id = uuid::Uuid::new_v4();
|
||||
|
||||
let up_model = upstream::Model {
|
||||
id: up_id,
|
||||
name: "with_targets".to_string(),
|
||||
protocol: "http".to_string(),
|
||||
algorithm: "least_conn".to_string(),
|
||||
sticky_session: true,
|
||||
created_by: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let target_id = uuid::Uuid::new_v4();
|
||||
let target_model = upstream_target::Model {
|
||||
id: target_id,
|
||||
upstream_id: up_id,
|
||||
target_host: "127.0.0.1".to_string(),
|
||||
target_port: 8080,
|
||||
weight: 1,
|
||||
is_backup: false,
|
||||
enabled: true,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
// query returns joined (upstream_target, upstream)
|
||||
.append_query_results(vec![vec![(target_model.clone(), Some(up_model.clone()))]])
|
||||
.into_connection();
|
||||
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let url = format!("/upstream_targets/{}?include_upstream=true", target_id);
|
||||
let res = server.get(&url).await;
|
||||
res.assert_status_ok();
|
||||
let text = res.text();
|
||||
let body: UpstreamTargetInfo = serde_json::from_str(&text).expect("failed to parse json");
|
||||
assert_eq!(body.upstream_id, up_id);
|
||||
assert!(body.upstream.is_some());
|
||||
let upstream = body.upstream.expect("upstream to be present");
|
||||
assert_eq!(upstream.id, up_id);
|
||||
assert_eq!(upstream.name, "with_targets");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_get_upstream_target_without_upstream_returns_info() {
|
||||
let target_id = uuid::Uuid::new_v4();
|
||||
|
||||
let target_model = upstream_target::Model {
|
||||
id: target_id,
|
||||
upstream_id: uuid::Uuid::new_v4(),
|
||||
target_host: "10.0.0.1".to_string(),
|
||||
target_port: 9090,
|
||||
weight: 5,
|
||||
is_backup: false,
|
||||
enabled: true,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![vec![target_model.clone()]])
|
||||
.into_connection();
|
||||
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let url = format!("/upstream_targets/{}", target_id);
|
||||
let res = server.get(&url).await;
|
||||
res.assert_status_ok();
|
||||
let text = res.text();
|
||||
let body: UpstreamTargetInfo = serde_json::from_str(&text).expect("failed to parse json");
|
||||
assert_eq!(body.id, target_id);
|
||||
assert!(body.upstream.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_get_upstream_target_not_found_returns_service_error() {
|
||||
let target_id = uuid::Uuid::new_v4();
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![Vec::<sea_orm::MockRow>::new()])
|
||||
.into_connection();
|
||||
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let url = format!("/upstream_targets/{}?include_upstream=false", target_id);
|
||||
let res = server.get(&url).await;
|
||||
res.assert_status(StatusCode::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod response;
|
||||
@@ -0,0 +1,232 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct UpdateUpstreamInfoResponse {
|
||||
pub id: uuid::Uuid,
|
||||
pub name: String,
|
||||
pub protocol: String,
|
||||
pub algorithm: String,
|
||||
pub sticky_session: bool,
|
||||
pub created_by: Option<uuid::Uuid>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
//
|
||||
pub upstream_targets: Vec<UpstreamTargetBasicInfo>,
|
||||
}
|
||||
|
||||
impl From<crate::services::nginx::info::upstream::UpstreamInfo> for UpdateUpstreamInfoResponse {
|
||||
fn from(info: crate::services::nginx::info::upstream::UpstreamInfo) -> Self {
|
||||
Self {
|
||||
id: info.id,
|
||||
name: info.name,
|
||||
protocol: info.protocol,
|
||||
algorithm: info.algorithm,
|
||||
sticky_session: info.sticky_session,
|
||||
created_by: info.created_by,
|
||||
created_at: info.created_at,
|
||||
updated_at: info.updated_at,
|
||||
upstream_targets: info
|
||||
.upstream_targets
|
||||
.into_iter()
|
||||
.map(|t| t.into())
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct UpdateUpstreamTargetInfoResponse {
|
||||
pub id: uuid::Uuid,
|
||||
pub host: String,
|
||||
pub port: i64,
|
||||
pub enabled: bool,
|
||||
pub is_backup: bool,
|
||||
pub weight: i32,
|
||||
//
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
//
|
||||
pub upstream_id: Uuid,
|
||||
}
|
||||
|
||||
impl From<crate::services::nginx::info::upstream_target::UpstreamTargetInfo>
|
||||
for UpdateUpstreamTargetInfoResponse
|
||||
{
|
||||
fn from(info: crate::services::nginx::info::upstream_target::UpstreamTargetInfo) -> Self {
|
||||
Self {
|
||||
id: info.id,
|
||||
host: info.target_host,
|
||||
port: info.target_port,
|
||||
enabled: info.enabled,
|
||||
is_backup: info.is_backup,
|
||||
weight: info.weight as i32,
|
||||
//
|
||||
created_at: info.created_at,
|
||||
updated_at: info.updated_at,
|
||||
upstream_id: info.upstream_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
response::Result as AxumResult,
|
||||
};
|
||||
use sea_orm::TransactionTrait;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
errors::api_error::ApiError,
|
||||
middlewares::request_info::AuthenticatedRequestInfo,
|
||||
routes::{AppState, api::openapi::tag::NGINX_TAG},
|
||||
};
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/nginx/upstreams/{upstream_id}",
|
||||
responses(
|
||||
(status = 200, description = "Upstream removed successfully", body = ()),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Not found"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
tag = NGINX_TAG,
|
||||
)]
|
||||
pub async fn remove_upstream(
|
||||
_request_info: AuthenticatedRequestInfo,
|
||||
Path(upstream_id): Path<Uuid>,
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> AxumResult<Json<()>, ApiError> {
|
||||
let upstream_service = &state.service.nginx.get_upstream_service();
|
||||
|
||||
let mut tx = state.database_connection.begin().await?;
|
||||
upstream_service
|
||||
.delete_upstream(upstream_id, Some(&mut tx))
|
||||
.await?;
|
||||
|
||||
state
|
||||
.service
|
||||
.nginx
|
||||
.regenerate_and_apply_config(state.service.agent_client.clone(), &Some(&mut tx))
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum_test::TestServer;
|
||||
use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, MockExecResult, MockRow};
|
||||
|
||||
use database::generated::entities::{upstream, upstream_target};
|
||||
|
||||
use crate::{
|
||||
configs::{FromConfig, ProgramSettings},
|
||||
middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER,
|
||||
routes::api::restricted::nginx::upstream::get_upstream_router,
|
||||
services::{agent_client::MockAgentService, get_mock_app_service},
|
||||
};
|
||||
|
||||
fn get_router_with_state(db: DatabaseConnection) -> axum::Router {
|
||||
let program_settings = ProgramSettings::mock();
|
||||
let mut mock = MockAgentService::new();
|
||||
mock.expect_validate().returning(|_cfg| Ok(()));
|
||||
mock.expect_apply().returning(|_cfg| Ok(()));
|
||||
let mock_agent = Arc::new(mock);
|
||||
let app_service =
|
||||
get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent);
|
||||
let state = Arc::new(crate::routes::AppState {
|
||||
database_connection: Arc::new(db),
|
||||
service: Arc::new(app_service),
|
||||
config: Arc::new(program_settings),
|
||||
});
|
||||
get_upstream_router(state).layer(axum::middleware::from_fn(
|
||||
crate::middlewares::require_auth::mock::mock_require_auth,
|
||||
))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_remove_upstream_succeeds_returns_ok() {
|
||||
let up_id = uuid::Uuid::new_v4();
|
||||
|
||||
let existing = upstream::Model {
|
||||
id: up_id,
|
||||
name: "todelete".to_string(),
|
||||
protocol: "http".to_string(),
|
||||
algorithm: "rr".to_string(),
|
||||
sticky_session: false,
|
||||
created_by: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let target_model = upstream_target::Model {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
upstream_id: up_id,
|
||||
target_host: "127.0.0.1".to_string(),
|
||||
target_port: 8080,
|
||||
weight: 1,
|
||||
is_backup: false,
|
||||
enabled: true,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
let dummy_row: Vec<MockRow> = vec![];
|
||||
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![vec![existing.clone()]])
|
||||
.append_exec_results(vec![
|
||||
MockExecResult {
|
||||
rows_affected: 1,
|
||||
last_insert_id: 0,
|
||||
},
|
||||
MockExecResult {
|
||||
rows_affected: 1,
|
||||
last_insert_id: 0,
|
||||
},
|
||||
])
|
||||
// additional query result for regenerate_and_apply_config -> generate_config
|
||||
.append_query_results(vec![vec![(existing.clone(), Some(target_model.clone()))]])
|
||||
.append_query_results(vec![dummy_row])
|
||||
.into_connection();
|
||||
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let res = server.delete(&format!("/upstreams/{}", up_id)).await;
|
||||
|
||||
res.assert_status_ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_remove_upstream_agent_error_returns_internal() {
|
||||
let up_id = uuid::Uuid::new_v4();
|
||||
|
||||
let existing = upstream::Model {
|
||||
id: up_id,
|
||||
name: "todelete".to_string(),
|
||||
protocol: "http".to_string(),
|
||||
algorithm: "rr".to_string(),
|
||||
sticky_session: false,
|
||||
created_by: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let target_model = upstream_target::Model {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
upstream_id: up_id,
|
||||
target_host: "127.0.0.1".to_string(),
|
||||
target_port: 8080,
|
||||
weight: 1,
|
||||
is_backup: false,
|
||||
enabled: true,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let mut mock = MockAgentService::new();
|
||||
mock.expect_validate().returning(|_cfg| Ok(()));
|
||||
mock.expect_apply().returning(|_cfg| {
|
||||
Err(
|
||||
crate::services::agent_client::AgentError::ApplicationFailed(
|
||||
"internal".to_string(),
|
||||
"Failed to communicate with the agent.".to_string(),
|
||||
),
|
||||
)
|
||||
});
|
||||
let mock_agent = Arc::new(mock);
|
||||
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![vec![existing.clone()]])
|
||||
.append_exec_results(vec![
|
||||
MockExecResult {
|
||||
rows_affected: 1,
|
||||
last_insert_id: 0,
|
||||
},
|
||||
MockExecResult {
|
||||
rows_affected: 1,
|
||||
last_insert_id: 0,
|
||||
},
|
||||
])
|
||||
.append_query_results(vec![vec![(existing.clone(), Some(target_model.clone()))]])
|
||||
.into_connection();
|
||||
|
||||
let program_settings = ProgramSettings::mock();
|
||||
let app_service =
|
||||
get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent);
|
||||
let state = Arc::new(crate::routes::AppState {
|
||||
database_connection: Arc::new(db),
|
||||
service: Arc::new(app_service),
|
||||
config: Arc::new(program_settings),
|
||||
});
|
||||
|
||||
let router = get_upstream_router(state).layer(axum::middleware::from_fn(
|
||||
crate::middlewares::require_auth::mock::mock_require_auth,
|
||||
));
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let res = server.delete(&format!("/upstreams/{}", up_id)).await;
|
||||
res.assert_status(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_remove_upstream_unauthenticated_returns_unauthorized() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection();
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let res = server
|
||||
.delete(&format!("/upstreams/{}", uuid::Uuid::new_v4()))
|
||||
.add_header(REQUEST_AUTH_USER_INVALID_HEADER, "true")
|
||||
.await;
|
||||
|
||||
res.assert_status(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_remove_upstream_not_found_returns_not_found() {
|
||||
let empty_results: Vec<Vec<upstream::Model>> = vec![Vec::<upstream::Model>::new()];
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(empty_results)
|
||||
.into_connection();
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let res = server
|
||||
.delete(&format!("/upstreams/{}", uuid::Uuid::new_v4()))
|
||||
.await;
|
||||
|
||||
res.assert_status(StatusCode::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
response::Result as AxumResult,
|
||||
};
|
||||
use sea_orm::TransactionTrait;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
errors::api_error::ApiError,
|
||||
middlewares::request_info::AuthenticatedRequestInfo,
|
||||
routes::{AppState, api::openapi::tag::NGINX_TAG},
|
||||
};
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/nginx/upstream_targets/{upstream_target_id}",
|
||||
responses(
|
||||
(status = 200, description = "Upstream target removed successfully", body = ()),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Not found"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
tag = NGINX_TAG,
|
||||
)]
|
||||
pub async fn remove_upstream_target(
|
||||
_request_info: AuthenticatedRequestInfo,
|
||||
Path(upstream_target_id): Path<Uuid>,
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> AxumResult<Json<()>, ApiError> {
|
||||
let upstream_service = &state.service.nginx.get_upstream_service();
|
||||
|
||||
let mut tx = state.database_connection.begin().await?;
|
||||
upstream_service
|
||||
.delete_upstream_target(upstream_target_id, Some(&mut tx))
|
||||
.await?;
|
||||
|
||||
state
|
||||
.service
|
||||
.nginx
|
||||
.regenerate_and_apply_config(state.service.agent_client.clone(), &Some(&mut tx))
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum_test::TestServer;
|
||||
use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, MockExecResult, MockRow};
|
||||
|
||||
use database::generated::entities::{upstream, upstream_target};
|
||||
|
||||
use crate::{
|
||||
configs::{FromConfig, ProgramSettings},
|
||||
middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER,
|
||||
routes::api::restricted::nginx::upstream::get_upstream_router,
|
||||
services::{agent_client::MockAgentService, get_mock_app_service},
|
||||
};
|
||||
|
||||
fn get_router_with_state(db: DatabaseConnection) -> axum::Router {
|
||||
let program_settings = ProgramSettings::mock();
|
||||
let mut mock = MockAgentService::new();
|
||||
mock.expect_validate().returning(|_cfg| Ok(()));
|
||||
mock.expect_apply().returning(|_cfg| Ok(()));
|
||||
let mock_agent = Arc::new(mock);
|
||||
let app_service =
|
||||
get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent);
|
||||
let state = Arc::new(crate::routes::AppState {
|
||||
database_connection: Arc::new(db),
|
||||
service: Arc::new(app_service),
|
||||
config: Arc::new(program_settings),
|
||||
});
|
||||
get_upstream_router(state).layer(axum::middleware::from_fn(
|
||||
crate::middlewares::require_auth::mock::mock_require_auth,
|
||||
))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_remove_upstream_target_succeeds_returns_ok() {
|
||||
let ut_id = uuid::Uuid::new_v4();
|
||||
|
||||
let current_model = upstream_target::Model {
|
||||
id: ut_id,
|
||||
upstream_id: uuid::Uuid::new_v4(),
|
||||
target_host: "127.0.0.1".to_string(),
|
||||
target_port: 8080,
|
||||
weight: 1,
|
||||
is_backup: false,
|
||||
enabled: true,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
// first find_by_id, then delete (delete typically doesn't return models)
|
||||
let up_model = upstream::Model {
|
||||
id: current_model.upstream_id,
|
||||
name: "test_upstream".to_string(),
|
||||
protocol: "http".to_string(),
|
||||
algorithm: "round_robin".to_string(),
|
||||
sticky_session: false,
|
||||
created_by: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
let dummy_row: Vec<MockRow> = vec![];
|
||||
|
||||
let first: Vec<Vec<upstream_target::Model>> = vec![vec![current_model.clone()]];
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(first)
|
||||
.append_exec_results(vec![MockExecResult {
|
||||
rows_affected: 1,
|
||||
last_insert_id: 0,
|
||||
}])
|
||||
// additional query result for regenerate_and_apply_config -> generate_config
|
||||
.append_query_results(vec![vec![(up_model.clone(), Some(current_model.clone()))]])
|
||||
.append_query_results(vec![dummy_row])
|
||||
.into_connection();
|
||||
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let res = server.delete(&format!("/upstream_targets/{}", ut_id)).await;
|
||||
|
||||
res.assert_status_ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_remove_upstream_target_agent_error_returns_internal() {
|
||||
let ut_id = uuid::Uuid::new_v4();
|
||||
|
||||
let current_model = upstream_target::Model {
|
||||
id: ut_id,
|
||||
upstream_id: uuid::Uuid::new_v4(),
|
||||
target_host: "127.0.0.1".to_string(),
|
||||
target_port: 8080,
|
||||
weight: 1,
|
||||
is_backup: false,
|
||||
enabled: true,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let up_model = upstream::Model {
|
||||
id: current_model.upstream_id,
|
||||
name: "test_upstream".to_string(),
|
||||
protocol: "http".to_string(),
|
||||
algorithm: "round_robin".to_string(),
|
||||
sticky_session: false,
|
||||
created_by: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let mut mock = MockAgentService::new();
|
||||
mock.expect_validate().returning(|_cfg| Ok(()));
|
||||
mock.expect_apply().returning(|_cfg| {
|
||||
Err(
|
||||
crate::services::agent_client::AgentError::ApplicationFailed(
|
||||
"internal".to_string(),
|
||||
"Failed to communicate with the agent.".to_string(),
|
||||
),
|
||||
)
|
||||
});
|
||||
let mock_agent = Arc::new(mock);
|
||||
|
||||
let first: Vec<Vec<upstream_target::Model>> = vec![vec![current_model.clone()]];
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(first)
|
||||
.append_exec_results(vec![MockExecResult {
|
||||
rows_affected: 1,
|
||||
last_insert_id: 0,
|
||||
}])
|
||||
.append_query_results(vec![vec![(up_model.clone(), Some(current_model.clone()))]])
|
||||
.into_connection();
|
||||
|
||||
let program_settings = ProgramSettings::mock();
|
||||
let app_service =
|
||||
get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent);
|
||||
let state = Arc::new(crate::routes::AppState {
|
||||
database_connection: Arc::new(db),
|
||||
service: Arc::new(app_service),
|
||||
config: Arc::new(program_settings),
|
||||
});
|
||||
|
||||
let router = get_upstream_router(state).layer(axum::middleware::from_fn(
|
||||
crate::middlewares::require_auth::mock::mock_require_auth,
|
||||
));
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let res = server.delete(&format!("/upstream_targets/{}", ut_id)).await;
|
||||
res.assert_status(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_remove_upstream_target_unauthenticated_returns_unauthorized() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection();
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let res = server
|
||||
.delete(&format!("/upstream_targets/{}", uuid::Uuid::new_v4()))
|
||||
.add_header(REQUEST_AUTH_USER_INVALID_HEADER, "true")
|
||||
.await;
|
||||
|
||||
res.assert_status(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_remove_upstream_target_not_found_returns_not_found() {
|
||||
let empty_results: Vec<Vec<upstream_target::Model>> =
|
||||
vec![Vec::<upstream_target::Model>::new()];
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(empty_results)
|
||||
.into_connection();
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let res = server
|
||||
.delete(&format!("/upstream_targets/{}", uuid::Uuid::new_v4()))
|
||||
.await;
|
||||
|
||||
res.assert_status(StatusCode::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
response::Result as AxumResult,
|
||||
};
|
||||
use sea_orm::TransactionTrait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
errors::api_error::ApiError,
|
||||
middlewares::request_info::AuthenticatedRequestInfo,
|
||||
routes::{
|
||||
AppState, api::openapi::tag::NGINX_TAG,
|
||||
api::restricted::nginx::upstream::info::response::UpdateUpstreamInfoResponse,
|
||||
},
|
||||
services::nginx::info::upstream::UpdateUpstreamInfo,
|
||||
};
|
||||
|
||||
#[derive(Deserialize, utoipa::ToSchema, Serialize)]
|
||||
pub struct UpstreamTargetBasicUpdateInfo {
|
||||
pub id: i64,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, utoipa::ToSchema, Serialize)]
|
||||
pub struct UpdateUpstreamRequestBody {
|
||||
pub name: Option<String>,
|
||||
pub protocol: Option<String>,
|
||||
pub algorithm: Option<String>,
|
||||
pub sticky_session: Option<bool>,
|
||||
// only updates upstream targets' enabled status for now
|
||||
pub upstream_targets: Option<Vec<UpstreamTargetBasicUpdateInfo>>,
|
||||
}
|
||||
|
||||
impl From<UpdateUpstreamRequestBody> for UpdateUpstreamInfo {
|
||||
fn from(val: UpdateUpstreamRequestBody) -> Self {
|
||||
Self {
|
||||
name: val.name,
|
||||
protocol: val.protocol,
|
||||
algorithm: val.algorithm,
|
||||
sticky_session: val.sticky_session,
|
||||
//
|
||||
upstream_targets: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/api/nginx/upstreams/{upstream_id}",
|
||||
request_body = UpdateUpstreamRequestBody,
|
||||
responses(
|
||||
(status = 200, description = "Upstream updated successfully", body = UpdateUpstreamInfoResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Not found"),
|
||||
(status = 422, description = "Invalid request"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
tag = NGINX_TAG,
|
||||
)]
|
||||
pub async fn update_upstream(
|
||||
_request_info: AuthenticatedRequestInfo,
|
||||
Path(upstream_id): Path<Uuid>,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(payload): Json<UpdateUpstreamRequestBody>,
|
||||
) -> AxumResult<Json<UpdateUpstreamInfoResponse>, ApiError> {
|
||||
let upstream_service = &state.service.nginx.get_upstream_service();
|
||||
let update_info: UpdateUpstreamInfo = payload.into();
|
||||
|
||||
let mut tx = state.database_connection.begin().await?;
|
||||
let r = upstream_service
|
||||
.update_upstream(upstream_id, update_info, Some(&mut tx))
|
||||
.await?;
|
||||
|
||||
state
|
||||
.service
|
||||
.nginx
|
||||
.regenerate_and_apply_config(state.service.agent_client.clone(), &Some(&mut tx))
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Json(r.into()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum_test::TestServer;
|
||||
use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, MockRow};
|
||||
|
||||
use database::generated::entities::{upstream, upstream_target};
|
||||
|
||||
use super::UpdateUpstreamRequestBody;
|
||||
use crate::{
|
||||
configs::{FromConfig, ProgramSettings},
|
||||
middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER,
|
||||
routes::api::restricted::nginx::upstream::get_upstream_router,
|
||||
services::{agent_client::MockAgentService, get_mock_app_service},
|
||||
};
|
||||
|
||||
fn get_router_with_state(db: DatabaseConnection) -> axum::Router {
|
||||
let program_settings = ProgramSettings::mock();
|
||||
let mut mock = MockAgentService::new();
|
||||
mock.expect_validate().returning(|_cfg| Ok(()));
|
||||
mock.expect_apply().returning(|_cfg| Ok(()));
|
||||
let mock_agent = Arc::new(mock);
|
||||
let app_service =
|
||||
get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent);
|
||||
let state = Arc::new(crate::routes::AppState {
|
||||
database_connection: Arc::new(db),
|
||||
service: Arc::new(app_service),
|
||||
config: Arc::new(program_settings),
|
||||
});
|
||||
get_upstream_router(state).layer(axum::middleware::from_fn(
|
||||
crate::middlewares::require_auth::mock::mock_require_auth,
|
||||
))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_update_upstream_succeeds_returns_ok() {
|
||||
let up_id = uuid::Uuid::new_v4();
|
||||
|
||||
let current_model = upstream::Model {
|
||||
id: up_id,
|
||||
name: "old_upstream".to_string(),
|
||||
protocol: "http".to_string(),
|
||||
algorithm: "round_robin".to_string(),
|
||||
sticky_session: false,
|
||||
created_by: Some(uuid::Uuid::new_v4()),
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let updated_model = upstream::Model {
|
||||
id: up_id,
|
||||
name: "updated_upstream".to_string(),
|
||||
protocol: "http".to_string(),
|
||||
algorithm: "round_robin".to_string(),
|
||||
sticky_session: false,
|
||||
created_by: Some(uuid::Uuid::new_v4()),
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
// first find_by_id, then update returns updated model
|
||||
let up_model = current_model.clone();
|
||||
let first: Vec<Vec<upstream::Model>> = vec![vec![current_model.clone()]];
|
||||
let second: Vec<Vec<upstream::Model>> = vec![vec![updated_model.clone()]];
|
||||
let dummy_row: Vec<MockRow> = vec![];
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(first)
|
||||
.append_query_results(second)
|
||||
// additional query result for regenerate_and_apply_config -> generate_config
|
||||
.append_query_results(vec![vec![(
|
||||
up_model.clone(),
|
||||
Option::<upstream_target::Model>::None,
|
||||
)]])
|
||||
.append_query_results(vec![dummy_row])
|
||||
.into_connection();
|
||||
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let payload = UpdateUpstreamRequestBody {
|
||||
name: Some("updated_upstream".to_string()),
|
||||
protocol: None,
|
||||
algorithm: None,
|
||||
sticky_session: None,
|
||||
upstream_targets: None,
|
||||
};
|
||||
|
||||
let res = server
|
||||
.patch(&format!("/upstreams/{}", up_id))
|
||||
.json(&payload)
|
||||
.await;
|
||||
|
||||
res.assert_status_ok();
|
||||
let text = res.text();
|
||||
let body: crate::routes::api::restricted::nginx::upstream::info::response::UpdateUpstreamInfoResponse =
|
||||
serde_json::from_str(&text).expect("failed to parse json");
|
||||
|
||||
assert_eq!(body.id, up_id);
|
||||
assert_eq!(body.name, "updated_upstream");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_update_upstream_agent_error_returns_internal() {
|
||||
let up_id = uuid::Uuid::new_v4();
|
||||
|
||||
let current_model = upstream::Model {
|
||||
id: up_id,
|
||||
name: "old_upstream".to_string(),
|
||||
protocol: "http".to_string(),
|
||||
algorithm: "round_robin".to_string(),
|
||||
sticky_session: false,
|
||||
created_by: Some(uuid::Uuid::new_v4()),
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let updated_model = upstream::Model {
|
||||
id: up_id,
|
||||
name: "updated_upstream".to_string(),
|
||||
protocol: "http".to_string(),
|
||||
algorithm: "round_robin".to_string(),
|
||||
sticky_session: false,
|
||||
created_by: Some(uuid::Uuid::new_v4()),
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let up_model = current_model.clone();
|
||||
|
||||
let mut mock = MockAgentService::new();
|
||||
mock.expect_validate().returning(|_cfg| Ok(()));
|
||||
mock.expect_apply().returning(|_cfg| {
|
||||
Err(
|
||||
crate::services::agent_client::AgentError::ApplicationFailed(
|
||||
"internal".to_string(),
|
||||
"Failed to communicate with the agent.".to_string(),
|
||||
),
|
||||
)
|
||||
});
|
||||
let mock_agent = Arc::new(mock);
|
||||
|
||||
let first: Vec<Vec<upstream::Model>> = vec![vec![current_model.clone()]];
|
||||
let second: Vec<Vec<upstream::Model>> = vec![vec![updated_model.clone()]];
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(first)
|
||||
.append_query_results(second)
|
||||
.append_query_results(vec![vec![(
|
||||
up_model.clone(),
|
||||
Option::<upstream_target::Model>::None,
|
||||
)]])
|
||||
.into_connection();
|
||||
|
||||
let program_settings = ProgramSettings::mock();
|
||||
let app_service =
|
||||
get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent);
|
||||
let state = Arc::new(crate::routes::AppState {
|
||||
database_connection: Arc::new(db),
|
||||
service: Arc::new(app_service),
|
||||
config: Arc::new(program_settings),
|
||||
});
|
||||
|
||||
let router = get_upstream_router(state).layer(axum::middleware::from_fn(
|
||||
crate::middlewares::require_auth::mock::mock_require_auth,
|
||||
));
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let payload = UpdateUpstreamRequestBody {
|
||||
name: Some("updated_upstream".to_string()),
|
||||
protocol: None,
|
||||
algorithm: None,
|
||||
sticky_session: None,
|
||||
upstream_targets: None,
|
||||
};
|
||||
|
||||
let res = server
|
||||
.patch(&format!("/upstreams/{}", up_id))
|
||||
.json(&payload)
|
||||
.await;
|
||||
|
||||
res.assert_status(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_update_upstream_unauthenticated_returns_unauthorized() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection();
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let payload = UpdateUpstreamRequestBody {
|
||||
name: Some("updated_upstream".to_string()),
|
||||
protocol: None,
|
||||
algorithm: None,
|
||||
sticky_session: None,
|
||||
upstream_targets: None,
|
||||
};
|
||||
|
||||
let res = server
|
||||
.patch(&format!("/upstreams/{}", uuid::Uuid::new_v4()))
|
||||
.add_header(REQUEST_AUTH_USER_INVALID_HEADER, "true")
|
||||
.json(&payload)
|
||||
.await;
|
||||
|
||||
res.assert_status(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_update_upstream_not_found_returns_not_found() {
|
||||
let empty_results: Vec<Vec<upstream::Model>> = vec![Vec::<upstream::Model>::new()];
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(empty_results)
|
||||
.into_connection();
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let payload = UpdateUpstreamRequestBody {
|
||||
name: Some("updated_upstream".to_string()),
|
||||
protocol: None,
|
||||
algorithm: None,
|
||||
sticky_session: None,
|
||||
upstream_targets: None,
|
||||
};
|
||||
|
||||
let res = server
|
||||
.patch(&format!("/upstreams/{}", uuid::Uuid::new_v4()))
|
||||
.json(&payload)
|
||||
.await;
|
||||
|
||||
res.assert_status(StatusCode::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
response::Result as AxumResult,
|
||||
};
|
||||
use sea_orm::TransactionTrait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
errors::api_error::ApiError,
|
||||
middlewares::request_info::AuthenticatedRequestInfo,
|
||||
routes::{
|
||||
AppState, api::openapi::tag::NGINX_TAG,
|
||||
api::restricted::nginx::upstream::info::response::UpdateUpstreamTargetInfoResponse,
|
||||
},
|
||||
services::nginx::info::upstream_target::UpdateUpstreamTargetInfo,
|
||||
};
|
||||
|
||||
#[derive(Deserialize, utoipa::ToSchema, Serialize)]
|
||||
pub struct UpdateUpstreamTargetRequestBody {
|
||||
pub host: Option<String>,
|
||||
pub port: Option<i64>,
|
||||
pub enabled: Option<bool>,
|
||||
pub is_backup: Option<bool>,
|
||||
pub weight: Option<i32>,
|
||||
}
|
||||
|
||||
impl From<UpdateUpstreamTargetRequestBody> for UpdateUpstreamTargetInfo {
|
||||
fn from(val: UpdateUpstreamTargetRequestBody) -> Self {
|
||||
Self {
|
||||
target_host: val.host,
|
||||
target_port: val.port,
|
||||
enabled: val.enabled,
|
||||
is_backup: val.is_backup,
|
||||
weight: val.weight.map(|w| w as i64),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/api/nginx/upstream_targets/{upstream_target_id}",
|
||||
request_body = UpdateUpstreamTargetRequestBody,
|
||||
responses(
|
||||
(status = 200, description = "Upstream target updated successfully", body = UpdateUpstreamTargetInfoResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Not found"),
|
||||
(status = 422, description = "Invalid request"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
tag = NGINX_TAG,
|
||||
)]
|
||||
pub async fn update_upstream_target(
|
||||
_request_info: AuthenticatedRequestInfo,
|
||||
Path(upstream_target_id): Path<Uuid>,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(payload): Json<UpdateUpstreamTargetRequestBody>,
|
||||
) -> AxumResult<Json<UpdateUpstreamTargetInfoResponse>, ApiError> {
|
||||
let upstream_service = &state.service.nginx.get_upstream_service();
|
||||
let update_info: UpdateUpstreamTargetInfo = payload.into();
|
||||
|
||||
let mut tx = state.database_connection.begin().await?;
|
||||
let r = upstream_service
|
||||
.update_upstream_target(upstream_target_id, update_info, Some(&mut tx))
|
||||
.await?;
|
||||
|
||||
state
|
||||
.service
|
||||
.nginx
|
||||
.regenerate_and_apply_config(state.service.agent_client.clone(), &Some(&mut tx))
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Json(r.into()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum::routing::patch;
|
||||
use axum_test::TestServer;
|
||||
use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase};
|
||||
|
||||
use database::generated::entities::{upstream, upstream_target};
|
||||
|
||||
use super::UpdateUpstreamTargetRequestBody;
|
||||
use crate::{
|
||||
configs::{FromConfig, ProgramSettings},
|
||||
middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER,
|
||||
services::{agent_client::MockAgentService, get_mock_app_service},
|
||||
};
|
||||
|
||||
fn get_router_with_state(db: DatabaseConnection) -> axum::Router {
|
||||
let program_settings = ProgramSettings::mock();
|
||||
let mut mock = MockAgentService::new();
|
||||
mock.expect_validate().returning(|_cfg| Ok(()));
|
||||
mock.expect_apply().returning(|_cfg| Ok(()));
|
||||
let mock_agent = Arc::new(mock);
|
||||
let app_service =
|
||||
get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent);
|
||||
let state = Arc::new(crate::routes::AppState {
|
||||
database_connection: Arc::new(db),
|
||||
service: Arc::new(app_service),
|
||||
config: Arc::new(program_settings),
|
||||
});
|
||||
|
||||
axum::Router::new()
|
||||
.route(
|
||||
"/upstream_targets/{upstream_target_id}",
|
||||
patch(crate::routes::api::restricted::nginx::upstream::update_upstream_target::update_upstream_target),
|
||||
)
|
||||
.with_state(state)
|
||||
.layer(axum::middleware::from_fn(
|
||||
crate::middlewares::require_auth::mock::mock_require_auth,
|
||||
))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_update_upstream_target_succeeds_returns_ok() {
|
||||
let target_id = uuid::Uuid::new_v4();
|
||||
|
||||
let current_model = upstream_target::Model {
|
||||
id: target_id,
|
||||
upstream_id: uuid::Uuid::new_v4(),
|
||||
target_host: "127.0.0.1".to_string(),
|
||||
target_port: 8080,
|
||||
weight: 1,
|
||||
is_backup: false,
|
||||
enabled: true,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let updated_model = upstream_target::Model {
|
||||
id: target_id,
|
||||
upstream_id: current_model.upstream_id,
|
||||
target_host: "127.0.0.1".to_string(),
|
||||
target_port: 8081,
|
||||
weight: 2,
|
||||
is_backup: false,
|
||||
enabled: false,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let up_model = upstream::Model {
|
||||
id: current_model.upstream_id,
|
||||
name: "test_upstream".to_string(),
|
||||
protocol: "http".to_string(),
|
||||
algorithm: "round_robin".to_string(),
|
||||
sticky_session: false,
|
||||
created_by: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let first: Vec<Vec<upstream_target::Model>> = vec![vec![current_model.clone()]];
|
||||
let second: Vec<Vec<upstream_target::Model>> = vec![vec![updated_model.clone()]];
|
||||
// additional query result for regenerate_and_apply_config -> generate_config
|
||||
let third: Vec<Vec<(upstream::Model, Option<upstream_target::Model>)>> =
|
||||
vec![vec![(up_model.clone(), Some(updated_model.clone()))]];
|
||||
// placeholder for other queries called by regenerate_and_apply_config
|
||||
let fourth: Vec<Vec<upstream::Model>> = vec![vec![]];
|
||||
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(first)
|
||||
.append_query_results(second)
|
||||
.append_query_results(third)
|
||||
.append_query_results(fourth)
|
||||
.into_connection();
|
||||
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let payload = UpdateUpstreamTargetRequestBody {
|
||||
host: None,
|
||||
port: Some(8081),
|
||||
enabled: Some(false),
|
||||
is_backup: None,
|
||||
weight: Some(2),
|
||||
};
|
||||
|
||||
let res = server
|
||||
.patch(&format!("/upstream_targets/{}", target_id))
|
||||
.json(&payload)
|
||||
.await;
|
||||
|
||||
res.assert_status_ok();
|
||||
let text = res.text();
|
||||
let body: crate::routes::api::restricted::nginx::upstream::info::response::UpdateUpstreamTargetInfoResponse =
|
||||
serde_json::from_str(&text).expect("failed to parse json");
|
||||
|
||||
assert_eq!(body.id, target_id);
|
||||
assert_eq!(body.port, 8081);
|
||||
assert!(!body.enabled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_update_upstream_target_unauthenticated_returns_unauthorized() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection();
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let payload = UpdateUpstreamTargetRequestBody {
|
||||
host: None,
|
||||
port: Some(8081),
|
||||
enabled: Some(false),
|
||||
is_backup: None,
|
||||
weight: Some(2),
|
||||
};
|
||||
|
||||
let res = server
|
||||
.patch(&format!("/upstream_targets/{}", uuid::Uuid::new_v4()))
|
||||
.add_header(REQUEST_AUTH_USER_INVALID_HEADER, "true")
|
||||
.json(&payload)
|
||||
.await;
|
||||
|
||||
res.assert_status(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_update_upstream_target_not_found_returns_not_found() {
|
||||
let empty_results: Vec<Vec<upstream_target::Model>> =
|
||||
vec![Vec::<upstream_target::Model>::new()];
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(empty_results)
|
||||
.into_connection();
|
||||
let router = get_router_with_state(db.clone());
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let payload = UpdateUpstreamTargetRequestBody {
|
||||
host: None,
|
||||
port: Some(8081),
|
||||
enabled: Some(false),
|
||||
is_backup: None,
|
||||
weight: Some(2),
|
||||
};
|
||||
|
||||
let res = server
|
||||
.patch(&format!("/upstream_targets/{}", uuid::Uuid::new_v4()))
|
||||
.json(&payload)
|
||||
.await;
|
||||
|
||||
res.assert_status(StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_update_upstream_target_agent_error_returns_internal() {
|
||||
let target_id = uuid::Uuid::new_v4();
|
||||
|
||||
let current_model = upstream_target::Model {
|
||||
id: target_id,
|
||||
upstream_id: uuid::Uuid::new_v4(),
|
||||
target_host: "127.0.0.1".to_string(),
|
||||
target_port: 8080,
|
||||
weight: 1,
|
||||
is_backup: false,
|
||||
enabled: true,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let updated_model = upstream_target::Model {
|
||||
id: target_id,
|
||||
upstream_id: current_model.upstream_id,
|
||||
target_host: "127.0.0.1".to_string(),
|
||||
target_port: 8081,
|
||||
weight: 2,
|
||||
is_backup: false,
|
||||
enabled: false,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let up_model = upstream::Model {
|
||||
id: current_model.upstream_id,
|
||||
name: "test_upstream".to_string(),
|
||||
protocol: "http".to_string(),
|
||||
algorithm: "round_robin".to_string(),
|
||||
sticky_session: false,
|
||||
created_by: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let mut mock = MockAgentService::new();
|
||||
mock.expect_validate().returning(|_cfg| Ok(()));
|
||||
mock.expect_apply().returning(|_cfg| {
|
||||
Err(
|
||||
crate::services::agent_client::AgentError::ApplicationFailed(
|
||||
"internal".to_string(),
|
||||
"Failed to communicate with the agent.".to_string(),
|
||||
),
|
||||
)
|
||||
});
|
||||
let mock_agent = Arc::new(mock);
|
||||
|
||||
let first: Vec<Vec<upstream_target::Model>> = vec![vec![current_model.clone()]];
|
||||
let second: Vec<Vec<upstream_target::Model>> = vec![vec![updated_model.clone()]];
|
||||
let third: Vec<Vec<(upstream::Model, Option<upstream_target::Model>)>> =
|
||||
vec![vec![(up_model.clone(), Some(updated_model.clone()))]];
|
||||
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(first)
|
||||
.append_query_results(second)
|
||||
.append_query_results(third)
|
||||
.into_connection();
|
||||
|
||||
let program_settings = ProgramSettings::mock();
|
||||
let app_service =
|
||||
get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent);
|
||||
let state = Arc::new(crate::routes::AppState {
|
||||
database_connection: Arc::new(db),
|
||||
service: Arc::new(app_service),
|
||||
config: Arc::new(program_settings),
|
||||
});
|
||||
|
||||
let router = axum::Router::new()
|
||||
.route(
|
||||
"/upstream_targets/{upstream_target_id}",
|
||||
axum::routing::patch(crate::routes::api::restricted::nginx::upstream::update_upstream_target::update_upstream_target),
|
||||
)
|
||||
.with_state(state)
|
||||
.layer(axum::middleware::from_fn(
|
||||
crate::middlewares::require_auth::mock::mock_require_auth,
|
||||
));
|
||||
|
||||
let server = TestServer::new(router).expect("failed to create test server");
|
||||
|
||||
let payload = UpdateUpstreamTargetRequestBody {
|
||||
host: None,
|
||||
port: Some(8081),
|
||||
enabled: Some(false),
|
||||
is_backup: None,
|
||||
weight: Some(2),
|
||||
};
|
||||
|
||||
let res = server
|
||||
.patch(&format!("/upstream_targets/{}", target_id))
|
||||
.json(&payload)
|
||||
.await;
|
||||
|
||||
res.assert_status(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Extension, Json,
|
||||
Json,
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
@@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};
|
||||
use tracing::error;
|
||||
|
||||
use crate::{
|
||||
middlewares::request_info::RequestInfo,
|
||||
middlewares::request_info::AuthenticatedRequestInfo,
|
||||
routes::{AppState, api::openapi::tag::USER_TAG},
|
||||
};
|
||||
|
||||
@@ -38,15 +38,9 @@ pub struct UserInfo {
|
||||
)]
|
||||
pub async fn get_user_info(
|
||||
State(app_state): State<Arc<AppState>>,
|
||||
request_info: Extension<Arc<RequestInfo>>,
|
||||
request_info: AuthenticatedRequestInfo,
|
||||
) -> Response {
|
||||
let user_id = match request_info.user_id {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
error!("User ID not found in request info");
|
||||
return (StatusCode::UNAUTHORIZED).into_response();
|
||||
}
|
||||
};
|
||||
let user_id = request_info.user_id;
|
||||
|
||||
match app_state.service.user.get_user_by_id(user_id, None).await {
|
||||
Ok(user) => {
|
||||
|
||||
@@ -1,3 +1,85 @@
|
||||
pub mod agent_client;
|
||||
pub mod auth;
|
||||
pub mod nginx;
|
||||
pub mod server_state;
|
||||
pub mod settings;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use ::agent_client::apis::configuration::Configuration;
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::services::agent_client::MockAgentService;
|
||||
use crate::{
|
||||
configs::ProgramSettings,
|
||||
routes::{self, AuthState},
|
||||
services::{
|
||||
agent_client::{AgentService, AgentServiceImpl},
|
||||
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<dyn 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(AgentServiceImpl::new(Configuration::from(
|
||||
settings.agent.clone(),
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn get_mock_app_service(
|
||||
db_connection: &Arc<sea_orm::DatabaseConnection>,
|
||||
settings: &ProgramSettings,
|
||||
mock_agent: Arc<MockAgentService>,
|
||||
) -> AppService {
|
||||
AppService {
|
||||
server_state: Arc::new(ServerStateService::new(db_connection.clone())),
|
||||
settings: Arc::new(SettingsService::new(db_connection.clone())),
|
||||
auth_state: routes::AuthState {
|
||||
strategy: routes::AuthStrategy {
|
||||
password: Arc::new(PasswordStrategy::new(db_connection.clone())),
|
||||
},
|
||||
authentication: Arc::new(AuthenticationServiceImpl::new(
|
||||
settings.auth.jwt_secret.clone(),
|
||||
)),
|
||||
},
|
||||
user: Arc::new(UserServiceImpl::new(db_connection.clone())),
|
||||
nginx: Arc::new(NginxService::new(db_connection.clone())),
|
||||
agent_client: mock_agent,
|
||||
}
|
||||
}
|
||||
|
||||
263
apps/api/src/services/agent_client.rs
Normal file
263
apps/api/src/services/agent_client.rs
Normal file
@@ -0,0 +1,263 @@
|
||||
use std::{os::unix::fs::FileTypeExt, sync::Arc};
|
||||
|
||||
use agent_client::{
|
||||
apis::{
|
||||
Api, ApiClient, Error as ApiError, ResponseContent,
|
||||
configuration::Configuration,
|
||||
nginx_agent_api::{ValidateAndReloadParams, ValidateParams, WriteConfigParams},
|
||||
},
|
||||
models::{ValidateAndReloadBody, ValidateBody, WriteConfigBody},
|
||||
};
|
||||
use tracing::{error, warn};
|
||||
|
||||
use crate::{configs::agent::AgentSettings, errors::service_error::ServiceError};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum AgentError {
|
||||
// (internal messages, user-facing messages)
|
||||
#[allow(dead_code)]
|
||||
ValidationFailed(String, String),
|
||||
// (internal messages, user-facing messages)
|
||||
ApplicationFailed(String, String),
|
||||
}
|
||||
|
||||
impl From<AgentError> for ServiceError {
|
||||
fn from(err: AgentError) -> Self {
|
||||
error!("Agent error occurred: {:?}", err);
|
||||
match err {
|
||||
AgentError::ValidationFailed(_internal, user) => ServiceError::InternalError(user),
|
||||
AgentError::ApplicationFailed(_internal, user) => ServiceError::InternalError(user),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: std::fmt::Debug> From<ResponseContent<T>> for AgentError {
|
||||
fn from(err: ResponseContent<T>) -> Self {
|
||||
let ResponseContent {
|
||||
status,
|
||||
content,
|
||||
entity,
|
||||
} = err;
|
||||
{
|
||||
let entity_str = entity
|
||||
.map(|e| format!("{:?}", e))
|
||||
.unwrap_or_else(|| "<empty>".to_string());
|
||||
AgentError::ApplicationFailed(
|
||||
format!(
|
||||
"Agent responded with error status {}: {}, entity: {}",
|
||||
status, content, entity_str
|
||||
),
|
||||
"Agent reported an error during operation.".to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: std::fmt::Debug> From<ApiError<T>> for AgentError {
|
||||
fn from(err: ApiError<T>) -> Self {
|
||||
match err {
|
||||
ApiError::ResponseError(resp) => AgentError::from(resp),
|
||||
ApiError::Io(err) => AgentError::ApplicationFailed(
|
||||
format!("IO error during agent communication: {}", err),
|
||||
"Failed to communicate with the agent.".to_string(),
|
||||
),
|
||||
ApiError::Reqwest(err) => AgentError::ApplicationFailed(
|
||||
format!("Reqwest error during agent communication: {}", err),
|
||||
"Failed to communicate with the agent.".to_string(),
|
||||
),
|
||||
ApiError::Serde(err) => AgentError::ApplicationFailed(
|
||||
format!("Serialization error during agent communication: {}", err),
|
||||
"Failed to communicate with the agent.".to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(test, mockall::automock)]
|
||||
#[async_trait::async_trait]
|
||||
pub trait AgentService: Send + Sync {
|
||||
#[allow(dead_code)]
|
||||
fn get_client(&self) -> Arc<ApiClient>;
|
||||
|
||||
// TODO: improve error handling and reporting, error reasons
|
||||
// validate configurations that has been created/updated before the given timestamp
|
||||
#[allow(dead_code)]
|
||||
async fn validate(&self, config: &str) -> Result<(), AgentError>;
|
||||
// validate and apply configurations that has been created/updated before the given timestamp
|
||||
async fn apply(&self, config: &str) -> Result<(), AgentError>;
|
||||
}
|
||||
|
||||
pub struct AgentServiceImpl {
|
||||
client: Arc<ApiClient>,
|
||||
}
|
||||
|
||||
impl AgentServiceImpl {
|
||||
pub fn new(config: impl Into<Arc<Configuration>>) -> Self {
|
||||
let client = ApiClient::new(config.into());
|
||||
AgentServiceImpl {
|
||||
client: Arc::new(client),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AgentSettings> for Configuration {
|
||||
fn from(settings: AgentSettings) -> Self {
|
||||
let mut config = Configuration::default();
|
||||
let mut builder = reqwest::Client::builder();
|
||||
|
||||
let url = settings.socket_path;
|
||||
// check if the url is a unix socket path
|
||||
let is_socket = std::fs::metadata(&url).is_ok_and(|m| m.file_type().is_socket());
|
||||
if is_socket || url.starts_with("unix://") {
|
||||
builder = builder.unix_socket(url.to_string());
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl AgentService for AgentServiceImpl {
|
||||
fn get_client(&self) -> Arc<ApiClient> {
|
||||
Arc::clone(&self.client)
|
||||
}
|
||||
|
||||
async fn validate(&self, config: &str) -> Result<(), AgentError> {
|
||||
let timestamp = chrono::Utc::now().timestamp_millis();
|
||||
let name = Self::get_config_name(true);
|
||||
self._validate(&name, timestamp, config).await
|
||||
}
|
||||
|
||||
async fn apply(&self, config: &str) -> Result<(), AgentError> {
|
||||
let timestamp = chrono::Utc::now().timestamp_millis();
|
||||
let name = Self::get_config_name(false);
|
||||
self._validate(&name, timestamp, config).await?;
|
||||
self._apply(&name, timestamp).await
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentServiceImpl {
|
||||
fn get_config_name(is_validate_only: bool) -> String {
|
||||
format!(
|
||||
"nginx_config_{}{}",
|
||||
if is_validate_only {
|
||||
"validation_"
|
||||
} else {
|
||||
"application_"
|
||||
},
|
||||
uuid::Uuid::new_v4()
|
||||
)
|
||||
}
|
||||
|
||||
async fn _validate(&self, name: &str, timestamp: i64, config: &str) -> Result<(), AgentError> {
|
||||
let api = self.client.nginx_agent_api();
|
||||
|
||||
api.write_config(WriteConfigParams {
|
||||
write_config_body: WriteConfigBody {
|
||||
config_name: name.to_string(),
|
||||
content: config.to_string(),
|
||||
timestamp,
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
|
||||
api.validate(ValidateParams {
|
||||
validate_body: ValidateBody {
|
||||
config_name: name.to_string(),
|
||||
timestamp,
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn _apply(&self, name: &str, timestamp: i64) -> Result<(), AgentError> {
|
||||
let api = self.client.nginx_agent_api();
|
||||
api.validate_and_reload(ValidateAndReloadParams {
|
||||
validate_and_reload_body: ValidateAndReloadBody {
|
||||
config_name: name.to_string(),
|
||||
timestamp,
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[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 = AgentServiceImpl::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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -197,14 +197,17 @@ mod tests {
|
||||
let (token, _) = service
|
||||
.generate_jwt(user_id, 60)
|
||||
.await
|
||||
.expect("generate jwt");
|
||||
.expect("Failed to generate jwt");
|
||||
|
||||
let valid = service
|
||||
.is_valid_jwt(&token, None)
|
||||
.await
|
||||
.expect("validate jwt");
|
||||
.expect("Failed to validate jwt");
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -213,10 +216,16 @@ mod tests {
|
||||
let service = AuthenticationServiceImpl::new(Some("secret".to_string()));
|
||||
|
||||
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 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!(
|
||||
valid.is_none(),
|
||||
"Token should be invalid for a different subject"
|
||||
@@ -236,10 +245,19 @@ mod tests {
|
||||
let service = AuthenticationServiceImpl::new(Some("secret".to_string()));
|
||||
|
||||
let user_id = Uuid::new_v4();
|
||||
let (token, _) = service.generate_jwt(user_id, 60).await.unwrap();
|
||||
let new_token = service.refresh_jwt(&token, 120).await.unwrap();
|
||||
let (token, _) = service
|
||||
.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.exp - claims.iat, 120);
|
||||
}
|
||||
@@ -249,10 +267,16 @@ mod tests {
|
||||
let service = AuthenticationServiceImpl::new(Some("secret".to_string()));
|
||||
|
||||
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;
|
||||
|
||||
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!(
|
||||
valid.is_none(),
|
||||
"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 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
|
||||
{
|
||||
|
||||
@@ -236,7 +236,7 @@ mod test {
|
||||
"CorrectPassword".as_bytes(),
|
||||
&SaltString::generate(&mut OsRng),
|
||||
)
|
||||
.unwrap()
|
||||
.expect("Failed to hash password")
|
||||
.to_string();
|
||||
let db = MockDatabase::new(sea_orm::DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![vec![user::Model {
|
||||
@@ -281,7 +281,7 @@ mod test {
|
||||
"CorrectPassword".as_bytes(),
|
||||
&SaltString::generate(&mut OsRng),
|
||||
)
|
||||
.unwrap()
|
||||
.expect("Failed to hash password")
|
||||
.to_string();
|
||||
let db = MockDatabase::new(sea_orm::DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![vec![user::Model {
|
||||
|
||||
106
apps/api/src/services/nginx.rs
Normal file
106
apps/api/src/services/nginx.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
pub mod builder;
|
||||
pub mod info;
|
||||
pub mod traits;
|
||||
|
||||
pub mod location;
|
||||
pub mod proxy_host;
|
||||
pub mod upstream;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use sea_orm::{DatabaseConnection, DatabaseTransaction};
|
||||
|
||||
use crate::{
|
||||
errors::service_error::ServiceError,
|
||||
services::{
|
||||
agent_client::AgentService,
|
||||
nginx::{
|
||||
builder::{NginxConfigBuilder, NginxConfigProvider},
|
||||
location::{LocationService, LocationServiceImpl},
|
||||
proxy_host::{ProxyService, ProxyServiceImpl},
|
||||
upstream::{UpstreamService, UpstreamServiceImpl},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
pub struct NginxService {
|
||||
#[allow(dead_code)]
|
||||
connection: Arc<DatabaseConnection>,
|
||||
//
|
||||
#[allow(dead_code)]
|
||||
upstream_service: Arc<dyn UpstreamService>,
|
||||
#[allow(dead_code)]
|
||||
proxy_service: Arc<dyn ProxyService>,
|
||||
#[allow(dead_code)]
|
||||
location_service: Arc<dyn LocationService>,
|
||||
}
|
||||
|
||||
impl NginxService {
|
||||
pub fn new(connection: Arc<DatabaseConnection>) -> Self {
|
||||
Self {
|
||||
connection: connection.clone(),
|
||||
//
|
||||
upstream_service: Arc::new(UpstreamServiceImpl::new(connection.clone())),
|
||||
proxy_service: Arc::new(ProxyServiceImpl::new(connection.clone())),
|
||||
location_service: Arc::new(LocationServiceImpl::new(connection.clone())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_upstream_service(&self) -> Arc<dyn UpstreamService> {
|
||||
self.upstream_service.clone()
|
||||
}
|
||||
|
||||
pub fn get_proxy_service(&self) -> Arc<dyn ProxyService> {
|
||||
self.proxy_service.clone()
|
||||
}
|
||||
|
||||
pub fn get_location_service(&self) -> Arc<dyn LocationService> {
|
||||
self.location_service.clone()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn validate_config(
|
||||
&self,
|
||||
agent: Arc<dyn AgentService>,
|
||||
config: &str,
|
||||
) -> Result<(), ServiceError> {
|
||||
agent.validate(config).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn apply_changes(
|
||||
&self,
|
||||
agent: Arc<dyn AgentService>,
|
||||
config: &str,
|
||||
) -> Result<(), ServiceError> {
|
||||
agent.apply(config).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn generate_config(
|
||||
&self,
|
||||
tx: &Option<&mut DatabaseTransaction>,
|
||||
) -> Result<String, ServiceError> {
|
||||
let mut builder = NginxConfigBuilder::default();
|
||||
self.upstream_service
|
||||
.generate_config(&mut builder, tx)
|
||||
.await?;
|
||||
self.proxy_service.generate_config(&mut builder, tx).await?;
|
||||
|
||||
builder.to_nginx_config(None)
|
||||
}
|
||||
|
||||
pub async fn regenerate_and_apply_config(
|
||||
&self,
|
||||
agent: Arc<dyn AgentService>,
|
||||
tx: &Option<&mut DatabaseTransaction>,
|
||||
) -> Result<(), ServiceError> {
|
||||
let config = self.generate_config(tx).await?;
|
||||
|
||||
self.apply_changes(agent, &config).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
61
apps/api/src/services/nginx/builder.rs
Normal file
61
apps/api/src/services/nginx/builder.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
use crate::{
|
||||
errors::service_error::ServiceError,
|
||||
services::nginx::info::{proxy_host::ProxyHostInfo, upstream::UpstreamInfo},
|
||||
};
|
||||
|
||||
pub const INDENT_SIZE: usize = 2;
|
||||
|
||||
pub trait NginxConfigProvider {
|
||||
fn to_nginx_config(&self, indent: Option<usize>) -> Result<String, ServiceError>;
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct NginxConfigBuilder {
|
||||
upstreams: Vec<UpstreamInfo>,
|
||||
proxy_hosts: Vec<ProxyHostInfo>,
|
||||
}
|
||||
|
||||
impl NginxConfigBuilder {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_proxy_host(&mut self, proxy_host: ProxyHostInfo) {
|
||||
self.proxy_hosts.push(proxy_host);
|
||||
}
|
||||
|
||||
pub fn add_proxy_hosts(&mut self, proxy_hosts: Vec<ProxyHostInfo>) {
|
||||
for proxy_host in proxy_hosts {
|
||||
self.add_proxy_host(proxy_host);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NginxConfigProvider for NginxConfigBuilder {
|
||||
fn to_nginx_config(&self, indent: Option<usize>) -> Result<String, ServiceError> {
|
||||
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)?);
|
||||
}
|
||||
|
||||
for proxy_host in &self.proxy_hosts {
|
||||
config.push('\n');
|
||||
config.push_str(&proxy_host.to_nginx_config(indent)?);
|
||||
}
|
||||
// TODO: Add other sections like servers, locations, etc.
|
||||
// trailing newline for file ending
|
||||
config.push('\n');
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
4
apps/api/src/services/nginx/info.rs
Normal file
4
apps/api/src/services/nginx/info.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod location;
|
||||
pub mod proxy_host;
|
||||
pub mod upstream;
|
||||
pub mod upstream_target;
|
||||
296
apps/api/src/services/nginx/info/location.rs
Normal file
296
apps/api/src/services/nginx/info/location.rs
Normal file
@@ -0,0 +1,296 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use database::generated::entities::{location, proxy_host, upstream};
|
||||
use sea_orm::ActiveValue::{Set, Unchanged};
|
||||
use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
errors::service_error::ServiceError,
|
||||
services::nginx::{builder::NginxConfigProvider, traits::indentable::Indentable},
|
||||
set_if_some,
|
||||
};
|
||||
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ProxyPassInfo {
|
||||
pub protocol: String,
|
||||
pub host: String,
|
||||
pub port: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct LocationInfo {
|
||||
pub id: Uuid,
|
||||
pub host_id: Uuid,
|
||||
pub path: String,
|
||||
pub match_type: String,
|
||||
pub order: i64,
|
||||
pub upstream_id: Option<Uuid>,
|
||||
pub proxy_pass_info: Option<ProxyPassInfo>,
|
||||
pub preserve_host_header: Option<bool>,
|
||||
pub allowed_methods: Option<Vec<String>>,
|
||||
pub custom_config: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
//
|
||||
pub upstream: Option<super::upstream::UpstreamInfo>,
|
||||
pub proxy_host: Option<super::proxy_host::ProxyHostInfo>,
|
||||
}
|
||||
|
||||
pub struct CreateLocationInfo {
|
||||
pub host_id: Uuid,
|
||||
pub path: String,
|
||||
pub match_type: String,
|
||||
pub order: i64,
|
||||
pub upstream_id: Option<Uuid>,
|
||||
pub proxy_pass_protocol: Option<String>,
|
||||
pub proxy_pass_host: Option<String>,
|
||||
pub proxy_pass_port: Option<i64>,
|
||||
pub preserve_host_header: Option<bool>,
|
||||
pub allowed_methods: Option<Vec<String>>,
|
||||
pub custom_config: Option<String>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct UpdateLocationInfo {
|
||||
pub path: Option<String>,
|
||||
pub match_type: Option<String>,
|
||||
pub order: Option<i64>,
|
||||
pub upstream_id: Option<Option<Uuid>>,
|
||||
pub proxy_pass_protocol: Option<Option<String>>,
|
||||
pub proxy_pass_host: Option<Option<String>>,
|
||||
pub proxy_pass_port: Option<Option<i64>>,
|
||||
pub preserve_host_header: Option<Option<bool>>,
|
||||
pub allowed_methods: Option<Option<Vec<String>>>,
|
||||
pub custom_config: Option<Option<String>>,
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
impl From<location::Model> for LocationInfo {
|
||||
fn from(model: location::Model) -> Self {
|
||||
let allowed_methods: Option<Vec<String>> = match model.allowed_methods {
|
||||
Some(JsonValue::Array(arr)) => {
|
||||
let v: Vec<String> = arr
|
||||
.into_iter()
|
||||
.filter_map(|val| val.as_str().map(|s| s.to_string()))
|
||||
.collect();
|
||||
if v.is_empty() { None } else { Some(v) }
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
Self {
|
||||
id: model.id,
|
||||
host_id: model.host_id,
|
||||
path: model.path,
|
||||
match_type: model.match_type,
|
||||
order: model.order,
|
||||
upstream_id: model.upstream_id,
|
||||
proxy_pass_info: match (
|
||||
model.proxy_pass_protocol,
|
||||
model.proxy_pass_host,
|
||||
model.proxy_pass_port,
|
||||
) {
|
||||
(Some(protocol), Some(host), Some(port)) => Some(ProxyPassInfo {
|
||||
protocol,
|
||||
host,
|
||||
port,
|
||||
}),
|
||||
(Some(_), _, _) | (_, Some(_), _) | (_, _, Some(_)) => {
|
||||
warn!("Incomplete proxy_pass_info for location {}", model.id);
|
||||
None
|
||||
}
|
||||
|
||||
_ => None,
|
||||
},
|
||||
preserve_host_header: model.preserve_host_header,
|
||||
allowed_methods,
|
||||
custom_config: model.custom_config,
|
||||
enabled: model.enabled,
|
||||
created_at: model.created_at,
|
||||
updated_at: model.updated_at,
|
||||
upstream: None,
|
||||
proxy_host: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<(location::Model, Option<proxy_host::Model>)> for LocationInfo {
|
||||
fn from(data: (location::Model, Option<proxy_host::Model>)) -> Self {
|
||||
let (location_model, proxy_host_model_opt) = data;
|
||||
(location_model, proxy_host_model_opt, None).into()
|
||||
}
|
||||
}
|
||||
|
||||
impl
|
||||
From<(
|
||||
location::Model,
|
||||
Option<proxy_host::Model>,
|
||||
Option<upstream::Model>,
|
||||
)> for LocationInfo
|
||||
{
|
||||
fn from(
|
||||
data: (
|
||||
location::Model,
|
||||
Option<proxy_host::Model>,
|
||||
Option<upstream::Model>,
|
||||
),
|
||||
) -> Self {
|
||||
let (location_model, proxy_host_model_opt, upstream_model_opt) = data;
|
||||
let mut location_info = LocationInfo::from(location_model);
|
||||
|
||||
if let Some(upstream_model) = upstream_model_opt {
|
||||
location_info.upstream = Some(super::upstream::UpstreamInfo::from(upstream_model));
|
||||
}
|
||||
|
||||
if let Some(proxy_host_model) = proxy_host_model_opt {
|
||||
location_info.proxy_host =
|
||||
Some(super::proxy_host::ProxyHostInfo::from(proxy_host_model));
|
||||
}
|
||||
|
||||
location_info
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CreateLocationInfo> for location::ActiveModel {
|
||||
fn from(val: CreateLocationInfo) -> Self {
|
||||
location::ActiveModel {
|
||||
id: Set(Uuid::new_v4()),
|
||||
host_id: Set(val.host_id),
|
||||
path: Set(val.path),
|
||||
match_type: Set(val.match_type),
|
||||
order: Set(val.order),
|
||||
upstream_id: Set(val.upstream_id),
|
||||
proxy_pass_protocol: Set(val.proxy_pass_protocol),
|
||||
proxy_pass_host: Set(val.proxy_pass_host),
|
||||
proxy_pass_port: Set(val.proxy_pass_port),
|
||||
preserve_host_header: Set(val.preserve_host_header),
|
||||
allowed_methods: Set(val
|
||||
.allowed_methods
|
||||
.map(|v| JsonValue::Array(v.into_iter().map(JsonValue::String).collect()))),
|
||||
custom_config: Set(val.custom_config),
|
||||
enabled: Set(val.enabled),
|
||||
created_at: Set(chrono::Utc::now()),
|
||||
updated_at: Set(chrono::Utc::now()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UpdateLocationInfo {
|
||||
pub fn apply_to_model(self, current_model: location::Model) -> location::ActiveModel {
|
||||
location::ActiveModel {
|
||||
id: Unchanged(current_model.id),
|
||||
host_id: Unchanged(current_model.host_id),
|
||||
path: set_if_some!(self.path),
|
||||
match_type: set_if_some!(self.match_type),
|
||||
order: set_if_some!(self.order),
|
||||
upstream_id: match self.upstream_id {
|
||||
Some(inner) => Set(inner),
|
||||
None => Unchanged(current_model.upstream_id),
|
||||
},
|
||||
proxy_pass_protocol: match self.proxy_pass_protocol {
|
||||
Some(inner) => Set(inner),
|
||||
None => Unchanged(current_model.proxy_pass_protocol),
|
||||
},
|
||||
proxy_pass_host: match self.proxy_pass_host {
|
||||
Some(inner) => Set(inner),
|
||||
None => Unchanged(current_model.proxy_pass_host),
|
||||
},
|
||||
proxy_pass_port: match self.proxy_pass_port {
|
||||
Some(inner) => Set(inner),
|
||||
None => Unchanged(current_model.proxy_pass_port),
|
||||
},
|
||||
preserve_host_header: match self.preserve_host_header {
|
||||
Some(inner) => Set(inner),
|
||||
None => Unchanged(current_model.preserve_host_header),
|
||||
},
|
||||
allowed_methods: match self.allowed_methods {
|
||||
Some(inner) => {
|
||||
let json_opt = inner
|
||||
.map(|v| JsonValue::Array(v.into_iter().map(JsonValue::String).collect()));
|
||||
Set(json_opt)
|
||||
}
|
||||
None => Unchanged(current_model.allowed_methods),
|
||||
},
|
||||
custom_config: match self.custom_config {
|
||||
Some(inner) => Set(inner),
|
||||
None => Unchanged(current_model.custom_config),
|
||||
},
|
||||
enabled: set_if_some!(self.enabled),
|
||||
created_at: Unchanged(current_model.created_at),
|
||||
updated_at: Set(chrono::Utc::now()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NginxConfigProvider for LocationInfo {
|
||||
fn to_nginx_config(&self, indent: Option<usize>) -> Result<String, ServiceError> {
|
||||
let indent = indent.unwrap_or(0);
|
||||
|
||||
let selector = match self.match_type.as_str() {
|
||||
"exact" => format!("location = {} ", self.path),
|
||||
"regex" => format!("location ~ {} ", self.path),
|
||||
_ => format!("location {} ", self.path),
|
||||
};
|
||||
|
||||
let mut body_lines: Vec<String> = Vec::new();
|
||||
|
||||
if let Some(methods) = &self.allowed_methods
|
||||
&& !methods.is_empty()
|
||||
{
|
||||
body_lines.push(format!(
|
||||
"limit_except {} {{ deny all; }}",
|
||||
methods.join(" ")
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(upstream) = &self.upstream {
|
||||
body_lines.push(format!(
|
||||
"proxy_pass {}://{};",
|
||||
upstream.protocol, upstream.name
|
||||
));
|
||||
} else if let Some(host) = &self.proxy_pass_info {
|
||||
body_lines.push(format!(
|
||||
"proxy_pass {}://{}:{};",
|
||||
host.protocol, host.host, host.port
|
||||
));
|
||||
} else {
|
||||
warn!(
|
||||
"Location {} has neither upstream nor proxy_pass_host defined",
|
||||
self.id
|
||||
);
|
||||
return Err(ServiceError::InternalError(
|
||||
"Location must have either an upstream or a proxy_pass_host defined".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(preserve) = self.preserve_host_header {
|
||||
if preserve {
|
||||
body_lines.push("proxy_set_header Host $host;".to_string());
|
||||
} else {
|
||||
body_lines.push("proxy_set_header Host $proxy_host;".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(cfg) = &self.custom_config
|
||||
&& !cfg.trim().is_empty()
|
||||
{
|
||||
body_lines.push(cfg.clone());
|
||||
}
|
||||
|
||||
let inner = if body_lines.is_empty() {
|
||||
"# location has no config".to_string()
|
||||
} else {
|
||||
body_lines
|
||||
.into_iter()
|
||||
.map(|l| l.indent(indent + 2))
|
||||
.collect::<Vec<String>>()
|
||||
.join("\n")
|
||||
};
|
||||
|
||||
Ok(format!("{}{{\n{}\n}}", selector.trim_end(), inner).indent(indent))
|
||||
}
|
||||
}
|
||||
251
apps/api/src/services/nginx/info/proxy_host.rs
Normal file
251
apps/api/src/services/nginx/info/proxy_host.rs
Normal file
@@ -0,0 +1,251 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use database::generated::entities::{location, proxy_host};
|
||||
use sea_orm::ActiveValue::{Set, Unchanged};
|
||||
use serde_json::Value as JsonValue;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
errors::service_error::ServiceError,
|
||||
services::nginx::{
|
||||
builder::{INDENT_SIZE, NginxConfigProvider},
|
||||
traits::indentable::Indentable,
|
||||
},
|
||||
set_if_some,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ProxyHostInfo {
|
||||
pub id: Uuid,
|
||||
pub name: Option<String>,
|
||||
pub domain: String,
|
||||
pub scheme: String,
|
||||
pub listen_port: i64,
|
||||
pub forward_scheme: String,
|
||||
pub forward_host: Option<String>,
|
||||
pub forward_port: Option<i64>,
|
||||
pub preserve_host_header: bool,
|
||||
pub enable_websocket: bool,
|
||||
pub meta: Option<JsonValue>,
|
||||
pub enabled: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
//
|
||||
pub upstream: Option<super::upstream::UpstreamInfo>,
|
||||
pub locations: Vec<super::location::LocationInfo>,
|
||||
}
|
||||
|
||||
pub struct ProxyHostCreateInfo {
|
||||
pub name: Option<String>,
|
||||
pub domain: String,
|
||||
pub scheme: String,
|
||||
pub listen_port: i64,
|
||||
pub forward_scheme: String,
|
||||
pub forward_host: Option<String>,
|
||||
pub forward_port: Option<i64>,
|
||||
pub preserve_host_header: bool,
|
||||
pub enable_websocket: bool,
|
||||
pub enabled: bool,
|
||||
pub meta: Option<JsonValue>,
|
||||
pub default_upstream_id: Option<Uuid>,
|
||||
pub created_by: Option<Uuid>,
|
||||
//
|
||||
pub locations: Vec<super::location::CreateLocationInfo>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct UpdateProxyHostInfo {
|
||||
pub name: Option<Option<String>>,
|
||||
pub domain: Option<String>,
|
||||
pub scheme: Option<String>,
|
||||
pub listen_port: Option<i64>,
|
||||
pub forward_scheme: Option<String>,
|
||||
pub forward_host: Option<Option<String>>,
|
||||
pub forward_port: Option<Option<i64>>,
|
||||
pub preserve_host_header: Option<bool>,
|
||||
pub enable_websocket: Option<bool>,
|
||||
pub enabled: Option<bool>,
|
||||
pub meta: Option<Option<JsonValue>>,
|
||||
pub default_upstream_id: Option<Option<Uuid>>,
|
||||
}
|
||||
|
||||
impl From<proxy_host::Model> for ProxyHostInfo {
|
||||
fn from(model: proxy_host::Model) -> Self {
|
||||
Self {
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
domain: model.domain,
|
||||
scheme: model.scheme,
|
||||
listen_port: model.listen_port,
|
||||
forward_scheme: model.forward_scheme,
|
||||
forward_host: model.forward_host,
|
||||
forward_port: model.forward_port,
|
||||
preserve_host_header: model.preserve_host_header,
|
||||
enable_websocket: model.enable_websocket,
|
||||
meta: model.meta,
|
||||
enabled: model.enabled,
|
||||
created_at: model.created_at,
|
||||
updated_at: model.updated_at,
|
||||
upstream: None,
|
||||
locations: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<(proxy_host::Model, Vec<location::Model>)> for ProxyHostInfo {
|
||||
fn from(data: (proxy_host::Model, Vec<location::Model>)) -> Self {
|
||||
let (proxy_model, location_models) = data;
|
||||
let mut proxy_info = ProxyHostInfo::from(proxy_model);
|
||||
let locations_info: Vec<super::location::LocationInfo> =
|
||||
location_models.into_iter().map(|m| m.into()).collect();
|
||||
proxy_info.locations = locations_info;
|
||||
proxy_info
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ProxyHostCreateInfo> for (proxy_host::ActiveModel, Vec<location::ActiveModel>) {
|
||||
fn from(val: ProxyHostCreateInfo) -> Self {
|
||||
let proxy_host = proxy_host::ActiveModel {
|
||||
id: Set(Uuid::new_v4()),
|
||||
name: Set(val.name),
|
||||
domain: Set(val.domain),
|
||||
scheme: Set(val.scheme),
|
||||
listen_port: Set(val.listen_port),
|
||||
forward_scheme: Set(val.forward_scheme),
|
||||
forward_host: Set(val.forward_host),
|
||||
forward_port: Set(val.forward_port),
|
||||
preserve_host_header: Set(val.preserve_host_header),
|
||||
enable_websocket: Set(val.enable_websocket),
|
||||
enabled: Set(val.enabled),
|
||||
meta: Set(val.meta),
|
||||
default_upstream_id: Set(val.default_upstream_id),
|
||||
created_by: Set(val.created_by),
|
||||
created_at: Set(chrono::Utc::now()),
|
||||
updated_at: Set(chrono::Utc::now()),
|
||||
};
|
||||
let location_models = val.locations.into_iter().map(|loc| loc.into()).collect();
|
||||
(proxy_host, location_models)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ProxyHostInfo> for proxy_host::ActiveModel {
|
||||
fn from(val: ProxyHostInfo) -> Self {
|
||||
proxy_host::ActiveModel {
|
||||
id: Set(val.id),
|
||||
name: Set(val.name),
|
||||
domain: Set(val.domain),
|
||||
scheme: Set(val.scheme),
|
||||
listen_port: Set(val.listen_port),
|
||||
forward_scheme: Set(val.forward_scheme),
|
||||
forward_host: Set(val.forward_host),
|
||||
forward_port: Set(val.forward_port),
|
||||
preserve_host_header: Set(val.preserve_host_header),
|
||||
enable_websocket: Set(val.enable_websocket),
|
||||
enabled: Set(val.enabled),
|
||||
meta: Set(val.meta),
|
||||
default_upstream_id: Set(val.upstream.as_ref().map(|u| u.id)),
|
||||
created_by: Set(None),
|
||||
created_at: Set(val.created_at),
|
||||
updated_at: Set(val.updated_at),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UpdateProxyHostInfo {
|
||||
pub fn apply_to_model(self, current_model: proxy_host::Model) -> proxy_host::ActiveModel {
|
||||
proxy_host::ActiveModel {
|
||||
id: Unchanged(current_model.id),
|
||||
name: match self.name {
|
||||
Some(inner) => Set(inner),
|
||||
None => Unchanged(current_model.name),
|
||||
},
|
||||
domain: set_if_some!(self.domain),
|
||||
scheme: set_if_some!(self.scheme),
|
||||
listen_port: set_if_some!(self.listen_port),
|
||||
forward_scheme: set_if_some!(self.forward_scheme),
|
||||
forward_host: match self.forward_host {
|
||||
Some(inner) => Set(inner),
|
||||
None => Unchanged(current_model.forward_host),
|
||||
},
|
||||
forward_port: match self.forward_port {
|
||||
Some(inner) => Set(inner),
|
||||
None => Unchanged(current_model.forward_port),
|
||||
},
|
||||
preserve_host_header: set_if_some!(self.preserve_host_header),
|
||||
enable_websocket: set_if_some!(self.enable_websocket),
|
||||
enabled: set_if_some!(self.enabled),
|
||||
meta: set_if_some!(self.meta),
|
||||
default_upstream_id: match self.default_upstream_id {
|
||||
Some(inner) => Set(inner),
|
||||
None => Unchanged(current_model.default_upstream_id),
|
||||
},
|
||||
created_by: Unchanged(current_model.created_by),
|
||||
created_at: Unchanged(current_model.created_at),
|
||||
updated_at: Set(chrono::Utc::now()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NginxConfigProvider for ProxyHostInfo {
|
||||
fn to_nginx_config(&self, indent: Option<usize>) -> Result<String, ServiceError> {
|
||||
let indent = indent.unwrap_or(0);
|
||||
|
||||
let mut body: Vec<String> = Vec::new();
|
||||
|
||||
// default location or fallback
|
||||
let default_pass = if let Some(up) = &self.upstream {
|
||||
format!("proxy_pass http://{};", up.name)
|
||||
} else if let Some(host) = &self.forward_host {
|
||||
if let Some(port) = self.forward_port {
|
||||
format!("proxy_pass http://{}:{};", host, port)
|
||||
} else {
|
||||
format!("proxy_pass http://{};", host)
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
// get locations's index sorted by order to prevent mutable borrow issues
|
||||
let mut index_list: Vec<usize> = (0..self.locations.len()).collect();
|
||||
index_list.sort_by(|&a, &b| {
|
||||
let order_a = self.locations[a].order;
|
||||
let order_b = self.locations[b].order;
|
||||
order_a.cmp(&order_b)
|
||||
});
|
||||
|
||||
for &index in &index_list {
|
||||
let loc = &self.locations[index];
|
||||
body.push(loc.to_nginx_config(Some(indent + INDENT_SIZE))?);
|
||||
}
|
||||
|
||||
// If there is a default proxy_pass and no root location for `/`, add it
|
||||
if !default_pass.is_empty() {
|
||||
body.insert(
|
||||
0,
|
||||
format!(
|
||||
"location / {{\n{}\n}}",
|
||||
default_pass.indent(indent + INDENT_SIZE)
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if self.enable_websocket {
|
||||
body.push("proxy_set_header Upgrade $http_upgrade;".to_string());
|
||||
body.push("proxy_set_header Connection \"upgrade\";".to_string());
|
||||
}
|
||||
|
||||
let inner = if body.is_empty() {
|
||||
"# server has no config".to_string()
|
||||
} else {
|
||||
body.into_iter()
|
||||
.map(|l| l.indent(indent + INDENT_SIZE))
|
||||
.collect::<Vec<String>>()
|
||||
.join("\n")
|
||||
};
|
||||
|
||||
Ok(format!(
|
||||
"server {{\n listen {};\n server_name {};\n{}\n}}",
|
||||
self.listen_port, self.domain, inner
|
||||
)
|
||||
.indent(indent))
|
||||
}
|
||||
}
|
||||
223
apps/api/src/services/nginx/info/upstream.rs
Normal file
223
apps/api/src/services/nginx/info/upstream.rs
Normal file
@@ -0,0 +1,223 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use database::generated::entities::{upstream, upstream_target};
|
||||
use sea_orm::ActiveValue::{Set, Unchanged};
|
||||
use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
errors::service_error::ServiceError,
|
||||
services::nginx::{
|
||||
builder::{INDENT_SIZE, NginxConfigProvider},
|
||||
info::upstream_target as upstream_target_info,
|
||||
traits::indentable::Indentable,
|
||||
},
|
||||
set_if_some,
|
||||
};
|
||||
|
||||
const PLACEHOLDER_TARGET: &str = "server 127.0.0.1:65535 down; # placeholder target";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct UpstreamInfo {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub protocol: String,
|
||||
pub algorithm: String,
|
||||
pub sticky_session: bool,
|
||||
pub created_by: Option<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>,
|
||||
//
|
||||
pub upstream_targets: Vec<upstream_target_info::UpstreamTargetCreateInfo>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct UpdateUpstreamInfo {
|
||||
pub name: Option<String>,
|
||||
pub protocol: Option<String>,
|
||||
pub algorithm: Option<String>,
|
||||
pub sticky_session: Option<bool>,
|
||||
//
|
||||
pub upstream_targets: Option<Vec<(Uuid, bool)>>,
|
||||
}
|
||||
|
||||
impl NginxConfigProvider for UpstreamInfo {
|
||||
fn to_nginx_config(&self, indent: Option<usize>) -> Result<String, ServiceError> {
|
||||
let targets_config: Vec<String> = self
|
||||
.upstream_targets
|
||||
.iter()
|
||||
.map(|target| target.to_nginx_config(Some(indent.unwrap_or(0) + INDENT_SIZE)))
|
||||
.collect::<Result<Vec<String>, ServiceError>>()?;
|
||||
|
||||
let mut targets_config_str = {
|
||||
let config_str = match self.algorithm.as_str() {
|
||||
"least-conn" => "least_conn",
|
||||
"ip-hash" => "ip_hash",
|
||||
"round-robin" => "",
|
||||
v => {
|
||||
// TODO: allow arbitrary algorithms via config extensions/plugins
|
||||
warn!(
|
||||
"Unknown upstream algorithm '{}', defaulting to 'round-robin'",
|
||||
v
|
||||
);
|
||||
""
|
||||
}
|
||||
}
|
||||
.to_string();
|
||||
// TODO: add support for sticky session / checking for nginx sticky module existence
|
||||
// if self.sticky_session {
|
||||
// config_str.push_str("sticky")
|
||||
// }
|
||||
if config_str.trim().is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
config_str + ";"
|
||||
}
|
||||
}
|
||||
.indent(indent.unwrap_or(0) + INDENT_SIZE * 2);
|
||||
targets_config_str.push('\n');
|
||||
|
||||
targets_config_str.push_str(
|
||||
&(if targets_config.is_empty() {
|
||||
// add placeholder if no targets
|
||||
PLACEHOLDER_TARGET.to_string()
|
||||
} else {
|
||||
// normal targets
|
||||
targets_config.join("\n")
|
||||
}
|
||||
.indent(indent.unwrap_or(0) + INDENT_SIZE)),
|
||||
);
|
||||
|
||||
// add placeholder if all targets are backup
|
||||
if self.upstream_targets.iter().all(|v| v.is_backup) {
|
||||
targets_config_str.push('\n');
|
||||
targets_config_str
|
||||
.push_str(&PLACEHOLDER_TARGET.indent(indent.unwrap_or(0) + INDENT_SIZE));
|
||||
}
|
||||
|
||||
Ok(
|
||||
format!("upstream {} {{\n{}\n}}", self.name, targets_config_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, Option<Vec<upstream_target::Model>>)> for UpstreamInfo {
|
||||
fn from(data: (upstream::Model, Option<Vec<upstream_target::Model>>)) -> Self {
|
||||
let (upstream_model, upstream_target_models) = data;
|
||||
if let Some(targets) = upstream_target_models {
|
||||
UpstreamInfo::from((upstream_model, targets))
|
||||
} else {
|
||||
UpstreamInfo::from(upstream_model)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<(upstream::Model, Vec<upstream_target::Model>)> for UpstreamInfo {
|
||||
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: 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: Unchanged(current_model.created_by),
|
||||
created_at: Unchanged(current_model.created_at),
|
||||
updated_at: Set(chrono::Utc::now()),
|
||||
}
|
||||
}
|
||||
}
|
||||
162
apps/api/src/services/nginx/info/upstream_target.rs
Normal file
162
apps/api/src/services/nginx/info/upstream_target.rs
Normal file
@@ -0,0 +1,162 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use sea_orm::ActiveValue::{Set, Unchanged};
|
||||
use uuid::Uuid;
|
||||
|
||||
use database::generated::entities::{upstream, upstream_target};
|
||||
|
||||
use crate::{
|
||||
errors::service_error::ServiceError,
|
||||
services::nginx::{builder::NginxConfigProvider, traits::indentable::Indentable},
|
||||
set_if_some,
|
||||
};
|
||||
|
||||
#[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 UpdateUpstreamTargetInfo {
|
||||
pub target_host: Option<String>,
|
||||
pub target_port: Option<i64>,
|
||||
pub weight: Option<i64>,
|
||||
pub is_backup: Option<bool>,
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
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>) -> Result<String, ServiceError> {
|
||||
Ok(format!(
|
||||
"server {}:{} 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: Unchanged(current_model.created_at),
|
||||
updated_at: Set(chrono::Utc::now()),
|
||||
upstream_id: Unchanged(current_model.upstream_id),
|
||||
}
|
||||
}
|
||||
}
|
||||
512
apps/api/src/services/nginx/location.rs
Normal file
512
apps/api/src/services/nginx/location.rs
Normal file
@@ -0,0 +1,512 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, DatabaseConnection, DatabaseTransaction, EntityTrait,
|
||||
ModelTrait, QueryFilter, QuerySelect, QueryTrait,
|
||||
};
|
||||
|
||||
use database::generated::entities::{location, proxy_host, upstream};
|
||||
|
||||
use crate::{
|
||||
errors::service_error::ServiceError,
|
||||
helpers::database::PaginationFilter,
|
||||
services::nginx::info::location::{CreateLocationInfo, LocationInfo, UpdateLocationInfo},
|
||||
with_conn,
|
||||
};
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait LocationService: Send + Sync {
|
||||
async fn create_location(
|
||||
&self,
|
||||
create_info: CreateLocationInfo,
|
||||
tx: Option<&mut DatabaseTransaction>,
|
||||
) -> Result<LocationInfo, ServiceError>;
|
||||
#[allow(dead_code)]
|
||||
async fn get_locations(
|
||||
&self,
|
||||
pagination: Option<PaginationFilter>,
|
||||
options: Option<GetLocationOptions>,
|
||||
tx: Option<&mut DatabaseTransaction>,
|
||||
) -> Result<Vec<LocationInfo>, ServiceError>;
|
||||
async fn get_location(
|
||||
&self,
|
||||
location_id: uuid::Uuid,
|
||||
options: Option<GetLocationOptions>,
|
||||
tx: Option<&mut DatabaseTransaction>,
|
||||
) -> Result<LocationInfo, ServiceError>;
|
||||
async fn update_location(
|
||||
&self,
|
||||
location_id: uuid::Uuid,
|
||||
update: UpdateLocationInfo,
|
||||
tx: Option<&mut DatabaseTransaction>,
|
||||
) -> Result<LocationInfo, ServiceError>;
|
||||
async fn delete_location(
|
||||
&self,
|
||||
location_id: uuid::Uuid,
|
||||
tx: Option<&mut DatabaseTransaction>,
|
||||
) -> Result<(), ServiceError>;
|
||||
}
|
||||
|
||||
pub struct LocationServiceImpl {
|
||||
connection: Arc<DatabaseConnection>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub struct LocationTotalCountOptions {}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct GetLocationOptions {
|
||||
pub include_upstream: bool,
|
||||
#[allow(dead_code)]
|
||||
pub filter_by_enabled: bool,
|
||||
}
|
||||
|
||||
impl LocationServiceImpl {
|
||||
pub fn new(connection: Arc<DatabaseConnection>) -> Self {
|
||||
Self { connection }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl LocationService for LocationServiceImpl {
|
||||
async fn create_location(
|
||||
&self,
|
||||
create_info: CreateLocationInfo,
|
||||
tx: Option<&mut DatabaseTransaction>,
|
||||
) -> Result<LocationInfo, ServiceError> {
|
||||
let model: location::ActiveModel = create_info.into();
|
||||
|
||||
let r = with_conn!(&*self.connection, tx, conn, { model.insert(*conn).await? });
|
||||
Ok(r.into())
|
||||
}
|
||||
|
||||
async fn get_locations(
|
||||
&self,
|
||||
pagination: Option<PaginationFilter>,
|
||||
options: Option<GetLocationOptions>,
|
||||
tx: Option<&mut DatabaseTransaction>,
|
||||
) -> Result<Vec<LocationInfo>, ServiceError> {
|
||||
let r = with_conn!(&*self.connection, tx, conn, {
|
||||
let mut find_query = location::Entity::find();
|
||||
if let Some(pagination) = pagination {
|
||||
let (offset, limit) = pagination.get_offset_limit();
|
||||
find_query = find_query.offset(offset).limit(limit);
|
||||
}
|
||||
let find_query = find_query
|
||||
.apply_if(
|
||||
options
|
||||
.as_ref()
|
||||
.is_some_and(|v| v.filter_by_enabled)
|
||||
.then_some(true),
|
||||
|q, _v| q.filter(location::Column::Enabled.eq(true)),
|
||||
)
|
||||
.find_also_related(proxy_host::Entity);
|
||||
|
||||
let r: Vec<LocationInfo> = if options.as_ref().is_some_and(|v| v.include_upstream) {
|
||||
find_query
|
||||
.find_also_related(upstream::Entity)
|
||||
.all(*conn)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|v| v.into())
|
||||
.collect()
|
||||
} else {
|
||||
find_query
|
||||
.all(*conn)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|m| m.into())
|
||||
.collect()
|
||||
};
|
||||
r
|
||||
});
|
||||
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
async fn get_location(
|
||||
&self,
|
||||
location_id: uuid::Uuid,
|
||||
options: Option<GetLocationOptions>,
|
||||
tx: Option<&mut DatabaseTransaction>,
|
||||
) -> Result<LocationInfo, ServiceError> {
|
||||
let r = with_conn!(&*self.connection, tx, conn, {
|
||||
let find_query =
|
||||
location::Entity::find_by_id(location_id).find_also_related(proxy_host::Entity);
|
||||
|
||||
let r: Option<LocationInfo> = if options.as_ref().is_some_and(|v| v.include_upstream) {
|
||||
find_query
|
||||
.find_also_related(upstream::Entity)
|
||||
.one(*conn)
|
||||
.await?
|
||||
.map(|v| v.into())
|
||||
} else {
|
||||
find_query.one(*conn).await?.map(|m| m.into())
|
||||
};
|
||||
r
|
||||
});
|
||||
|
||||
Ok(r.ok_or(ServiceError::NotFound(format!(
|
||||
"Location with id {} not found",
|
||||
location_id
|
||||
)))?)
|
||||
}
|
||||
|
||||
async fn update_location(
|
||||
&self,
|
||||
location_id: uuid::Uuid,
|
||||
update: UpdateLocationInfo,
|
||||
tx: Option<&mut DatabaseTransaction>,
|
||||
) -> Result<LocationInfo, ServiceError> {
|
||||
let current_model = with_conn!(&*self.connection, tx, conn, {
|
||||
location::Entity::find_by_id(location_id)
|
||||
.one(*conn)
|
||||
.await?
|
||||
.ok_or(ServiceError::NotFound(format!(
|
||||
"Location with id {} not found",
|
||||
location_id
|
||||
)))?
|
||||
});
|
||||
let active_model = update.apply_to_model(current_model);
|
||||
|
||||
let r = with_conn!(&*self.connection, tx, conn, {
|
||||
active_model.update(*conn).await?
|
||||
});
|
||||
Ok(r.into())
|
||||
}
|
||||
|
||||
async fn delete_location(
|
||||
&self,
|
||||
location_id: uuid::Uuid,
|
||||
tx: Option<&mut DatabaseTransaction>,
|
||||
) -> Result<(), ServiceError> {
|
||||
let model = with_conn!(&*self.connection, tx, conn, {
|
||||
location::Entity::find_by_id(location_id)
|
||||
.one(*conn)
|
||||
.await?
|
||||
.ok_or(ServiceError::NotFound(format!(
|
||||
"Location with id {} not found",
|
||||
location_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::{location, proxy_host};
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_location_returns_info() {
|
||||
let host_id = uuid::Uuid::new_v4();
|
||||
let created = location::Model {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
host_id,
|
||||
path: "/test".to_string(),
|
||||
match_type: "prefix".to_string(),
|
||||
order: 0,
|
||||
upstream_id: None,
|
||||
proxy_pass_protocol: None,
|
||||
proxy_pass_host: None,
|
||||
proxy_pass_port: None,
|
||||
preserve_host_header: None,
|
||||
allowed_methods: None,
|
||||
custom_config: None,
|
||||
enabled: true,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![vec![created.clone()]])
|
||||
.into_connection();
|
||||
|
||||
let svc = LocationServiceImpl::new(Arc::new(db));
|
||||
|
||||
let create_info = CreateLocationInfo {
|
||||
host_id,
|
||||
path: "/test".to_string(),
|
||||
match_type: "prefix".to_string(),
|
||||
order: 0,
|
||||
upstream_id: None,
|
||||
proxy_pass_protocol: None,
|
||||
proxy_pass_host: None,
|
||||
proxy_pass_port: None,
|
||||
preserve_host_header: None,
|
||||
allowed_methods: None,
|
||||
custom_config: None,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
let res = svc.create_location(create_info, None).await;
|
||||
assert!(res.is_ok());
|
||||
let info = res.expect("Failed to create location");
|
||||
assert_eq!(info.path, "/test");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_locations_returns_list() {
|
||||
let host_id = uuid::Uuid::new_v4();
|
||||
|
||||
let l1 = location::Model {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
host_id,
|
||||
path: "/a".to_string(),
|
||||
match_type: "prefix".to_string(),
|
||||
order: 0,
|
||||
upstream_id: None,
|
||||
proxy_pass_protocol: None,
|
||||
proxy_pass_host: None,
|
||||
proxy_pass_port: None,
|
||||
preserve_host_header: None,
|
||||
allowed_methods: None,
|
||||
custom_config: None,
|
||||
enabled: true,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
let l2 = location::Model {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
host_id,
|
||||
path: "/b".to_string(),
|
||||
match_type: "prefix".to_string(),
|
||||
order: 1,
|
||||
upstream_id: None,
|
||||
proxy_pass_protocol: None,
|
||||
proxy_pass_host: None,
|
||||
proxy_pass_port: None,
|
||||
preserve_host_header: None,
|
||||
allowed_methods: None,
|
||||
custom_config: None,
|
||||
enabled: true,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![vec![
|
||||
(l1.clone(), None::<proxy_host::Model>),
|
||||
(l2.clone(), None::<proxy_host::Model>),
|
||||
]])
|
||||
.into_connection();
|
||||
|
||||
let svc = LocationServiceImpl::new(Arc::new(db));
|
||||
|
||||
let res = svc.get_locations(None, None, None).await;
|
||||
assert!(res.is_ok());
|
||||
let list = res.expect("Failed to get locations");
|
||||
assert_eq!(list.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_location_with_upstream_returns_upstream() {
|
||||
let host_id = uuid::Uuid::new_v4();
|
||||
let up_id = uuid::Uuid::new_v4();
|
||||
|
||||
let loc = location::Model {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
host_id,
|
||||
path: "/up".to_string(),
|
||||
match_type: "prefix".to_string(),
|
||||
order: 0,
|
||||
upstream_id: Some(up_id),
|
||||
proxy_pass_protocol: None,
|
||||
proxy_pass_host: None,
|
||||
proxy_pass_port: None,
|
||||
preserve_host_header: None,
|
||||
allowed_methods: None,
|
||||
custom_config: None,
|
||||
enabled: true,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![vec![(loc.clone(), None::<proxy_host::Model>)]])
|
||||
.into_connection();
|
||||
|
||||
let svc = LocationServiceImpl::new(Arc::new(db));
|
||||
|
||||
let res = svc
|
||||
.get_location(
|
||||
loc.id,
|
||||
Some(GetLocationOptions {
|
||||
include_upstream: false,
|
||||
filter_by_enabled: false,
|
||||
}),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(res.is_ok());
|
||||
let info = res.expect("Failed to get location");
|
||||
assert_eq!(info.id, loc.id);
|
||||
assert_eq!(info.upstream_id, Some(up_id));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_location_not_found_returns_not_found() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![Vec::<sea_orm::MockRow>::new()])
|
||||
.into_connection();
|
||||
|
||||
let svc = LocationServiceImpl::new(Arc::new(db));
|
||||
|
||||
let res = svc.get_location(uuid::Uuid::new_v4(), None, None).await;
|
||||
|
||||
assert!(matches!(res, Err(ServiceError::NotFound(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_location_success() {
|
||||
let id = uuid::Uuid::new_v4();
|
||||
let host_id = uuid::Uuid::new_v4();
|
||||
let existing = location::Model {
|
||||
id,
|
||||
host_id,
|
||||
path: "/old".to_string(),
|
||||
match_type: "prefix".to_string(),
|
||||
order: 0,
|
||||
upstream_id: None,
|
||||
proxy_pass_protocol: None,
|
||||
proxy_pass_host: None,
|
||||
proxy_pass_port: None,
|
||||
preserve_host_header: None,
|
||||
allowed_methods: None,
|
||||
custom_config: None,
|
||||
enabled: true,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
let updated = location::Model {
|
||||
id,
|
||||
host_id,
|
||||
path: "/new".to_string(),
|
||||
match_type: "prefix".to_string(),
|
||||
order: 0,
|
||||
upstream_id: None,
|
||||
proxy_pass_protocol: None,
|
||||
proxy_pass_host: None,
|
||||
proxy_pass_port: None,
|
||||
preserve_host_header: None,
|
||||
allowed_methods: None,
|
||||
custom_config: None,
|
||||
enabled: true,
|
||||
created_at: 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 = LocationServiceImpl::new(Arc::new(db));
|
||||
|
||||
let update_info = UpdateLocationInfo {
|
||||
path: Some("/new".to_string()),
|
||||
match_type: None,
|
||||
order: None,
|
||||
upstream_id: None,
|
||||
proxy_pass_protocol: None,
|
||||
proxy_pass_host: None,
|
||||
proxy_pass_port: None,
|
||||
preserve_host_header: None,
|
||||
allowed_methods: None,
|
||||
custom_config: None,
|
||||
enabled: None,
|
||||
};
|
||||
|
||||
let res = svc.update_location(id, update_info, None).await;
|
||||
assert!(res.is_ok());
|
||||
let got = res.expect("Failed to update location");
|
||||
assert_eq!(got.path, "/new");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_location_not_found() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![Vec::<sea_orm::MockRow>::new()])
|
||||
.into_connection();
|
||||
|
||||
let svc = LocationServiceImpl::new(Arc::new(db));
|
||||
|
||||
let res = svc
|
||||
.update_location(
|
||||
uuid::Uuid::new_v4(),
|
||||
UpdateLocationInfo {
|
||||
path: None,
|
||||
match_type: None,
|
||||
order: None,
|
||||
upstream_id: None,
|
||||
proxy_pass_protocol: None,
|
||||
proxy_pass_host: None,
|
||||
proxy_pass_port: None,
|
||||
preserve_host_header: None,
|
||||
allowed_methods: None,
|
||||
custom_config: None,
|
||||
enabled: None,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(matches!(res, Err(ServiceError::NotFound(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_location_success() {
|
||||
let id = uuid::Uuid::new_v4();
|
||||
let existing = location::Model {
|
||||
id,
|
||||
host_id: uuid::Uuid::new_v4(),
|
||||
path: "/del".to_string(),
|
||||
match_type: "prefix".to_string(),
|
||||
order: 0,
|
||||
upstream_id: None,
|
||||
proxy_pass_protocol: None,
|
||||
proxy_pass_host: None,
|
||||
proxy_pass_port: None,
|
||||
preserve_host_header: None,
|
||||
allowed_methods: None,
|
||||
custom_config: None,
|
||||
enabled: true,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![vec![existing.clone()]])
|
||||
.append_exec_results(vec![MockExecResult {
|
||||
rows_affected: 1,
|
||||
last_insert_id: 0,
|
||||
}])
|
||||
.into_connection();
|
||||
|
||||
let svc = LocationServiceImpl::new(Arc::new(db));
|
||||
let res = svc.delete_location(id, None).await;
|
||||
assert!(res.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_location_not_found() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![Vec::<sea_orm::MockRow>::new()])
|
||||
.into_connection();
|
||||
|
||||
let svc = LocationServiceImpl::new(Arc::new(db));
|
||||
|
||||
let res = svc.delete_location(uuid::Uuid::new_v4(), None).await;
|
||||
assert!(matches!(res, Err(ServiceError::NotFound(_))));
|
||||
}
|
||||
}
|
||||
622
apps/api/src/services/nginx/proxy_host.rs
Normal file
622
apps/api/src/services/nginx/proxy_host.rs
Normal file
@@ -0,0 +1,622 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection, DatabaseTransaction,
|
||||
EntityTrait, FromQueryResult, JoinType, ModelTrait, QueryFilter, QuerySelect, QueryTrait,
|
||||
RelationTrait, TransactionTrait,
|
||||
};
|
||||
|
||||
use database::generated::entities::{location, proxy_host};
|
||||
|
||||
use crate::{
|
||||
errors::service_error::ServiceError,
|
||||
helpers::database::PaginationFilter,
|
||||
services::nginx::{
|
||||
builder::NginxConfigBuilder,
|
||||
info::proxy_host::{ProxyHostCreateInfo, ProxyHostInfo, UpdateProxyHostInfo},
|
||||
},
|
||||
with_conn,
|
||||
};
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait ProxyService: Send + Sync {
|
||||
async fn create_proxy(
|
||||
&self,
|
||||
create_info: ProxyHostCreateInfo,
|
||||
tx: &Option<&mut DatabaseTransaction>,
|
||||
) -> Result<ProxyHostInfo, ServiceError>;
|
||||
async fn get_total_proxies(
|
||||
&self,
|
||||
options: Option<ProxyTotalCountOptions>,
|
||||
tx: &Option<&mut DatabaseTransaction>,
|
||||
) -> Result<u64, ServiceError>;
|
||||
async fn get_proxies(
|
||||
&self,
|
||||
pagination: Option<PaginationFilter>,
|
||||
options: Option<ProxyHostListOptions>,
|
||||
tx: &Option<&mut DatabaseTransaction>,
|
||||
) -> Result<Vec<ProxyHostInfo>, ServiceError>;
|
||||
async fn get_proxy(
|
||||
&self,
|
||||
proxy_id: uuid::Uuid,
|
||||
options: Option<ProxyHostGetOptions>,
|
||||
tx: &Option<&mut DatabaseTransaction>,
|
||||
) -> Result<ProxyHostInfo, ServiceError>;
|
||||
async fn update_proxy(
|
||||
&self,
|
||||
proxy_id: uuid::Uuid,
|
||||
update: UpdateProxyHostInfo,
|
||||
tx: &Option<&mut DatabaseTransaction>,
|
||||
) -> Result<ProxyHostInfo, ServiceError>;
|
||||
async fn delete_proxy(
|
||||
&self,
|
||||
proxy_id: uuid::Uuid,
|
||||
tx: &Option<&mut DatabaseTransaction>,
|
||||
) -> Result<(), ServiceError>;
|
||||
async fn generate_config(
|
||||
&self,
|
||||
builder: &mut NginxConfigBuilder,
|
||||
tx: &Option<&mut DatabaseTransaction>,
|
||||
) -> Result<(), ServiceError>;
|
||||
}
|
||||
|
||||
pub struct ProxyServiceImpl {
|
||||
connection: Arc<DatabaseConnection>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub struct ProxyTotalCountOptions {}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ProxyHostGetOptions {
|
||||
pub include_upstream: bool,
|
||||
pub filter_by_enabled: bool,
|
||||
}
|
||||
|
||||
pub type ProxyHostListOptions = ProxyHostGetOptions;
|
||||
|
||||
impl ProxyServiceImpl {
|
||||
pub fn new(connection: Arc<DatabaseConnection>) -> Self {
|
||||
Self { connection }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ProxyService for ProxyServiceImpl {
|
||||
async fn create_proxy(
|
||||
&self,
|
||||
create_info: ProxyHostCreateInfo,
|
||||
tx: &Option<&mut DatabaseTransaction>,
|
||||
) -> Result<ProxyHostInfo, ServiceError> {
|
||||
let (proxy_host, location_models): (proxy_host::ActiveModel, Vec<location::ActiveModel>) =
|
||||
create_info.into();
|
||||
|
||||
let owned_tx = match tx {
|
||||
Some(_) => None,
|
||||
None => Some(self.connection.begin().await.map_err(ServiceError::from)?),
|
||||
};
|
||||
|
||||
let tx_ref = owned_tx.as_ref().or(tx.as_deref());
|
||||
|
||||
let r = with_conn!(&*self.connection, tx_ref, conn, {
|
||||
let inserted_proxy = proxy_host.insert(*conn).await?;
|
||||
let mut inserted_location_models: Vec<location::Model> =
|
||||
Vec::with_capacity(location_models.len());
|
||||
|
||||
for mut loc_model in location_models {
|
||||
loc_model.host_id = Set(inserted_proxy.id);
|
||||
let r = loc_model.insert(*conn).await?;
|
||||
inserted_location_models.push(r);
|
||||
}
|
||||
(inserted_proxy, inserted_location_models)
|
||||
});
|
||||
|
||||
Ok(r.into())
|
||||
}
|
||||
|
||||
async fn get_total_proxies(
|
||||
&self,
|
||||
_options: Option<ProxyTotalCountOptions>,
|
||||
tx: &Option<&mut DatabaseTransaction>,
|
||||
) -> Result<u64, ServiceError> {
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
struct CountResult {
|
||||
count: i64,
|
||||
}
|
||||
let count_info = with_conn!(&*self.connection, tx, conn, {
|
||||
proxy_host::Entity::find()
|
||||
.select_only()
|
||||
.column_as(proxy_host::Column::Id.count(), "count")
|
||||
.into_model::<CountResult>()
|
||||
.one(*conn)
|
||||
.await?
|
||||
});
|
||||
Ok(count_info.map_or(0, |c| c.count) as u64)
|
||||
}
|
||||
|
||||
async fn get_proxies(
|
||||
&self,
|
||||
pagination: Option<PaginationFilter>,
|
||||
options: Option<ProxyHostListOptions>,
|
||||
tx: &Option<&mut DatabaseTransaction>,
|
||||
) -> Result<Vec<ProxyHostInfo>, ServiceError> {
|
||||
let r = with_conn!(&*self.connection, tx, conn, {
|
||||
let mut find_query = proxy_host::Entity::find();
|
||||
if let Some(pagination) = pagination {
|
||||
let (offset, limit) = pagination.get_offset_limit();
|
||||
find_query = find_query.offset(offset).limit(limit);
|
||||
}
|
||||
|
||||
let find_query = find_query
|
||||
.apply_if(
|
||||
options
|
||||
.as_ref()
|
||||
.is_some_and(|v| v.filter_by_enabled)
|
||||
.then_some(true),
|
||||
|q, _v| q.filter(location::Column::Enabled.eq(true)),
|
||||
)
|
||||
.find_with_related(location::Entity);
|
||||
|
||||
let r: Vec<ProxyHostInfo> = if options.as_ref().is_some_and(|v| v.include_upstream) {
|
||||
find_query
|
||||
.join(JoinType::LeftJoin, proxy_host::Relation::Upstream.def())
|
||||
.all(*conn)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|v| v.into())
|
||||
.collect()
|
||||
} else {
|
||||
find_query
|
||||
.all(*conn)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|m| m.into())
|
||||
.collect()
|
||||
};
|
||||
r
|
||||
});
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
async fn get_proxy(
|
||||
&self,
|
||||
proxy_id: uuid::Uuid,
|
||||
options: Option<ProxyHostGetOptions>,
|
||||
tx: &Option<&mut DatabaseTransaction>,
|
||||
) -> Result<ProxyHostInfo, ServiceError> {
|
||||
let r: ProxyHostInfo = with_conn!(&*self.connection, tx, conn, {
|
||||
let find_query = proxy_host::Entity::find_by_id(proxy_id)
|
||||
.apply_if(
|
||||
options
|
||||
.as_ref()
|
||||
.is_some_and(|v| v.filter_by_enabled)
|
||||
.then_some(true),
|
||||
|q, _v| q.filter(location::Column::Enabled.eq(true)),
|
||||
)
|
||||
.find_with_related(location::Entity);
|
||||
let r: Option<ProxyHostInfo> = if options.as_ref().is_some_and(|v| v.include_upstream) {
|
||||
find_query
|
||||
.join(JoinType::LeftJoin, proxy_host::Relation::Upstream.def())
|
||||
.all(*conn)
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|v| v.into())
|
||||
} else {
|
||||
find_query
|
||||
.all(*conn)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|m| m.into())
|
||||
.next()
|
||||
};
|
||||
r.ok_or(ServiceError::NotFound(format!(
|
||||
"Proxy host with id {} not found",
|
||||
proxy_id
|
||||
)))?
|
||||
});
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
async fn update_proxy(
|
||||
&self,
|
||||
proxy_id: uuid::Uuid,
|
||||
update: UpdateProxyHostInfo,
|
||||
tx: &Option<&mut DatabaseTransaction>,
|
||||
) -> Result<ProxyHostInfo, ServiceError> {
|
||||
let current_model = with_conn!(&*self.connection, tx, conn, {
|
||||
proxy_host::Entity::find_by_id(proxy_id)
|
||||
.one(*conn)
|
||||
.await?
|
||||
.ok_or(ServiceError::NotFound(format!(
|
||||
"Proxy host with id {} not found",
|
||||
proxy_id
|
||||
)))?
|
||||
});
|
||||
let active_model = update.apply_to_model(current_model);
|
||||
|
||||
let r = with_conn!(&*self.connection, tx, conn, {
|
||||
active_model.update(*conn).await?
|
||||
});
|
||||
Ok(r.into())
|
||||
}
|
||||
|
||||
async fn delete_proxy(
|
||||
&self,
|
||||
proxy_id: uuid::Uuid,
|
||||
tx: &Option<&mut DatabaseTransaction>,
|
||||
) -> Result<(), ServiceError> {
|
||||
let model = with_conn!(&*self.connection, tx, conn, {
|
||||
proxy_host::Entity::find_by_id(proxy_id)
|
||||
.one(*conn)
|
||||
.await?
|
||||
.ok_or(ServiceError::NotFound(format!(
|
||||
"Proxy host with id {} not found",
|
||||
proxy_id
|
||||
)))?
|
||||
});
|
||||
with_conn!(&*self.connection, tx, conn, {
|
||||
model.delete(*conn).await?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
async fn generate_config(
|
||||
&self,
|
||||
builder: &mut NginxConfigBuilder,
|
||||
tx: &Option<&mut DatabaseTransaction>,
|
||||
) -> Result<(), ServiceError> {
|
||||
let proxies = self
|
||||
.get_proxies(
|
||||
None,
|
||||
Some(ProxyHostListOptions {
|
||||
include_upstream: true,
|
||||
..Default::default()
|
||||
}),
|
||||
tx,
|
||||
)
|
||||
.await?;
|
||||
builder.add_proxy_hosts(proxies);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
use sea_orm::MockExecResult;
|
||||
use sea_orm::{DatabaseBackend, MockDatabase};
|
||||
|
||||
use database::generated::entities::{location, proxy_host};
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_proxy_returns_info() {
|
||||
let id = uuid::Uuid::new_v4();
|
||||
let created = proxy_host::Model {
|
||||
id,
|
||||
name: Some("test_proxy".to_string()),
|
||||
domain: "example.com".to_string(),
|
||||
scheme: "http".to_string(),
|
||||
listen_port: 80,
|
||||
forward_scheme: "http".to_string(),
|
||||
forward_host: None,
|
||||
forward_port: None,
|
||||
preserve_host_header: false,
|
||||
enable_websocket: false,
|
||||
enabled: true,
|
||||
meta: None,
|
||||
default_upstream_id: None,
|
||||
created_by: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let loc = location::Model {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
host_id: id,
|
||||
path: "/".to_string(),
|
||||
match_type: "prefix".to_string(),
|
||||
order: 0,
|
||||
upstream_id: None,
|
||||
proxy_pass_protocol: None,
|
||||
proxy_pass_host: None,
|
||||
proxy_pass_port: None,
|
||||
preserve_host_header: None,
|
||||
allowed_methods: None,
|
||||
custom_config: None,
|
||||
enabled: true,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![vec![created.clone()]])
|
||||
.append_query_results(vec![vec![loc.clone()]])
|
||||
.into_connection();
|
||||
|
||||
let svc = ProxyServiceImpl::new(Arc::new(db));
|
||||
|
||||
let create_info = crate::services::nginx::info::proxy_host::ProxyHostCreateInfo {
|
||||
name: Some("test_proxy".to_string()),
|
||||
domain: "example.com".to_string(),
|
||||
scheme: "http".to_string(),
|
||||
listen_port: 80,
|
||||
forward_scheme: "http".to_string(),
|
||||
forward_host: None,
|
||||
forward_port: None,
|
||||
preserve_host_header: false,
|
||||
enable_websocket: false,
|
||||
enabled: true,
|
||||
meta: None,
|
||||
default_upstream_id: None,
|
||||
created_by: None,
|
||||
locations: Vec::new(),
|
||||
};
|
||||
|
||||
let res = svc.create_proxy(create_info, &None).await;
|
||||
assert!(res.is_ok());
|
||||
let info = res.expect("Failed to create proxy");
|
||||
assert_eq!(info.domain, "example.com");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_total_proxies_returns_count() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![Vec::<sea_orm::MockRow>::new()])
|
||||
.into_connection();
|
||||
|
||||
let svc = ProxyServiceImpl::new(Arc::new(db));
|
||||
let res = svc
|
||||
.get_total_proxies(None, &None)
|
||||
.await
|
||||
.expect("Failed to get total proxies");
|
||||
assert_eq!(res, 0u64);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_proxies_returns_list() {
|
||||
let p1 = proxy_host::Model {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
name: Some("p1".to_string()),
|
||||
domain: "d1".to_string(),
|
||||
scheme: "http".to_string(),
|
||||
listen_port: 80,
|
||||
forward_scheme: "http".to_string(),
|
||||
forward_host: None,
|
||||
forward_port: None,
|
||||
preserve_host_header: false,
|
||||
enable_websocket: false,
|
||||
enabled: true,
|
||||
meta: None,
|
||||
default_upstream_id: None,
|
||||
created_by: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
let p2 = proxy_host::Model {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
name: Some("p2".to_string()),
|
||||
domain: "d2".to_string(),
|
||||
scheme: "http".to_string(),
|
||||
listen_port: 80,
|
||||
forward_scheme: "http".to_string(),
|
||||
forward_host: None,
|
||||
forward_port: None,
|
||||
preserve_host_header: false,
|
||||
enable_websocket: false,
|
||||
enabled: true,
|
||||
meta: None,
|
||||
default_upstream_id: None,
|
||||
created_by: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![vec![
|
||||
(p1.clone(), None::<location::Model>),
|
||||
(p2.clone(), None::<location::Model>),
|
||||
]])
|
||||
.into_connection();
|
||||
|
||||
let svc = ProxyServiceImpl::new(Arc::new(db));
|
||||
let res = svc.get_proxies(None, None, &None).await;
|
||||
assert!(res.is_ok());
|
||||
let list = res.expect("Failed to get proxies");
|
||||
assert_eq!(list.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_proxy_returns_info() {
|
||||
let id = uuid::Uuid::new_v4();
|
||||
let p = proxy_host::Model {
|
||||
id,
|
||||
name: Some("proxy".to_string()),
|
||||
domain: "ex.com".to_string(),
|
||||
scheme: "http".to_string(),
|
||||
listen_port: 80,
|
||||
forward_scheme: "http".to_string(),
|
||||
forward_host: None,
|
||||
forward_port: None,
|
||||
preserve_host_header: false,
|
||||
enable_websocket: false,
|
||||
enabled: true,
|
||||
meta: None,
|
||||
default_upstream_id: None,
|
||||
created_by: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![vec![(p.clone(), None::<location::Model>)]])
|
||||
.into_connection();
|
||||
|
||||
let svc = ProxyServiceImpl::new(Arc::new(db));
|
||||
let res = svc.get_proxy(id, None, &None).await;
|
||||
assert!(res.is_ok());
|
||||
let got = res.expect("Failed to get proxy");
|
||||
assert_eq!(got.id, id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_proxy_not_found() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![Vec::<sea_orm::MockRow>::new()])
|
||||
.into_connection();
|
||||
|
||||
let svc = ProxyServiceImpl::new(Arc::new(db));
|
||||
let res = svc.get_proxy(uuid::Uuid::new_v4(), None, &None).await;
|
||||
assert!(matches!(res, Err(ServiceError::NotFound(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_proxy_success() {
|
||||
let id = uuid::Uuid::new_v4();
|
||||
let existing = proxy_host::Model {
|
||||
id,
|
||||
name: Some("old".to_string()),
|
||||
domain: "d".to_string(),
|
||||
scheme: "http".to_string(),
|
||||
listen_port: 80,
|
||||
forward_scheme: "http".to_string(),
|
||||
forward_host: None,
|
||||
forward_port: None,
|
||||
preserve_host_header: false,
|
||||
enable_websocket: false,
|
||||
enabled: true,
|
||||
meta: None,
|
||||
default_upstream_id: None,
|
||||
created_by: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
let updated = proxy_host::Model {
|
||||
id,
|
||||
name: Some("new".to_string()),
|
||||
domain: existing.domain.clone(),
|
||||
scheme: existing.scheme.clone(),
|
||||
listen_port: existing.listen_port,
|
||||
forward_scheme: existing.forward_scheme.clone(),
|
||||
forward_host: existing.forward_host.clone(),
|
||||
forward_port: existing.forward_port,
|
||||
preserve_host_header: existing.preserve_host_header,
|
||||
enable_websocket: existing.enable_websocket,
|
||||
enabled: existing.enabled,
|
||||
meta: existing.meta.clone(),
|
||||
default_upstream_id: existing.default_upstream_id,
|
||||
created_by: existing.created_by,
|
||||
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 = ProxyServiceImpl::new(Arc::new(db));
|
||||
|
||||
let update_info = crate::services::nginx::info::proxy_host::UpdateProxyHostInfo {
|
||||
name: None,
|
||||
domain: None,
|
||||
scheme: None,
|
||||
listen_port: None,
|
||||
forward_scheme: None,
|
||||
forward_host: None,
|
||||
forward_port: None,
|
||||
preserve_host_header: None,
|
||||
enable_websocket: None,
|
||||
enabled: None,
|
||||
meta: None,
|
||||
default_upstream_id: None,
|
||||
};
|
||||
|
||||
let res = svc.update_proxy(id, update_info, &None).await;
|
||||
assert!(res.is_ok());
|
||||
let got = res.expect("Failed to update proxy");
|
||||
assert_eq!(got.name.expect("Name should be present"), "new");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_proxy_not_found() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![Vec::<sea_orm::MockRow>::new()])
|
||||
.into_connection();
|
||||
|
||||
let svc = ProxyServiceImpl::new(Arc::new(db));
|
||||
|
||||
let res = svc
|
||||
.update_proxy(
|
||||
uuid::Uuid::new_v4(),
|
||||
crate::services::nginx::info::proxy_host::UpdateProxyHostInfo {
|
||||
name: None,
|
||||
domain: None,
|
||||
scheme: None,
|
||||
listen_port: None,
|
||||
forward_scheme: None,
|
||||
forward_host: None,
|
||||
forward_port: None,
|
||||
preserve_host_header: None,
|
||||
enable_websocket: None,
|
||||
enabled: None,
|
||||
meta: None,
|
||||
default_upstream_id: None,
|
||||
},
|
||||
&None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(matches!(res, Err(ServiceError::NotFound(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_proxy_success() {
|
||||
let id = uuid::Uuid::new_v4();
|
||||
let existing = proxy_host::Model {
|
||||
id,
|
||||
name: Some("to-delete".to_string()),
|
||||
domain: "d".to_string(),
|
||||
scheme: "http".to_string(),
|
||||
listen_port: 80,
|
||||
forward_scheme: "http".to_string(),
|
||||
forward_host: None,
|
||||
forward_port: None,
|
||||
preserve_host_header: false,
|
||||
enable_websocket: false,
|
||||
enabled: true,
|
||||
meta: None,
|
||||
default_upstream_id: None,
|
||||
created_by: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![vec![existing.clone()]])
|
||||
.append_exec_results(vec![MockExecResult {
|
||||
rows_affected: 1,
|
||||
last_insert_id: 0,
|
||||
}])
|
||||
.into_connection();
|
||||
|
||||
let svc = ProxyServiceImpl::new(Arc::new(db));
|
||||
|
||||
let res = svc.delete_proxy(id, &None).await;
|
||||
assert!(res.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_proxy_not_found() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![Vec::<sea_orm::MockRow>::new()])
|
||||
.into_connection();
|
||||
|
||||
let svc = ProxyServiceImpl::new(Arc::new(db));
|
||||
|
||||
let res = svc.delete_proxy(uuid::Uuid::new_v4(), &None).await;
|
||||
assert!(matches!(res, Err(ServiceError::NotFound(_))));
|
||||
}
|
||||
}
|
||||
1
apps/api/src/services/nginx/traits.rs
Normal file
1
apps/api/src/services/nginx/traits.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod indentable;
|
||||
31
apps/api/src/services/nginx/traits/indentable.rs
Normal file
31
apps/api/src/services/nginx/traits/indentable.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
pub trait Indentable<T> {
|
||||
fn indent(&self, spaces: T) -> String;
|
||||
}
|
||||
|
||||
impl Indentable<usize> for &str {
|
||||
fn indent(&self, spaces: usize) -> String {
|
||||
let indent_str = " ".repeat(spaces);
|
||||
self.lines()
|
||||
.map(|line| format!("{}{}", indent_str, line))
|
||||
.collect::<Vec<String>>()
|
||||
.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
impl Indentable<Option<usize>> for String {
|
||||
fn indent(&self, spaces: Option<usize>) -> String {
|
||||
self.as_str().indent(spaces.unwrap_or(0))
|
||||
}
|
||||
}
|
||||
|
||||
impl Indentable<usize> for String {
|
||||
fn indent(&self, spaces: usize) -> String {
|
||||
self.as_str().indent(spaces)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indentable<Option<usize>> for &str {
|
||||
fn indent(&self, spaces: Option<usize>) -> String {
|
||||
self.indent(spaces.unwrap_or(0))
|
||||
}
|
||||
}
|
||||
882
apps/api/src/services/nginx/upstream.rs
Normal file
882
apps/api/src/services/nginx/upstream.rs
Normal file
@@ -0,0 +1,882 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, DatabaseConnection, DatabaseTransaction, EntityTrait, ExprTrait,
|
||||
FromQueryResult, ModelTrait, QueryFilter, QuerySelect, QueryTrait, TransactionTrait,
|
||||
};
|
||||
|
||||
use database::generated::entities::{upstream, upstream_target};
|
||||
|
||||
use crate::{
|
||||
errors::service_error::ServiceError,
|
||||
helpers::database::PaginationFilter,
|
||||
services::nginx::{
|
||||
builder::NginxConfigBuilder,
|
||||
info::{
|
||||
upstream::{UpdateUpstreamInfo, UpstreamCreateInfo, UpstreamInfo},
|
||||
upstream_target::{
|
||||
UpdateUpstreamTargetInfo, UpstreamTargetCreateInfo, UpstreamTargetInfo,
|
||||
},
|
||||
},
|
||||
},
|
||||
with_conn,
|
||||
};
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait UpstreamService: Send + Sync {
|
||||
async fn create_upstream(
|
||||
&self,
|
||||
create_info: UpstreamCreateInfo,
|
||||
tx: Option<&mut DatabaseTransaction>,
|
||||
) -> Result<UpstreamInfo, ServiceError>;
|
||||
async fn get_total_upstreams(
|
||||
&self,
|
||||
options: Option<UpstreamTotalCountOptions>,
|
||||
tx: Option<&mut DatabaseTransaction>,
|
||||
) -> Result<u64, ServiceError>;
|
||||
async fn get_upstream(
|
||||
&self,
|
||||
upstream_id: uuid::Uuid,
|
||||
options: Option<GetUpstreamOptions>,
|
||||
tx: Option<&mut DatabaseTransaction>,
|
||||
) -> Result<UpstreamInfo, ServiceError>;
|
||||
async fn get_upstreams(
|
||||
&self,
|
||||
pagination: Option<PaginationFilter>,
|
||||
options: Option<GetUpstreamOptions>,
|
||||
tx: Option<&mut DatabaseTransaction>,
|
||||
) -> Result<Vec<UpstreamInfo>, ServiceError>;
|
||||
async fn update_upstream(
|
||||
&self,
|
||||
id: uuid::Uuid,
|
||||
upstream: UpdateUpstreamInfo,
|
||||
tx: Option<&mut DatabaseTransaction>,
|
||||
) -> Result<UpstreamInfo, ServiceError>;
|
||||
async fn delete_upstream(
|
||||
&self,
|
||||
upstream_id: uuid::Uuid,
|
||||
tx: Option<&mut DatabaseTransaction>,
|
||||
) -> Result<(), ServiceError>;
|
||||
async fn create_upstream_target(
|
||||
&self,
|
||||
create_info: UpstreamTargetCreateInfo,
|
||||
tx: Option<&mut DatabaseTransaction>,
|
||||
) -> Result<UpstreamTargetInfo, ServiceError>;
|
||||
async fn get_upstream_target(
|
||||
&self,
|
||||
target_id: uuid::Uuid,
|
||||
options: Option<GetUpstreamTargetOptions>,
|
||||
tx: Option<&mut DatabaseTransaction>,
|
||||
) -> Result<UpstreamTargetInfo, ServiceError>;
|
||||
#[allow(dead_code)]
|
||||
async fn get_upstream_targets_by_upstream(
|
||||
&self,
|
||||
upstream_id: uuid::Uuid,
|
||||
tx: Option<&mut DatabaseTransaction>,
|
||||
) -> Result<Vec<UpstreamTargetInfo>, ServiceError>;
|
||||
async fn update_upstream_target(
|
||||
&self,
|
||||
id: uuid::Uuid,
|
||||
target: UpdateUpstreamTargetInfo,
|
||||
tx: Option<&mut DatabaseTransaction>,
|
||||
) -> Result<UpstreamTargetInfo, ServiceError>;
|
||||
async fn delete_upstream_target(
|
||||
&self,
|
||||
target_id: uuid::Uuid,
|
||||
tx: Option<&mut DatabaseTransaction>,
|
||||
) -> Result<(), ServiceError>;
|
||||
async fn generate_config(
|
||||
&self,
|
||||
builder: &mut NginxConfigBuilder,
|
||||
tx: &Option<&mut DatabaseTransaction>,
|
||||
) -> Result<(), ServiceError>;
|
||||
}
|
||||
|
||||
pub struct UpstreamServiceImpl {
|
||||
connection: Arc<DatabaseConnection>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct GetUpstreamOptions {
|
||||
pub include_targets: bool,
|
||||
pub filter_by_enabled: bool,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub struct UpstreamTotalCountOptions {}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct GetUpstreamTargetOptions {
|
||||
pub include_upstream: bool,
|
||||
}
|
||||
|
||||
impl UpstreamServiceImpl {
|
||||
pub fn new(connection: Arc<DatabaseConnection>) -> Self {
|
||||
Self { connection }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl UpstreamService for UpstreamServiceImpl {
|
||||
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())
|
||||
}
|
||||
|
||||
async fn get_total_upstreams(
|
||||
&self,
|
||||
_options: Option<UpstreamTotalCountOptions>,
|
||||
tx: Option<&mut DatabaseTransaction>,
|
||||
) -> Result<u64, ServiceError> {
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
struct CountResult {
|
||||
// The field name must match the column alias in the query
|
||||
count: i64,
|
||||
}
|
||||
let count_info = with_conn!(&*self.connection, tx, conn, {
|
||||
upstream::Entity::find()
|
||||
.select_only()
|
||||
.column_as(upstream::Column::Id.count(), "count")
|
||||
.into_model::<CountResult>()
|
||||
.one(*conn)
|
||||
.await?
|
||||
});
|
||||
Ok(count_info.map_or(0, |c| c.count) as u64)
|
||||
}
|
||||
|
||||
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))
|
||||
.apply_if(
|
||||
concrete_options.filter_by_enabled.then_some(true),
|
||||
|query, _v| query.filter(upstream_target::Column::Enabled.eq(true)),
|
||||
)
|
||||
.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)
|
||||
}
|
||||
|
||||
async fn get_upstreams(
|
||||
&self,
|
||||
pagination: Option<PaginationFilter>,
|
||||
options: Option<GetUpstreamOptions>,
|
||||
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
|
||||
};
|
||||
let find_query = match options {
|
||||
Some(opts) => {
|
||||
if opts.include_targets && opts.filter_by_enabled {
|
||||
find_query.filter(
|
||||
upstream_target::Column::Enabled
|
||||
.eq(true)
|
||||
.or(upstream_target::Column::Id.is_null()),
|
||||
)
|
||||
} else {
|
||||
find_query
|
||||
}
|
||||
}
|
||||
_ => find_query,
|
||||
};
|
||||
find_query
|
||||
.find_with_related(upstream_target::Entity)
|
||||
.all(*conn)
|
||||
.await?
|
||||
});
|
||||
|
||||
Ok(r.into_iter().map(|m| m.into()).collect())
|
||||
}
|
||||
|
||||
async fn update_upstream(
|
||||
&self,
|
||||
id: uuid::Uuid,
|
||||
upstream: UpdateUpstreamInfo,
|
||||
tx: Option<&mut DatabaseTransaction>,
|
||||
) -> Result<UpstreamInfo, ServiceError> {
|
||||
// If a transaction was provided use it, otherwise create and own one here.
|
||||
let mut maybe_owned_tx: Option<DatabaseTransaction> = None;
|
||||
let tx_ref: Option<&mut DatabaseTransaction> = if let Some(tx) = tx {
|
||||
Some(tx)
|
||||
} else {
|
||||
maybe_owned_tx = Some(self.connection.begin().await?);
|
||||
maybe_owned_tx.as_mut()
|
||||
};
|
||||
|
||||
let current_model = with_conn!(&*self.connection, tx_ref, conn, {
|
||||
upstream::Entity::find_by_id(id)
|
||||
.one(*conn)
|
||||
.await?
|
||||
.ok_or(ServiceError::NotFound(format!(
|
||||
"Upstream with id {} not found",
|
||||
id
|
||||
)))?
|
||||
});
|
||||
let upstream_active_model = upstream.clone().apply_to_model(current_model);
|
||||
|
||||
let r = with_conn!(&*self.connection, tx_ref, conn, {
|
||||
let updated_upstream_model = upstream_active_model.update(*conn).await?;
|
||||
|
||||
// update upstream targets if any
|
||||
if let Some(targets) = upstream.upstream_targets {
|
||||
for (target_id, enabled) in targets.into_iter() {
|
||||
let target_model = upstream_target::Entity::find_by_id(target_id)
|
||||
.one(*conn)
|
||||
.await?
|
||||
.ok_or(ServiceError::NotFound(format!(
|
||||
"Upstream target with id {} not found",
|
||||
target_id
|
||||
)))?;
|
||||
let mut target_active_model: upstream_target::ActiveModel = target_model.into();
|
||||
target_active_model.enabled = sea_orm::ActiveValue::Set(enabled);
|
||||
|
||||
target_active_model.update(*conn).await?;
|
||||
Ok::<(), ServiceError>(())?;
|
||||
}
|
||||
}
|
||||
|
||||
updated_upstream_model
|
||||
});
|
||||
|
||||
// Commit
|
||||
if let Some(t) = maybe_owned_tx.take() {
|
||||
t.commit().await?;
|
||||
}
|
||||
Ok(r.into())
|
||||
}
|
||||
|
||||
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, {
|
||||
// delete all targets belonging to the upstream
|
||||
upstream_target::Entity::delete_many()
|
||||
.filter(upstream_target::Column::UpstreamId.eq(upstream_id))
|
||||
.exec(*conn)
|
||||
.await?;
|
||||
model.delete(*conn).await?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
//
|
||||
//
|
||||
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())
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
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 = with_conn!(&*self.connection, tx, conn, {
|
||||
active_model.update(*conn).await?
|
||||
});
|
||||
Ok(r.into())
|
||||
}
|
||||
|
||||
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(())
|
||||
})
|
||||
}
|
||||
|
||||
async fn generate_config(
|
||||
&self,
|
||||
builder: &mut NginxConfigBuilder,
|
||||
tx: &Option<&mut DatabaseTransaction>,
|
||||
) -> Result<(), ServiceError> {
|
||||
// get all upstreams and their targets
|
||||
let upstreams = with_conn!(&*self.connection, tx, conn, {
|
||||
upstream::Entity::find()
|
||||
.find_with_related(upstream_target::Entity)
|
||||
.all(*conn)
|
||||
.await?
|
||||
});
|
||||
let upstreams_info = upstreams
|
||||
.into_iter()
|
||||
.map(|(up_model, target_models)| (up_model, target_models).into())
|
||||
.collect::<Vec<UpstreamInfo>>();
|
||||
builder.add_upstreams(upstreams_info);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
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 = UpstreamServiceImpl::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 = UpstreamServiceImpl::new(Arc::new(db));
|
||||
|
||||
let res = svc
|
||||
.get_upstream(
|
||||
up_id,
|
||||
Some(GetUpstreamOptions {
|
||||
include_targets: true,
|
||||
filter_by_enabled: false,
|
||||
}),
|
||||
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 = UpstreamServiceImpl::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(), None::<upstream_target::Model>),
|
||||
(u2.clone(), None::<upstream_target::Model>),
|
||||
]])
|
||||
.into_connection();
|
||||
|
||||
let svc = UpstreamServiceImpl::new(Arc::new(db));
|
||||
|
||||
let res = svc.get_upstreams(None, 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 = UpstreamServiceImpl::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 = UpstreamServiceImpl::new(Arc::new(db));
|
||||
|
||||
let update_info = crate::services::nginx::info::upstream::UpdateUpstreamInfo {
|
||||
name: None,
|
||||
protocol: None,
|
||||
algorithm: None,
|
||||
sticky_session: 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 = UpstreamServiceImpl::new(Arc::new(db));
|
||||
|
||||
let res = svc
|
||||
.update_upstream(
|
||||
uuid::Uuid::new_v4(),
|
||||
crate::services::nginx::info::upstream::UpdateUpstreamInfo {
|
||||
name: None,
|
||||
protocol: None,
|
||||
algorithm: None,
|
||||
sticky_session: 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,
|
||||
},
|
||||
MockExecResult {
|
||||
rows_affected: 1,
|
||||
last_insert_id: 0,
|
||||
},
|
||||
])
|
||||
.into_connection();
|
||||
|
||||
let svc = UpstreamServiceImpl::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 = UpstreamServiceImpl::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 = UpstreamServiceImpl::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 = UpstreamServiceImpl::new(Arc::new(db));
|
||||
|
||||
let update_info = crate::services::nginx::info::upstream_target::UpdateUpstreamTargetInfo {
|
||||
target_host: None,
|
||||
target_port: None,
|
||||
weight: None,
|
||||
is_backup: None,
|
||||
enabled: 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 = UpstreamServiceImpl::new(Arc::new(db));
|
||||
let res = svc.delete_upstream_target(id, None).await;
|
||||
assert!(res.is_ok());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,84 @@
|
||||
export namespace Schemas {
|
||||
// <Schemas>
|
||||
export type AdminInitRequest = { password: string; setup_secret: string; username: string };
|
||||
export type CreateLocationReq = {
|
||||
match_type: string;
|
||||
order: number;
|
||||
path: string;
|
||||
upstream_id?: (string | null) | undefined;
|
||||
};
|
||||
export type CreateLocationRequestBodyByUpstreamId = {
|
||||
allowed_methods?: (Array<string> | null) | undefined;
|
||||
custom_config?: (string | null) | undefined;
|
||||
match_type: string;
|
||||
order: number;
|
||||
path: string;
|
||||
preserve_host_header?: (boolean | null) | undefined;
|
||||
upstream_id: string;
|
||||
};
|
||||
export type CreateLocationRequestBodyByProxyPass = {
|
||||
allowed_methods?: (Array<string> | null) | undefined;
|
||||
custom_config?: (string | null) | undefined;
|
||||
match_type: string;
|
||||
order: number;
|
||||
path: string;
|
||||
preserve_host_header?: (boolean | null) | undefined;
|
||||
proxy_pass_host: string;
|
||||
proxy_pass_port: number;
|
||||
proxy_pass_protocol: string;
|
||||
};
|
||||
export type CreateLocationRequestBody = CreateLocationRequestBodyByUpstreamId | CreateLocationRequestBodyByProxyPass;
|
||||
export type CreateProxyRequestBody = {
|
||||
default_upstream_id?: (string | null) | undefined;
|
||||
domain: string;
|
||||
enable_websocket: boolean;
|
||||
enabled: boolean;
|
||||
forward_host?: (string | null) | undefined;
|
||||
forward_port?: (number | null) | undefined;
|
||||
forward_scheme: string;
|
||||
listen_port: number;
|
||||
locations: Array<CreateLocationReq>;
|
||||
meta?: unknown | undefined;
|
||||
name?: (string | null) | undefined;
|
||||
preserve_host_header: boolean;
|
||||
scheme: string;
|
||||
};
|
||||
export type UpstreamBasicInfo = {
|
||||
created_at: string;
|
||||
id: string;
|
||||
name: string;
|
||||
protocol: string;
|
||||
updated_at: string;
|
||||
};
|
||||
export type UpstreamTargetInfo = {
|
||||
created_at: string;
|
||||
enabled: boolean;
|
||||
id: string;
|
||||
is_backup: boolean;
|
||||
target_host: string;
|
||||
target_port: number;
|
||||
updated_at: string;
|
||||
upstream?: (null | UpstreamBasicInfo) | undefined;
|
||||
upstream_id: string;
|
||||
weight: number;
|
||||
};
|
||||
export type CreateUpstreamRequestBody = {
|
||||
algorithm?: (string | null) | undefined;
|
||||
name: string;
|
||||
protocol: string;
|
||||
sticky_session?: (boolean | null) | undefined;
|
||||
upstream_targets: Array<UpstreamTargetInfo>;
|
||||
};
|
||||
export type CreateUpstreamTargetInfo = {
|
||||
enabled?: (boolean | null) | undefined;
|
||||
host: string;
|
||||
is_backup?: (boolean | null) | undefined;
|
||||
port: number;
|
||||
upstream_id: string;
|
||||
weight?: (number | null) | undefined;
|
||||
};
|
||||
export type GetUpstreamParams = Partial<{ include_targets: boolean | null }>;
|
||||
export type GetUpstreamTargetsParams = Partial<{ include_upstream: boolean | null }>;
|
||||
export type HealthInfo = {
|
||||
errors?: (Array<string> | null) | undefined;
|
||||
is_initialized: boolean;
|
||||
@@ -8,7 +86,115 @@ export namespace Schemas {
|
||||
up_since: string;
|
||||
version: string;
|
||||
};
|
||||
export type LocationInfoResponse = {
|
||||
created_at: string;
|
||||
enabled: boolean;
|
||||
host_id: string;
|
||||
id: string;
|
||||
match_type: string;
|
||||
order: number;
|
||||
path: string;
|
||||
updated_at: string;
|
||||
upstream_id?: (string | null) | undefined;
|
||||
};
|
||||
export type LoginRequest = { password: string; username: string };
|
||||
export type PaginationInfo = { current_page: number; per_page: number; total_items: number; total_pages: number };
|
||||
export type ProxyHostUpstreamBasic = { id: string; name: string; protocol: string };
|
||||
export type ProxyHostInfoResponse = {
|
||||
created_at: string;
|
||||
domain: string;
|
||||
enable_websocket: boolean;
|
||||
enabled: boolean;
|
||||
forward_host?: (string | null) | undefined;
|
||||
forward_port?: (number | null) | undefined;
|
||||
forward_scheme: string;
|
||||
id: string;
|
||||
listen_port: number;
|
||||
locations: Array<LocationInfoResponse>;
|
||||
name?: (string | null) | undefined;
|
||||
preserve_host_header: boolean;
|
||||
scheme: string;
|
||||
updated_at: string;
|
||||
upstream?: (null | ProxyHostUpstreamBasic) | undefined;
|
||||
};
|
||||
export type ProxyListResponse = { items: Array<ProxyHostInfoResponse>; pagination: PaginationInfo };
|
||||
export type UpdateLocationRequestBody = Partial<{
|
||||
match_type: string | null;
|
||||
order: number | null;
|
||||
path: string | null;
|
||||
upstream_id: string | null;
|
||||
}>;
|
||||
export type UpdateProxyRequestBody = Partial<{ domain: string | null; name: string | null }>;
|
||||
export type UpstreamTargetBasicInfo = {
|
||||
created_at: string;
|
||||
enabled: boolean;
|
||||
id: string;
|
||||
is_backup: boolean;
|
||||
target_host: string;
|
||||
target_port: number;
|
||||
updated_at: string;
|
||||
weight: number;
|
||||
};
|
||||
export type UpdateUpstreamInfoResponse = {
|
||||
algorithm: string;
|
||||
created_at: string;
|
||||
created_by?: (string | null) | undefined;
|
||||
id: string;
|
||||
name: string;
|
||||
protocol: string;
|
||||
sticky_session: boolean;
|
||||
updated_at: string;
|
||||
upstream_targets: Array<UpstreamTargetBasicInfo>;
|
||||
};
|
||||
export type UpdateUpstreamRequestBody = Partial<{
|
||||
algorithm: string | null;
|
||||
name: string | null;
|
||||
protocol: string | null;
|
||||
sticky_session: boolean | null;
|
||||
upstream_targets: Array<UpstreamTargetBasicUpdateInfo> | null;
|
||||
}>;
|
||||
export type UpdateUpstreamTargetInfoResponse = {
|
||||
created_at: string;
|
||||
enabled: boolean;
|
||||
host: string;
|
||||
id: string;
|
||||
is_backup: boolean;
|
||||
port: number;
|
||||
updated_at: string;
|
||||
upstream_id: string;
|
||||
weight: number;
|
||||
};
|
||||
export type UpdateUpstreamTargetRequestBody = Partial<{
|
||||
enabled: boolean | null;
|
||||
host: string | null;
|
||||
is_backup: boolean | null;
|
||||
port: number | null;
|
||||
weight: number | null;
|
||||
}>;
|
||||
export type UpstreamInfoResponse = {
|
||||
algorithm: string;
|
||||
created_at: string;
|
||||
created_by?: (string | null) | undefined;
|
||||
id: string;
|
||||
name: string;
|
||||
protocol: string;
|
||||
sticky_session: boolean;
|
||||
updated_at: string;
|
||||
upstream_targets: Array<UpstreamTargetBasicInfo>;
|
||||
};
|
||||
export type UpstreamListResponse = { items: Array<UpstreamInfoResponse>; pagination: PaginationInfo };
|
||||
export type UpstreamTargetBasicUpdateInfo = { enabled: boolean; id: number };
|
||||
export type UpstreamTargetInfoResponse = {
|
||||
created_at: string;
|
||||
enabled: boolean;
|
||||
host: string;
|
||||
id: string;
|
||||
is_backup: boolean;
|
||||
port: number;
|
||||
updated_at: string;
|
||||
upstream_id: string;
|
||||
weight: number;
|
||||
};
|
||||
export type UserInfo = { id: string; username: string };
|
||||
|
||||
// </Schemas>
|
||||
@@ -42,6 +228,180 @@ export namespace Endpoints {
|
||||
parameters: never;
|
||||
responses: { 200: Schemas.HealthInfo; 404: unknown };
|
||||
};
|
||||
export type get_Get_location = {
|
||||
method: "GET";
|
||||
path: "/api/nginx/locations/{location_id}";
|
||||
requestFormat: "json";
|
||||
parameters: {
|
||||
path: { location_id: string };
|
||||
};
|
||||
responses: { 200: Schemas.LocationInfoResponse; 404: unknown; 500: unknown };
|
||||
};
|
||||
export type delete_Remove_location = {
|
||||
method: "DELETE";
|
||||
path: "/api/nginx/locations/{location_id}";
|
||||
requestFormat: "json";
|
||||
parameters: {
|
||||
path: { location_id: string };
|
||||
};
|
||||
responses: { 200: unknown; 401: unknown; 404: unknown; 500: unknown };
|
||||
};
|
||||
export type patch_Update_location = {
|
||||
method: "PATCH";
|
||||
path: "/api/nginx/locations/{location_id}";
|
||||
requestFormat: "json";
|
||||
parameters: {
|
||||
path: { location_id: string };
|
||||
|
||||
body: Schemas.UpdateLocationRequestBody;
|
||||
};
|
||||
responses: { 200: Schemas.LocationInfoResponse; 401: unknown; 404: unknown; 422: unknown; 500: unknown };
|
||||
};
|
||||
export type get_Get_proxy_list = {
|
||||
method: "GET";
|
||||
path: "/api/nginx/proxy_hosts";
|
||||
requestFormat: "json";
|
||||
parameters: never;
|
||||
responses: { 200: Schemas.ProxyListResponse; 500: unknown };
|
||||
};
|
||||
export type post_Create_proxy = {
|
||||
method: "POST";
|
||||
path: "/api/nginx/proxy_hosts";
|
||||
requestFormat: "json";
|
||||
parameters: {
|
||||
body: Schemas.CreateProxyRequestBody;
|
||||
};
|
||||
responses: { 200: Schemas.ProxyHostInfoResponse; 401: unknown; 422: unknown; 500: unknown };
|
||||
};
|
||||
export type get_Get_proxy = {
|
||||
method: "GET";
|
||||
path: "/api/nginx/proxy_hosts/{proxy_id}";
|
||||
requestFormat: "json";
|
||||
parameters: {
|
||||
path: { proxy_id: string };
|
||||
};
|
||||
responses: { 200: Schemas.ProxyHostInfoResponse; 404: unknown; 500: unknown };
|
||||
};
|
||||
export type delete_Remove_proxy = {
|
||||
method: "DELETE";
|
||||
path: "/api/nginx/proxy_hosts/{proxy_id}";
|
||||
requestFormat: "json";
|
||||
parameters: {
|
||||
path: { proxy_id: string };
|
||||
};
|
||||
responses: { 200: unknown; 401: unknown; 404: unknown; 500: unknown };
|
||||
};
|
||||
export type patch_Update_proxy = {
|
||||
method: "PATCH";
|
||||
path: "/api/nginx/proxy_hosts/{proxy_id}";
|
||||
requestFormat: "json";
|
||||
parameters: {
|
||||
path: { proxy_id: string };
|
||||
|
||||
body: Schemas.UpdateProxyRequestBody;
|
||||
};
|
||||
responses: { 200: Schemas.ProxyHostInfoResponse; 401: unknown; 422: unknown; 500: unknown };
|
||||
};
|
||||
export type post_Create_location = {
|
||||
method: "POST";
|
||||
path: "/api/nginx/proxy_hosts/{proxy_id}/locations";
|
||||
requestFormat: "json";
|
||||
parameters: {
|
||||
path: { proxy_id: string };
|
||||
|
||||
body: Schemas.CreateLocationRequestBody;
|
||||
};
|
||||
responses: { 200: Schemas.LocationInfoResponse; 401: unknown; 422: unknown; 500: unknown };
|
||||
};
|
||||
export type get_Get_upstream_target = {
|
||||
method: "GET";
|
||||
path: "/api/nginx/upstream_targets/{upstream_target_id}";
|
||||
requestFormat: "json";
|
||||
parameters: {
|
||||
path: { upstream_target_id: string };
|
||||
};
|
||||
responses: { 200: Schemas.UpstreamTargetInfo; 404: unknown; 500: unknown };
|
||||
};
|
||||
export type delete_Remove_upstream_target = {
|
||||
method: "DELETE";
|
||||
path: "/api/nginx/upstream_targets/{upstream_target_id}";
|
||||
requestFormat: "json";
|
||||
parameters: {
|
||||
path: { upstream_target_id: string };
|
||||
};
|
||||
responses: { 200: unknown; 401: unknown; 404: unknown; 500: unknown };
|
||||
};
|
||||
export type patch_Update_upstream_target = {
|
||||
method: "PATCH";
|
||||
path: "/api/nginx/upstream_targets/{upstream_target_id}";
|
||||
requestFormat: "json";
|
||||
parameters: {
|
||||
path: { upstream_target_id: string };
|
||||
|
||||
body: Schemas.UpdateUpstreamTargetRequestBody;
|
||||
};
|
||||
responses: {
|
||||
200: Schemas.UpdateUpstreamTargetInfoResponse;
|
||||
401: unknown;
|
||||
404: unknown;
|
||||
422: unknown;
|
||||
500: unknown;
|
||||
};
|
||||
};
|
||||
export type get_Get_upstream_list = {
|
||||
method: "GET";
|
||||
path: "/api/nginx/upstreams";
|
||||
requestFormat: "json";
|
||||
parameters: never;
|
||||
responses: { 200: Schemas.UpstreamListResponse; 500: unknown };
|
||||
};
|
||||
export type post_Create_upstream = {
|
||||
method: "POST";
|
||||
path: "/api/nginx/upstreams";
|
||||
requestFormat: "json";
|
||||
parameters: {
|
||||
body: Schemas.CreateUpstreamRequestBody;
|
||||
};
|
||||
responses: { 200: Schemas.UpstreamInfoResponse; 401: unknown; 422: unknown; 500: unknown };
|
||||
};
|
||||
export type get_Get_upstream = {
|
||||
method: "GET";
|
||||
path: "/api/nginx/upstreams/{upstream_id}";
|
||||
requestFormat: "json";
|
||||
parameters: {
|
||||
path: { upstream_id: string };
|
||||
};
|
||||
responses: { 200: Schemas.UpstreamInfoResponse; 404: unknown; 500: unknown };
|
||||
};
|
||||
export type delete_Remove_upstream = {
|
||||
method: "DELETE";
|
||||
path: "/api/nginx/upstreams/{upstream_id}";
|
||||
requestFormat: "json";
|
||||
parameters: {
|
||||
path: { upstream_id: string };
|
||||
};
|
||||
responses: { 200: unknown; 401: unknown; 404: unknown; 500: unknown };
|
||||
};
|
||||
export type patch_Update_upstream = {
|
||||
method: "PATCH";
|
||||
path: "/api/nginx/upstreams/{upstream_id}";
|
||||
requestFormat: "json";
|
||||
parameters: {
|
||||
path: { upstream_id: string };
|
||||
|
||||
body: Schemas.UpdateUpstreamRequestBody;
|
||||
};
|
||||
responses: { 200: Schemas.UpdateUpstreamInfoResponse; 401: unknown; 404: unknown; 422: unknown; 500: unknown };
|
||||
};
|
||||
export type post_Add_upstream_target = {
|
||||
method: "POST";
|
||||
path: "/api/nginx/upstreams/{upstream_id}/targets";
|
||||
requestFormat: "json";
|
||||
parameters: {
|
||||
body: Schemas.CreateUpstreamTargetInfo;
|
||||
};
|
||||
responses: { 200: Schemas.UpstreamTargetInfoResponse; 401: unknown; 422: unknown; 500: unknown };
|
||||
};
|
||||
export type get_Get_user_info = {
|
||||
method: "GET";
|
||||
path: "/api/user/me";
|
||||
@@ -58,11 +418,33 @@ export type EndpointByMethod = {
|
||||
post: {
|
||||
"/api/auth/init_admin": Endpoints.post_Init_admin;
|
||||
"/api/auth/login": Endpoints.post_Login;
|
||||
"/api/nginx/proxy_hosts": Endpoints.post_Create_proxy;
|
||||
"/api/nginx/proxy_hosts/{proxy_id}/locations": Endpoints.post_Create_location;
|
||||
"/api/nginx/upstreams": Endpoints.post_Create_upstream;
|
||||
"/api/nginx/upstreams/{upstream_id}/targets": Endpoints.post_Add_upstream_target;
|
||||
};
|
||||
get: {
|
||||
"/api/health/info": Endpoints.get_Get_health_info;
|
||||
"/api/nginx/locations/{location_id}": Endpoints.get_Get_location;
|
||||
"/api/nginx/proxy_hosts": Endpoints.get_Get_proxy_list;
|
||||
"/api/nginx/proxy_hosts/{proxy_id}": Endpoints.get_Get_proxy;
|
||||
"/api/nginx/upstream_targets/{upstream_target_id}": Endpoints.get_Get_upstream_target;
|
||||
"/api/nginx/upstreams": Endpoints.get_Get_upstream_list;
|
||||
"/api/nginx/upstreams/{upstream_id}": Endpoints.get_Get_upstream;
|
||||
"/api/user/me": Endpoints.get_Get_user_info;
|
||||
};
|
||||
delete: {
|
||||
"/api/nginx/locations/{location_id}": Endpoints.delete_Remove_location;
|
||||
"/api/nginx/proxy_hosts/{proxy_id}": Endpoints.delete_Remove_proxy;
|
||||
"/api/nginx/upstream_targets/{upstream_target_id}": Endpoints.delete_Remove_upstream_target;
|
||||
"/api/nginx/upstreams/{upstream_id}": Endpoints.delete_Remove_upstream;
|
||||
};
|
||||
patch: {
|
||||
"/api/nginx/locations/{location_id}": Endpoints.patch_Update_location;
|
||||
"/api/nginx/proxy_hosts/{proxy_id}": Endpoints.patch_Update_proxy;
|
||||
"/api/nginx/upstream_targets/{upstream_target_id}": Endpoints.patch_Update_upstream_target;
|
||||
"/api/nginx/upstreams/{upstream_id}": Endpoints.patch_Update_upstream;
|
||||
};
|
||||
};
|
||||
|
||||
// </EndpointByMethod>
|
||||
@@ -70,6 +452,8 @@ export type EndpointByMethod = {
|
||||
// <EndpointByMethod.Shorthands>
|
||||
export type PostEndpoints = EndpointByMethod["post"];
|
||||
export type GetEndpoints = EndpointByMethod["get"];
|
||||
export type DeleteEndpoints = EndpointByMethod["delete"];
|
||||
export type PatchEndpoints = EndpointByMethod["patch"];
|
||||
// </EndpointByMethod.Shorthands>
|
||||
|
||||
// <ApiClientTypes>
|
||||
@@ -364,6 +748,68 @@ export class ApiClient {
|
||||
}
|
||||
// </ApiClient.get>
|
||||
|
||||
// <ApiClient.delete>
|
||||
delete<Path extends keyof DeleteEndpoints, TEndpoint extends DeleteEndpoints[Path]>(
|
||||
path: Path,
|
||||
...params: MaybeOptionalArg<
|
||||
TEndpoint extends { parameters: infer UParams }
|
||||
? NotNever<UParams> extends true
|
||||
? UParams & { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean }
|
||||
: { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean }
|
||||
: { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean }
|
||||
>
|
||||
): Promise<Extract<InferResponseByStatus<TEndpoint, SuccessStatusCode>, { data: {} }>["data"]>;
|
||||
|
||||
delete<Path extends keyof DeleteEndpoints, TEndpoint extends DeleteEndpoints[Path]>(
|
||||
path: Path,
|
||||
...params: MaybeOptionalArg<
|
||||
TEndpoint extends { parameters: infer UParams }
|
||||
? NotNever<UParams> extends true
|
||||
? UParams & { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean }
|
||||
: { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean }
|
||||
: { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean }
|
||||
>
|
||||
): Promise<SafeApiResponse<TEndpoint>>;
|
||||
|
||||
delete<Path extends keyof DeleteEndpoints, _TEndpoint extends DeleteEndpoints[Path]>(
|
||||
path: Path,
|
||||
...params: MaybeOptionalArg<any>
|
||||
): Promise<any> {
|
||||
return this.request("delete", path, ...params);
|
||||
}
|
||||
// </ApiClient.delete>
|
||||
|
||||
// <ApiClient.patch>
|
||||
patch<Path extends keyof PatchEndpoints, TEndpoint extends PatchEndpoints[Path]>(
|
||||
path: Path,
|
||||
...params: MaybeOptionalArg<
|
||||
TEndpoint extends { parameters: infer UParams }
|
||||
? NotNever<UParams> extends true
|
||||
? UParams & { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean }
|
||||
: { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean }
|
||||
: { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean }
|
||||
>
|
||||
): Promise<Extract<InferResponseByStatus<TEndpoint, SuccessStatusCode>, { data: {} }>["data"]>;
|
||||
|
||||
patch<Path extends keyof PatchEndpoints, TEndpoint extends PatchEndpoints[Path]>(
|
||||
path: Path,
|
||||
...params: MaybeOptionalArg<
|
||||
TEndpoint extends { parameters: infer UParams }
|
||||
? NotNever<UParams> extends true
|
||||
? UParams & { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean }
|
||||
: { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean }
|
||||
: { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean }
|
||||
>
|
||||
): Promise<SafeApiResponse<TEndpoint>>;
|
||||
|
||||
patch<Path extends keyof PatchEndpoints, _TEndpoint extends PatchEndpoints[Path]>(
|
||||
path: Path,
|
||||
...params: MaybeOptionalArg<any>
|
||||
): Promise<any> {
|
||||
return this.request("patch", path, ...params);
|
||||
}
|
||||
// </ApiClient.patch>
|
||||
|
||||
// <ApiClient.request>
|
||||
/**
|
||||
* Generic request method with full type-safety for any endpoint
|
||||
|
||||
@@ -43,6 +43,8 @@ const createQueryKey = <TOptions extends EndpointParameters>(
|
||||
// <EndpointByMethod.Shorthands>
|
||||
export type PostEndpoints = EndpointByMethod["post"];
|
||||
export type GetEndpoints = EndpointByMethod["get"];
|
||||
export type DeleteEndpoints = EndpointByMethod["delete"];
|
||||
export type PatchEndpoints = EndpointByMethod["patch"];
|
||||
// </EndpointByMethod.Shorthands>
|
||||
|
||||
// <ApiClientTypes>
|
||||
@@ -130,6 +132,66 @@ export class TanstackQueryApiClient {
|
||||
}
|
||||
// </ApiClient.get>
|
||||
|
||||
// <ApiClient.delete>
|
||||
delete<Path extends keyof DeleteEndpoints, TEndpoint extends DeleteEndpoints[Path]>(
|
||||
path: Path,
|
||||
...params: MaybeOptionalArg<TEndpoint["parameters"]>
|
||||
) {
|
||||
const queryKey = createQueryKey(path as string, params[0]);
|
||||
const query = {
|
||||
/** type-only property if you need easy access to the endpoint params */
|
||||
"~endpoint": {} as TEndpoint,
|
||||
queryKey,
|
||||
queryFn: {} as "You need to pass .queryOptions to the useQuery hook",
|
||||
queryOptions: queryOptions({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const requestParams = {
|
||||
...(params[0] || {}),
|
||||
...(queryKey[0] || {}),
|
||||
overrides: { signal },
|
||||
withResponse: false as const,
|
||||
};
|
||||
const res = await this.client.delete(path, requestParams as never);
|
||||
return res as InferResponseData<TEndpoint, SuccessStatusCode>;
|
||||
},
|
||||
queryKey: queryKey,
|
||||
}),
|
||||
};
|
||||
|
||||
return query;
|
||||
}
|
||||
// </ApiClient.delete>
|
||||
|
||||
// <ApiClient.patch>
|
||||
patch<Path extends keyof PatchEndpoints, TEndpoint extends PatchEndpoints[Path]>(
|
||||
path: Path,
|
||||
...params: MaybeOptionalArg<TEndpoint["parameters"]>
|
||||
) {
|
||||
const queryKey = createQueryKey(path as string, params[0]);
|
||||
const query = {
|
||||
/** type-only property if you need easy access to the endpoint params */
|
||||
"~endpoint": {} as TEndpoint,
|
||||
queryKey,
|
||||
queryFn: {} as "You need to pass .queryOptions to the useQuery hook",
|
||||
queryOptions: queryOptions({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const requestParams = {
|
||||
...(params[0] || {}),
|
||||
...(queryKey[0] || {}),
|
||||
overrides: { signal },
|
||||
withResponse: false as const,
|
||||
};
|
||||
const res = await this.client.patch(path, requestParams as never);
|
||||
return res as InferResponseData<TEndpoint, SuccessStatusCode>;
|
||||
},
|
||||
queryKey: queryKey,
|
||||
}),
|
||||
};
|
||||
|
||||
return query;
|
||||
}
|
||||
// </ApiClient.patch>
|
||||
|
||||
// <ApiClient.request>
|
||||
/**
|
||||
* Generic mutation method with full type-safety for any endpoint; it doesnt require parameters to be passed initially
|
||||
|
||||
24
justfile
24
justfile
@@ -50,9 +50,31 @@ generate-openapi:
|
||||
# Generate API client for frontend
|
||||
cd apps/frontend && \
|
||||
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 assets
|
||||
|
||||
7
openapitools.json
Normal file
7
openapitools.json
Normal 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
6
package.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"devDependencies": {
|
||||
"@openapitools/openapi-generator-cli": "^2.26.0",
|
||||
"rust-just": "^1.44.0"
|
||||
}
|
||||
}
|
||||
3418
apps/frontend/pnpm-lock.yaml → pnpm-lock.yaml
generated
3418
apps/frontend/pnpm-lock.yaml → pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
7
pnpm-workspace.yaml
Normal file
7
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
packages:
|
||||
- apps/frontend
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- '@nestjs/core'
|
||||
- '@openapitools/openapi-generator-cli'
|
||||
- esbuild
|
||||
3
public/agent-client/.gitignore
vendored
Normal file
3
public/agent-client/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
/target/
|
||||
**/*.rs.bk
|
||||
Cargo.lock
|
||||
23
public/agent-client/.openapi-generator-ignore
Normal file
23
public/agent-client/.openapi-generator-ignore
Normal 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
|
||||
21
public/agent-client/.openapi-generator/FILES
Normal file
21
public/agent-client/.openapi-generator/FILES
Normal 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
|
||||
1
public/agent-client/.openapi-generator/VERSION
Normal file
1
public/agent-client/.openapi-generator/VERSION
Normal file
@@ -0,0 +1 @@
|
||||
7.18.0
|
||||
1
public/agent-client/.travis.yml
Normal file
1
public/agent-client/.travis.yml
Normal file
@@ -0,0 +1 @@
|
||||
language: rust
|
||||
27
public/agent-client/Cargo.toml
Normal file
27
public/agent-client/Cargo.toml
Normal 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"
|
||||
|
||||
53
public/agent-client/README.md
Normal file
53
public/agent-client/README.md
Normal 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
|
||||
|
||||
|
||||
|
||||
121
public/agent-client/docs/NginxAgentApi.md
Normal file
121
public/agent-client/docs/NginxAgentApi.md
Normal 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)
|
||||
|
||||
11
public/agent-client/docs/StatusResp.md
Normal file
11
public/agent-client/docs/StatusResp.md
Normal 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)
|
||||
|
||||
|
||||
121
public/agent-client/docs/TagNginxtagApi.md
Normal file
121
public/agent-client/docs/TagNginxtagApi.md
Normal 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)
|
||||
|
||||
12
public/agent-client/docs/ValidateAndReloadBody.md
Normal file
12
public/agent-client/docs/ValidateAndReloadBody.md
Normal 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)
|
||||
|
||||
|
||||
12
public/agent-client/docs/ValidateAndReloadResp.md
Normal file
12
public/agent-client/docs/ValidateAndReloadResp.md
Normal 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)
|
||||
|
||||
|
||||
12
public/agent-client/docs/ValidateBody.md
Normal file
12
public/agent-client/docs/ValidateBody.md
Normal 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)
|
||||
|
||||
|
||||
13
public/agent-client/docs/WriteConfigBody.md
Normal file
13
public/agent-client/docs/WriteConfigBody.md
Normal 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)
|
||||
|
||||
|
||||
57
public/agent-client/git_push.sh
Normal file
57
public/agent-client/git_push.sh
Normal 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'
|
||||
48
public/agent-client/src/apis/configuration.rs
Normal file
48
public/agent-client/src/apis/configuration.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
165
public/agent-client/src/apis/mod.rs
Normal file
165
public/agent-client/src/apis/mod.rs
Normal 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
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user