66 Commits

Author SHA1 Message Date
1efc4ffc5a Merge pull request 'feature/devcontainer' (#15) from feature/devcontainer into master
All checks were successful
Test / test-frontend (push) Successful in 28s
Test / lint-frontend (push) Successful in 32s
Test / frontend-build (push) Successful in 35s
Verify / verify-openapi-spec (push) Successful in 7s
Verify / verify-generated-database-code (push) Successful in 43s
Verify / verify-generated-agent-code (push) Successful in 1m17s
Verify / verify-frontend-api-client (push) Successful in 10s
Test / test-crates (push) Successful in 1m26s
Test / lint-crates (push) Successful in 1m17s
Reviewed-on: #15
2026-01-15 22:19:34 +08:00
3216cbbd5e feat: add VS Code extensions to devcontainer configuration
All checks were successful
Test / lint-frontend (pull_request) Successful in 1m32s
Test / test-frontend (pull_request) Successful in 1m32s
Test / frontend-build (pull_request) Successful in 2m7s
Verify / verify-openapi-spec (pull_request) Successful in 28s
Verify / verify-generated-agent-code (pull_request) Successful in 2m38s
Test / lint-crates (pull_request) Successful in 2m6s
Verify / verify-frontend-api-client (pull_request) Successful in 10s
Test / test-crates (pull_request) Successful in 4m39s
Verify / verify-generated-database-code (pull_request) Successful in 6m13s
2026-01-15 14:06:10 +00:00
7f0040b668 feat: add devcontainer configuration and setup script 2026-01-15 13:56:48 +00:00
d67a9f6f0d Merge pull request 'feature/proxy-service' (#14) from feature/proxy-service into master
All checks were successful
Test / test-frontend (push) Successful in 22s
Test / lint-frontend (push) Successful in 26s
Test / frontend-build (push) Successful in 30s
Verify / verify-generated-database-code (push) Successful in 1m5s
Verify / verify-generated-agent-code (push) Successful in 1m8s
Verify / verify-openapi-spec (push) Successful in 1m10s
Verify / verify-frontend-api-client (push) Successful in 19s
Test / test-crates (push) Successful in 1m0s
Test / lint-crates (push) Successful in 1m8s
Reviewed-on: #14
2026-01-12 11:56:37 +08:00
GW_MC
4fe03b245e test: added create location with proxy pass tests
All checks were successful
Test / test-frontend (pull_request) Successful in 24s
Test / lint-frontend (pull_request) Successful in 27s
Test / frontend-build (pull_request) Successful in 31s
Verify / verify-generated-agent-code (pull_request) Successful in 1m37s
Verify / verify-generated-database-code (pull_request) Successful in 3m4s
Verify / verify-openapi-spec (pull_request) Successful in 3m0s
Verify / verify-frontend-api-client (pull_request) Successful in 20s
Test / lint-crates (pull_request) Successful in 1m3s
Test / test-crates (pull_request) Successful in 2m50s
2026-01-12 11:49:46 +08:00
GW_MC
b4a36dbe4c Fix: create location support for proxy pass 2026-01-12 11:49:29 +08:00
GW_MC
7ae76f622c feat: added proxy_host builder, and improve transaction borrowing 2026-01-07 16:37:51 +08:00
GW_MC
9b8232d94d feat: Implement NGINX proxy host and location management endpoints
- Add `get_location` endpoint to retrieve location information with optional upstream inclusion.
- Introduce `get_proxy_list` and `get_proxy` endpoints for listing and retrieving proxy hosts.
- Implement `remove_location` and `remove_proxy` endpoints for deleting locations and proxy hosts respectively.
- Add `update_location` and `update_proxy` endpoints for modifying existing locations and proxy hosts.
- Create response structures for location and proxy host information.
- Implement tests for all new endpoints to ensure correct functionality and error handling.
2026-01-07 15:58:21 +08:00
GW_MC
83e02acb22 Fix: Refactor upstream count retrieval and improve query filtering logic 2026-01-07 15:58:03 +08:00
GW_MC
eb1afc87cc feat: Update NginxConfigProvider to return Result with ServiceError for upstream and upstream target 2026-01-07 15:57:53 +08:00
GW_MC
1c0053207c feat: Implement ProxyHost and Location services with CRUD operations
- Added `ProxyHostInfo`, `ProxyHostCreateInfo`, and `UpdateProxyHostInfo` structs to manage proxy host data.
- Created `ProxyService` and `ProxyServiceImpl` for handling proxy host operations including create, read, update, and delete.
- Implemented `LocationService` and `LocationServiceImpl` for managing locations associated with proxy hosts.
- Introduced database transaction handling for creating proxies and locations.
- Added tests for all service methods to ensure functionality and correctness.
2026-01-07 15:57:44 +08:00
GW_MC
ab840126b3 feat: add location and proxy host entities with migrations 2026-01-07 15:57:13 +08:00
1ed065e08e Merge pull request 'feature/upstream-service' (#13) from feature/upstream-service into master
All checks were successful
Test / lint-frontend (push) Successful in 29s
Test / frontend-build (push) Successful in 32s
Verify / verify-generated-database-code (push) Successful in 1m1s
Verify / verify-generated-agent-code (push) Successful in 1m4s
Verify / verify-openapi-spec (push) Successful in 1m2s
Verify / verify-frontend-api-client (push) Successful in 20s
Test / test-crates (push) Successful in 49s
Test / lint-crates (push) Successful in 1m5s
Test / test-frontend (push) Successful in 32s
Reviewed-on: #13
2026-01-01 10:49:30 +08:00
GW_MC
d21459802c Add total upstream count retrieval to UpstreamService
All checks were successful
Test / lint-frontend (pull_request) Successful in 28s
Test / test-frontend (pull_request) Successful in 27s
Test / frontend-build (pull_request) Successful in 30s
Verify / verify-generated-database-code (pull_request) Successful in 1m7s
Verify / verify-generated-agent-code (pull_request) Successful in 1m9s
Verify / verify-openapi-spec (pull_request) Successful in 1m8s
Verify / verify-frontend-api-client (pull_request) Successful in 20s
Test / test-crates (pull_request) Successful in 1m1s
Test / lint-crates (pull_request) Successful in 1m8s
2026-01-01 10:40:44 +08:00
GW_MC
5e1a8364c7 Fix: update test database query results to include upstream target models
All checks were successful
Test / test-frontend (pull_request) Successful in 24s
Test / lint-frontend (pull_request) Successful in 27s
Test / frontend-build (pull_request) Successful in 32s
Verify / verify-generated-database-code (pull_request) Successful in 1m5s
Verify / verify-generated-agent-code (pull_request) Successful in 1m8s
Verify / verify-openapi-spec (pull_request) Successful in 1m5s
Verify / verify-frontend-api-client (pull_request) Successful in 20s
Test / test-crates (pull_request) Successful in 55s
Test / lint-crates (pull_request) Successful in 1m5s
2025-12-31 20:26:20 +08:00
GW_MC
3be9ecc4c1 Refactor: improve config formatting, clean up imports
Some checks failed
Test / test-frontend (pull_request) Successful in 24s
Test / lint-frontend (pull_request) Successful in 27s
Test / frontend-build (pull_request) Successful in 31s
Verify / verify-generated-database-code (pull_request) Successful in 1m9s
Verify / verify-generated-agent-code (pull_request) Successful in 1m12s
Verify / verify-openapi-spec (pull_request) Successful in 1m9s
Verify / verify-frontend-api-client (pull_request) Successful in 22s
Test / test-crates (pull_request) Failing after 58s
Test / lint-crates (pull_request) Successful in 1m8s
2025-12-31 20:10:47 +08:00
GW_MC
545bc66f8c Fix: invalid config when all are backup 2025-12-31 19:17:14 +08:00
GW_MC
75097a661b Add filtering options for upstream targets in get_upstreams 2025-12-31 19:16:55 +08:00
GW_MC
9860dddf60 Fix incorrect upstream_target config 2025-12-31 18:09:30 +08:00
GW_MC
c4634b18f9 Fix CORS method not allowed 2025-12-31 18:09:19 +08:00
GW_MC
a0b4df745e fix upstream does not contain a target when init 2025-12-31 18:03:54 +08:00
GW_MC
46801fba99 improve error logging 2025-12-31 18:03:42 +08:00
GW_MC
cb65d4e9f7 fix incorrect Extension 2025-12-31 18:03:19 +08:00
GW_MC
10cc8f9d97 Fix incorrect path 2025-12-31 18:02:45 +08:00
GW_MC
d184261027 feat: added openapi doc
All checks were successful
Test / lint-frontend (pull_request) Successful in 45s
Test / test-frontend (pull_request) Successful in 44s
Test / frontend-build (pull_request) Successful in 47s
Verify / verify-generated-agent-code (pull_request) Successful in 1m15s
Verify / verify-openapi-spec (pull_request) Successful in 2m29s
Verify / verify-generated-database-code (pull_request) Successful in 2m35s
Verify / verify-frontend-api-client (pull_request) Successful in 19s
Test / lint-crates (pull_request) Successful in 59s
Test / test-crates (pull_request) Successful in 2m44s
2025-12-31 16:44:18 +08:00
GW_MC
6a30a03e59 feat: enhance socket path validation for reqwest client configuration 2025-12-31 16:11:03 +08:00
GW_MC
9c3f775a67 refactor: remove unused Filters struct and clean up imports in create_upstream tests 2025-12-31 16:05:28 +08:00
GW_MC
331b4e1e96 feat: implement transaction handling for upstream and target operations
- Added transaction support in `add_upstream_target`, `remove_upstream`, `remove_upstream_target`, `update_upstream`, and `update_upstream_target` functions to ensure atomicity of operations.
- Updated the `NginxService` to include methods for validating and applying configurations using the agent service.
- Enhanced error handling in agent service interactions, returning appropriate internal server errors when agent communication fails.
- Introduced mock agent service for testing, allowing for simulation of agent interactions without actual network calls.
- Refactored tests to cover scenarios where agent operations fail, ensuring that internal server errors are returned as expected.
2025-12-31 15:57:29 +08:00
GW_MC
4f85d88380 feat: implement conversion from DbErr to ApiError 2025-12-31 14:51:22 +08:00
GW_MC
d81e5fe48d refactor: clean up test module imports in health info endpoint 2025-12-31 14:48:51 +08:00
GW_MC
dff560019f revert editing session 2025-12-31 12:01:08 +08:00
GW_MC
b2a322ed79 chore: added trait for upstream service 2025-12-30 18:22:18 +08:00
GW_MC
f05544267c feat: add remove upstream and remove upstream target handlers 2025-12-30 18:02:46 +08:00
GW_MC
f4db47daf2 feat: implement update handlers for upstream and upstream target management 2025-12-30 15:09:49 +08:00
GW_MC
b43f9fcb00 Refactor database schema: Remove access list and related entities, add editing session and edit operation entities
- Deleted `access_list_entry`, `location`, `proxy_host`, `proxy_host_access_list`, `session`, `stream_service`, `stream_service_access_list` entities and their corresponding migration files.
- Introduced `editing_session` and `edit_operation` entities with appropriate fields and relationships.
- Updated `mod.rs` and `prelude.rs` to reflect the changes in the entity structure.
- Adjusted migration files to remove obsolete migrations and include new migrations for the editing session and edit operation tables.
2025-12-30 15:09:33 +08:00
GW_MC
1b251fd89f feat: add add_upstream_target handler and response struct for upstream target management 2025-12-29 21:34:40 +08:00
GW_MC
dd79cbe0bb feat: add create_upstream handler for upstream creation 2025-12-29 20:12:01 +08:00
GW_MC
37f4163870 added mock for require_auth middleware 2025-12-29 19:30:31 +08:00
GW_MC
481abd7f26 fix: AuthenticatedRequestInfo support any state 2025-12-29 18:58:00 +08:00
GW_MC
7a557d6e00 feat: update UpstreamCreateInfo conversion to include upstream targets 2025-12-29 18:45:03 +08:00
GW_MC
0cbc223b4e feat: implement AuthenticatedRequestInfo for user authentication handling 2025-12-29 18:44:52 +08:00
GW_MC
fdfd1c98e0 feat: introduce ApiError for improved error handling in upstream routes 2025-12-29 18:03:09 +08:00
GW_MC
abeea4fad7 refactor: upstream info response structures and module 2025-12-29 18:01:57 +08:00
GW_MC
fea205cba8 chore: delegate path prefix to router 2025-12-29 17:52:29 +08:00
GW_MC
3cc6b40e61 Fix: missing upstream in From 2025-12-29 17:48:44 +08:00
GW_MC
41023939db feat: add tests for upstream and upstream target handlers 2025-12-29 17:44:41 +08:00
GW_MC
6eb0d9060b fix: path not starting with / 2025-12-29 17:24:50 +08:00
GW_MC
7d83838da3 feat: add get_upstream_target endpoint and update router for upstream targets 2025-12-29 16:38:43 +08:00
GW_MC
921165476c feat: enhance UpstreamTargetInfo with upstream details and refactor imports 2025-12-29 16:38:33 +08:00
GW_MC
a0621e2407 test: added test cases for upstream service 2025-12-29 16:37:57 +08:00
GW_MC
f8b13be650 feat: add Nginx upstream read management routes and handlers 2025-12-29 15:27:03 +08:00
GW_MC
238c3db92b feat: implement Nginx service with upstream management and configuration generation 2025-12-29 15:21:02 +08:00
GW_MC
814f76291c fix: update test cases to use mock 2025-12-29 15:19:06 +08:00
GW_MC
7ac3368715 feat: added intoResponse 2025-12-29 15:18:40 +08:00
GW_MC
35fadb46f6 feat: add pagination helper and integrate serde_urlencoded for query extraction 2025-12-29 12:16:09 +08:00
GW_MC
91d0e1cd7c init nginx related database schema 2025-12-29 12:05:22 +08:00
c33e3aa0ca Merge pull request 'feature/agent-client' (#12) from feature/agent-client into master
All checks were successful
Test / lint-frontend (push) Successful in 26s
Test / test-frontend (push) Successful in 25s
Test / frontend-build (push) Successful in 32s
Verify / verify-generated-database-code (push) Successful in 1m8s
Verify / verify-generated-agent-code (push) Successful in 1m11s
Verify / verify-openapi-spec (push) Successful in 1m7s
Verify / verify-frontend-api-client (push) Successful in 8s
Test / test-crates (push) Successful in 52s
Test / lint-crates (push) Successful in 1m8s
Reviewed-on: #12
2025-12-28 19:25:34 +08:00
GW_MC
6f5596dc69 Enforce deny unwrap_used
All checks were successful
Test / test-frontend (pull_request) Successful in 26s
Test / lint-frontend (pull_request) Successful in 28s
Test / frontend-build (pull_request) Successful in 32s
Verify / verify-generated-database-code (pull_request) Successful in 1m7s
Verify / verify-generated-agent-code (pull_request) Successful in 1m10s
Verify / verify-openapi-spec (pull_request) Successful in 1m9s
Verify / verify-frontend-api-client (pull_request) Successful in 7s
Test / test-crates (pull_request) Successful in 54s
Test / lint-crates (pull_request) Successful in 1m10s
2025-12-28 19:07:17 +08:00
GW_MC
96e7f36731 feat: integrate AgentService into app service and enhance configuration handling
All checks were successful
Test / test-frontend (pull_request) Successful in 25s
Test / lint-frontend (pull_request) Successful in 28s
Test / frontend-build (pull_request) Successful in 32s
Verify / verify-generated-database-code (pull_request) Successful in 1m7s
Verify / verify-generated-agent-code (pull_request) Successful in 1m11s
Verify / verify-openapi-spec (pull_request) Successful in 1m13s
Verify / verify-frontend-api-client (pull_request) Successful in 8s
Test / test-crates (pull_request) Successful in 55s
Test / lint-crates (pull_request) Successful in 1m9s
2025-12-28 18:35:53 +08:00
GW_MC
410328a2af refactor app service 2025-12-28 18:28:28 +08:00
GW_MC
9f122566d0 feat: add agent settings configuration and update agent client service 2025-12-28 18:08:55 +08:00
GW_MC
c65dc3af47 feat: Basic agent-client service
Some checks failed
Test / test-frontend (pull_request) Successful in 27s
Test / frontend-build (pull_request) Successful in 33s
Verify / verify-openapi-spec (pull_request) Successful in 7s
Verify / verify-generated-agent-code (pull_request) Successful in 1m7s
Verify / verify-generated-database-code (pull_request) Successful in 2m14s
Verify / verify-frontend-api-client (pull_request) Successful in 6s
Test / lint-crates (pull_request) Failing after 1m28s
Test / test-crates (pull_request) Successful in 2m40s
Test / lint-frontend (pull_request) Successful in 29s
2025-12-28 17:53:27 +08:00
GW_MC
6f395ed1ae rename workflows jobs 2025-12-28 16:42:12 +08:00
GW_MC
e6e85564e7 fix: incorrect pnpm cache
All checks were successful
Test / test-frontend (pull_request) Successful in 1m17s
Test / lint-frontend (pull_request) Successful in 1m19s
Test / frontend-build (pull_request) Successful in 1m55s
Verify / verify-openapi-spec (pull_request) Successful in 31s
Verify / verify-generated-agent-code (pull_request) Successful in 2m54s
Verify / verify-generated-code (pull_request) Successful in 3m57s
Verify / verify-frontend-api-client (pull_request) Successful in 8s
Test / lint (pull_request) Successful in 1m25s
Test / test (pull_request) Successful in 3m26s
2025-12-28 16:29:31 +08:00
GW_MC
c7a090f78a fix: frontend deps not installed 2025-12-28 16:23:10 +08:00
GW_MC
8cc2775fe4 feat: auto format generated code, and ignore clippy lint in agent-client 2025-12-28 16:22:41 +08:00
100 changed files with 18361 additions and 7255 deletions

View 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
View 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."

View File

@@ -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,7 +80,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: |
@@ -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: |

View File

@@ -11,11 +11,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.
verify-generated-code:
verify-generated-database-code:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
@@ -213,7 +210,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
if: steps.check_swagger_changes.outputs.changed == 'true'

1
.gitignore vendored
View File

@@ -29,3 +29,4 @@ target
generated-config.yaml
node_modules/
.pnpm-store/

261
Cargo.lock generated
View File

@@ -7,7 +7,7 @@ name = "agent_client"
version = "0.1.0"
dependencies = [
"async-trait",
"mockall",
"mockall 0.13.1",
"reqwest",
"serde",
"serde_json",
@@ -292,6 +292,35 @@ dependencies = [
"syn 2.0.111",
]
[[package]]
name = "axum-test"
version = "18.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3290e73c56c5cc4701cdd7d46b9ced1b4bd61c7e9f9c769a9e9e87ff617d75d2"
dependencies = [
"anyhow",
"axum",
"bytes",
"bytesize",
"cookie",
"expect-json",
"http",
"http-body-util",
"hyper",
"hyper-util",
"mime",
"pretty_assertions",
"reserve-port",
"rust-multipart-rfc7578_2",
"serde",
"serde_json",
"serde_urlencoded",
"smallvec",
"tokio",
"tower",
"url",
]
[[package]]
name = "base16ct"
version = "0.2.0"
@@ -509,6 +538,12 @@ version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
[[package]]
name = "bytesize"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3"
[[package]]
name = "cc"
version = "1.2.45"
@@ -542,7 +577,7 @@ dependencies = [
"num-traits",
"serde",
"wasm-bindgen",
"windows-link",
"windows-link 0.2.1",
]
[[package]]
@@ -994,6 +1029,12 @@ dependencies = [
"unicode-xid",
]
[[package]]
name = "diff"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
[[package]]
name = "digest"
version = "0.10.7"
@@ -1123,6 +1164,15 @@ dependencies = [
"zeroize",
]
[[package]]
name = "email_address"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
dependencies = [
"serde",
]
[[package]]
name = "encoding_rs"
version = "0.8.35"
@@ -1191,6 +1241,35 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "expect-json"
version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "422e7906e79941e5ac58c64dfd2da03e6ae3de62227f87606fbbe125d91080f9"
dependencies = [
"chrono",
"email_address",
"expect-json-macros",
"num",
"regex",
"serde",
"serde_json",
"thiserror",
"typetag",
"uuid",
]
[[package]]
name = "expect-json-macros"
version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6b515b7f10f1e61bfd938522e9884509b82060af2016153f5b3d6f44d6da89c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
]
[[package]]
name = "fastrand"
version = "2.3.0"
@@ -1709,9 +1788,11 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -1923,6 +2004,15 @@ dependencies = [
"syn 2.0.111",
]
[[package]]
name = "inventory"
version = "0.3.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e"
dependencies = [
"rustversion",
]
[[package]]
name = "ipnet"
version = "2.11.0"
@@ -2157,7 +2247,21 @@ dependencies = [
"cfg-if",
"downcast",
"fragile",
"mockall_derive",
"mockall_derive 0.13.1",
"predicates",
"predicates-tree",
]
[[package]]
name = "mockall"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f58d964098a5f9c6b63d0798e5372fd04708193510a7af313c22e9f29b7b620b"
dependencies = [
"cfg-if",
"downcast",
"fragile",
"mockall_derive 0.14.0",
"predicates",
"predicates-tree",
]
@@ -2174,6 +2278,18 @@ dependencies = [
"syn 2.0.111",
]
[[package]]
name = "mockall_derive"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca41ce716dda6a9be188b385aa78ee5260fc25cd3802cb2a8afdc6afbe6b6dbf"
dependencies = [
"cfg-if",
"proc-macro2",
"quote",
"syn 2.0.111",
]
[[package]]
name = "native-tls"
version = "0.2.14"
@@ -2468,7 +2584,7 @@ dependencies = [
"libc",
"redox_syscall",
"smallvec",
"windows-link",
"windows-link 0.2.1",
]
[[package]]
@@ -2739,6 +2855,16 @@ dependencies = [
"termtree",
]
[[package]]
name = "pretty_assertions"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d"
dependencies = [
"diff",
"yansi",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
@@ -3073,8 +3199,10 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"http-body-util",
@@ -3084,6 +3212,7 @@ dependencies = [
"hyper-util",
"js-sys",
"log",
"mime",
"mime_guess",
"native-tls",
"percent-encoding",
@@ -3110,6 +3239,15 @@ dependencies = [
"webpki-roots 1.0.4",
]
[[package]]
name = "reserve-port"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21918d6644020c6f6ef1993242989bf6d4952d2e025617744f184c02df51c356"
dependencies = [
"thiserror",
]
[[package]]
name = "rfc6979"
version = "0.4.0"
@@ -3207,6 +3345,21 @@ dependencies = [
"ordered-multimap",
]
[[package]]
name = "rust-multipart-rfc7578_2"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c839d037155ebc06a571e305af66ff9fd9063a6e662447051737e1ac75beea41"
dependencies = [
"bytes",
"futures-core",
"futures-util",
"http",
"mime",
"rand 0.9.2",
"thiserror",
]
[[package]]
name = "rust_decimal"
version = "1.39.0"
@@ -4193,6 +4346,27 @@ dependencies = [
"syn 2.0.111",
]
[[package]]
name = "system-configuration"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
dependencies = [
"bitflags",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "tap"
version = "1.0.1"
@@ -4658,6 +4832,30 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "typetag"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be2212c8a9b9bcfca32024de14998494cf9a5dfa59ea1b829de98bac374b86bf"
dependencies = [
"erased-serde",
"inventory",
"once_cell",
"serde",
"typetag-impl",
]
[[package]]
name = "typetag-impl"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27a7a9b72ba121f6f1f6c3632b85604cac41aedb5ddc70accbebb6cac83de846"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
]
[[package]]
name = "ucd-trie"
version = "0.1.7"
@@ -5008,9 +5206,9 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]]
@@ -5035,19 +5233,54 @@ dependencies = [
"syn 2.0.111",
]
[[package]]
name = "windows-link"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-registry"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
dependencies = [
"windows-link 0.1.3",
"windows-result 0.3.4",
"windows-strings 0.4.2",
]
[[package]]
name = "windows-result"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
"windows-link 0.2.1",
]
[[package]]
name = "windows-strings"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
dependencies = [
"windows-link 0.1.3",
]
[[package]]
@@ -5056,7 +5289,7 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
"windows-link 0.2.1",
]
[[package]]
@@ -5101,7 +5334,7 @@ version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
"windows-link 0.2.1",
]
[[package]]
@@ -5141,7 +5374,7 @@ version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link",
"windows-link 0.2.1",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
@@ -5367,10 +5600,12 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
name = "yet-another-nginx-proxy-manager"
version = "0.1.0"
dependencies = [
"agent_client",
"argon2",
"async-trait",
"axum",
"axum-extra",
"axum-test",
"chrono",
"clap",
"config",
@@ -5379,10 +5614,14 @@ dependencies = [
"jsonwebtoken",
"migration",
"mime_guess",
"mockall 0.14.0",
"once_cell",
"reqwest",
"sea-orm",
"serde",
"serde_json",
"serde_urlencoded",
"tempfile",
"tokio",
"tower",
"tower-http",

View File

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

View File

@@ -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(), || {

View File

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

View File

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

View File

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

View File

@@ -14,3 +14,5 @@ pub(crate) const DATABASE_MIGRATE_ON_STARTUP_KEY: &str = "DATABASE.MIGRATION.MIG
pub(crate) const AUTH_JWT_SECRET_KEY: &str = "AUTH.JWT_SECRET";
pub(crate) const AUTH_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";

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
#[macro_export]
macro_rules! set_if_some {
($field:expr) => {
match $field {
Some(value) => sea_orm::ActiveValue::Set(value),
None => sea_orm::ActiveValue::NotSet,
}
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,76 @@
use axum::{
extract::FromRequestParts,
http::{StatusCode, request::Parts},
};
use serde::{Deserialize, Serialize};
use crate::helpers::database::PaginationFilter;
#[derive(Serialize, Deserialize, utoipa::ToSchema, Clone)]
/// Pagination parameters for API requests
pub struct Pagination {
/// Page number (1-based)
pub page: u32,
/// Items per page
pub per_page: u32,
}
impl Default for Pagination {
fn default() -> Self {
Self {
page: 1,
per_page: 20,
}
}
}
impl From<Pagination> for PaginationFilter {
fn from(pagination: Pagination) -> Self {
Self {
page: pagination.page as u64,
per_page: pagination.per_page as u64,
}
}
}
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
/// Pagination information included in API responses
pub struct PaginationInfo {
/// Total number of items
pub total_items: u64,
/// Total number of pages
pub total_pages: u32,
/// Current page number
pub current_page: u32,
/// Items per page
pub per_page: u32,
}
/// Extractor for pagination parameters from query string
pub struct ExtractPagination(pub Pagination);
impl<S> FromRequestParts<S> for ExtractPagination
where
S: Send + Sync,
{
type Rejection = (StatusCode, &'static str);
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let query = parts.uri.query().unwrap_or("");
let pagination: Pagination = serde_urlencoded::from_str(query).unwrap_or_default();
// validation
if pagination.page == 0 {
return Err((StatusCode::BAD_REQUEST, "page must be greater than 0"));
}
if pagination.per_page < 1 || pagination.per_page > 100 {
return Err((
StatusCode::BAD_REQUEST,
"per_page must be between 1 and 100",
));
}
Ok(ExtractPagination(pagination))
}
}

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) => {

View File

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

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

View File

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

View File

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

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

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

View File

@@ -0,0 +1,4 @@
pub mod location;
pub mod proxy_host;
pub mod upstream;
pub mod upstream_target;

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

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

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

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

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

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

View File

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

View File

@@ -0,0 +1,31 @@
pub trait Indentable<T> {
fn indent(&self, spaces: T) -> String;
}
impl Indentable<usize> for &str {
fn indent(&self, spaces: usize) -> String {
let indent_str = " ".repeat(spaces);
self.lines()
.map(|line| format!("{}{}", indent_str, line))
.collect::<Vec<String>>()
.join("\n")
}
}
impl Indentable<Option<usize>> for String {
fn indent(&self, spaces: Option<usize>) -> String {
self.as_str().indent(spaces.unwrap_or(0))
}
}
impl Indentable<usize> for String {
fn indent(&self, spaces: usize) -> String {
self.as_str().indent(spaces)
}
}
impl Indentable<Option<usize>> for &str {
fn indent(&self, spaces: Option<usize>) -> String {
self.indent(spaces.unwrap_or(0))
}
}

View File

@@ -0,0 +1,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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -67,6 +67,12 @@ generate-agent-client:
--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-agent-client

6279
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -19,3 +19,9 @@ default = ["native-tls"]
native-tls = ["reqwest/native-tls"]
rustls-tls = ["reqwest/rustls-tls"]
mockall = ["dep:mockall"]
[lints.clippy]
all = "allow"
[lints.rust]
unsafe_code = "forbid"

View File

@@ -8,8 +8,6 @@
* Generated by: https://openapi-generator.tech
*/
#[derive(Debug, Clone)]
pub struct Configuration {
pub base_path: String,
@@ -29,7 +27,6 @@ pub struct ApiKey {
pub key: String,
}
impl Configuration {
pub fn new() -> Configuration {
Configuration::default()

View File

@@ -16,7 +16,7 @@ pub enum Error<T> {
ResponseError(ResponseContent<T>),
}
impl <T> fmt::Display for Error<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()),
@@ -28,7 +28,7 @@ impl <T> fmt::Display for Error<T> {
}
}
impl <T: fmt::Debug> error::Error for Error<T> {
impl<T: fmt::Debug> error::Error for Error<T> {
fn source(&self) -> Option<&(dyn error::Error + 'static)> {
Some(match self {
Error::Reqwest(e) => e,
@@ -39,19 +39,19 @@ impl <T: fmt::Debug> error::Error for Error<T> {
}
}
impl <T> From<reqwest::Error> for Error<T> {
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> {
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> {
impl<T> From<std::io::Error> for Error<T> {
fn from(e: std::io::Error) -> Self {
Error::Io(e)
}
@@ -78,8 +78,10 @@ pub fn parse_deep_object(prefix: &str, value: &serde_json::Value) -> Vec<(String
value,
));
}
},
serde_json::Value::String(s) => params.push((format!("{}[{}]", prefix, key), s.clone())),
}
serde_json::Value::String(s) => {
params.push((format!("{}[{}]", prefix, key), s.clone()))
}
_ => params.push((format!("{}[{}]", prefix, key), value.to_string())),
}
}
@@ -96,7 +98,7 @@ pub fn parse_deep_object(prefix: &str, value: &serde_json::Value) -> Vec<(String
enum ContentType {
Json,
Text,
Unsupported(String)
Unsupported(String),
}
impl From<&str> for ContentType {
@@ -128,7 +130,9 @@ pub struct ApiClient {
impl ApiClient {
pub fn new(configuration: Arc<configuration::Configuration>) -> Self {
Self {
nginx_agent_api: Box::new(nginx_agent_api::NginxAgentApiClient::new(configuration.clone())),
nginx_agent_api: Box::new(nginx_agent_api::NginxAgentApiClient::new(
configuration.clone(),
)),
}
}
}
@@ -159,4 +163,3 @@ impl Api for MockApiClient {
&self.nginx_agent_api_mock
}
}

View File

@@ -8,44 +8,51 @@
* Generated by: https://openapi-generator.tech
*/
use super::{configuration, Error};
use crate::apis::ContentType;
use crate::{apis::ResponseContent, models};
use async_trait::async_trait;
#[cfg(feature = "mockall")]
use mockall::automock;
use reqwest;
use serde::{de::Error as _, Deserialize, Serialize};
use std::sync::Arc;
use serde::{Deserialize, Serialize, de::Error as _};
use crate::{apis::ResponseContent, models};
use super::{Error, configuration};
use crate::apis::ContentType;
#[cfg_attr(feature = "mockall", automock)]
#[async_trait]
pub trait NginxAgentApi: Send + Sync {
/// GET /status
///
///
async fn status(&self, ) -> Result<ResponseContent<StatusSuccess>, Error<StatusError>>;
async fn status(&self) -> Result<ResponseContent<StatusSuccess>, Error<StatusError>>;
/// POST /validate
///
///
async fn validate(&self, params: ValidateParams ) -> Result<ResponseContent<ValidateSuccess>, Error<ValidateError>>;
async fn validate(
&self,
params: ValidateParams,
) -> Result<ResponseContent<ValidateSuccess>, Error<ValidateError>>;
/// POST /validate_and_reload
///
///
async fn validate_and_reload(&self, params: ValidateAndReloadParams ) -> Result<ResponseContent<ValidateAndReloadSuccess>, Error<ValidateAndReloadError>>;
async fn validate_and_reload(
&self,
params: ValidateAndReloadParams,
) -> Result<ResponseContent<ValidateAndReloadSuccess>, Error<ValidateAndReloadError>>;
/// POST /write_config
///
///
async fn write_config(&self, params: WriteConfigParams ) -> Result<ResponseContent<WriteConfigSuccess>, Error<WriteConfigError>>;
async fn write_config(
&self,
params: WriteConfigParams,
) -> Result<ResponseContent<WriteConfigSuccess>, Error<WriteConfigError>>;
}
pub struct NginxAgentApiClient {
configuration: Arc<configuration::Configuration>
configuration: Arc<configuration::Configuration>,
}
impl NginxAgentApiClient {
@@ -54,40 +61,38 @@ impl NginxAgentApiClient {
}
}
/// struct for passing parameters to the method [`NginxAgentApi::validate`]
#[derive(Clone, Debug)]
pub struct ValidateParams {
pub validate_body: models::ValidateBody
pub validate_body: models::ValidateBody,
}
/// struct for passing parameters to the method [`NginxAgentApi::validate_and_reload`]
#[derive(Clone, Debug)]
pub struct ValidateAndReloadParams {
pub validate_and_reload_body: models::ValidateAndReloadBody
pub validate_and_reload_body: models::ValidateAndReloadBody,
}
/// struct for passing parameters to the method [`NginxAgentApi::write_config`]
#[derive(Clone, Debug)]
pub struct WriteConfigParams {
pub write_config_body: models::WriteConfigBody
pub write_config_body: models::WriteConfigBody,
}
#[async_trait]
impl NginxAgentApi for NginxAgentApiClient {
async fn status(&self, ) -> Result<ResponseContent<StatusSuccess>, Error<StatusError>> {
async fn status(&self) -> Result<ResponseContent<StatusSuccess>, Error<StatusError>> {
let local_var_configuration = &self.configuration;
let local_var_client = &local_var_configuration.client;
let local_var_uri_str = format!("{}/status", local_var_configuration.base_path);
let mut local_var_req_builder = local_var_client.request(reqwest::Method::GET, local_var_uri_str.as_str());
let mut local_var_req_builder =
local_var_client.request(reqwest::Method::GET, local_var_uri_str.as_str());
if let Some(ref local_var_user_agent) = local_var_configuration.user_agent {
local_var_req_builder = local_var_req_builder.header(reqwest::header::USER_AGENT, local_var_user_agent.clone());
local_var_req_builder = local_var_req_builder
.header(reqwest::header::USER_AGENT, local_var_user_agent.clone());
}
let local_var_req = local_var_req_builder.build()?;
@@ -97,32 +102,43 @@ impl NginxAgentApi for NginxAgentApiClient {
let local_var_content = local_var_resp.text().await?;
if !local_var_status.is_client_error() && !local_var_status.is_server_error() {
let local_var_entity: Option<StatusSuccess> = serde_json::from_str(&local_var_content).ok();
let local_var_result = ResponseContent { status: local_var_status, content: local_var_content, entity: local_var_entity };
let local_var_entity: Option<StatusSuccess> =
serde_json::from_str(&local_var_content).ok();
let local_var_result = ResponseContent {
status: local_var_status,
content: local_var_content,
entity: local_var_entity,
};
Ok(local_var_result)
} else {
let local_var_entity: Option<StatusError> = serde_json::from_str(&local_var_content).ok();
let local_var_error = ResponseContent { status: local_var_status, content: local_var_content, entity: local_var_entity };
let local_var_entity: Option<StatusError> =
serde_json::from_str(&local_var_content).ok();
let local_var_error = ResponseContent {
status: local_var_status,
content: local_var_content,
entity: local_var_entity,
};
Err(Error::ResponseError(local_var_error))
}
}
async fn validate(&self, params: ValidateParams ) -> Result<ResponseContent<ValidateSuccess>, Error<ValidateError>> {
let ValidateParams {
validate_body,
} = params;
async fn validate(
&self,
params: ValidateParams,
) -> Result<ResponseContent<ValidateSuccess>, Error<ValidateError>> {
let ValidateParams { validate_body } = params;
let local_var_configuration = &self.configuration;
let local_var_client = &local_var_configuration.client;
let local_var_uri_str = format!("{}/validate", local_var_configuration.base_path);
let mut local_var_req_builder = local_var_client.request(reqwest::Method::POST, local_var_uri_str.as_str());
let mut local_var_req_builder =
local_var_client.request(reqwest::Method::POST, local_var_uri_str.as_str());
if let Some(ref local_var_user_agent) = local_var_configuration.user_agent {
local_var_req_builder = local_var_req_builder.header(reqwest::header::USER_AGENT, local_var_user_agent.clone());
local_var_req_builder = local_var_req_builder
.header(reqwest::header::USER_AGENT, local_var_user_agent.clone());
}
local_var_req_builder = local_var_req_builder.json(&validate_body);
@@ -133,32 +149,46 @@ impl NginxAgentApi for NginxAgentApiClient {
let local_var_content = local_var_resp.text().await?;
if !local_var_status.is_client_error() && !local_var_status.is_server_error() {
let local_var_entity: Option<ValidateSuccess> = serde_json::from_str(&local_var_content).ok();
let local_var_result = ResponseContent { status: local_var_status, content: local_var_content, entity: local_var_entity };
let local_var_entity: Option<ValidateSuccess> =
serde_json::from_str(&local_var_content).ok();
let local_var_result = ResponseContent {
status: local_var_status,
content: local_var_content,
entity: local_var_entity,
};
Ok(local_var_result)
} else {
let local_var_entity: Option<ValidateError> = serde_json::from_str(&local_var_content).ok();
let local_var_error = ResponseContent { status: local_var_status, content: local_var_content, entity: local_var_entity };
let local_var_entity: Option<ValidateError> =
serde_json::from_str(&local_var_content).ok();
let local_var_error = ResponseContent {
status: local_var_status,
content: local_var_content,
entity: local_var_entity,
};
Err(Error::ResponseError(local_var_error))
}
}
async fn validate_and_reload(&self, params: ValidateAndReloadParams ) -> Result<ResponseContent<ValidateAndReloadSuccess>, Error<ValidateAndReloadError>> {
async fn validate_and_reload(
&self,
params: ValidateAndReloadParams,
) -> Result<ResponseContent<ValidateAndReloadSuccess>, Error<ValidateAndReloadError>> {
let ValidateAndReloadParams {
validate_and_reload_body,
} = params;
let local_var_configuration = &self.configuration;
let local_var_client = &local_var_configuration.client;
let local_var_uri_str = format!("{}/validate_and_reload", local_var_configuration.base_path);
let mut local_var_req_builder = local_var_client.request(reqwest::Method::POST, local_var_uri_str.as_str());
let local_var_uri_str =
format!("{}/validate_and_reload", local_var_configuration.base_path);
let mut local_var_req_builder =
local_var_client.request(reqwest::Method::POST, local_var_uri_str.as_str());
if let Some(ref local_var_user_agent) = local_var_configuration.user_agent {
local_var_req_builder = local_var_req_builder.header(reqwest::header::USER_AGENT, local_var_user_agent.clone());
local_var_req_builder = local_var_req_builder
.header(reqwest::header::USER_AGENT, local_var_user_agent.clone());
}
local_var_req_builder = local_var_req_builder.json(&validate_and_reload_body);
@@ -169,32 +199,43 @@ impl NginxAgentApi for NginxAgentApiClient {
let local_var_content = local_var_resp.text().await?;
if !local_var_status.is_client_error() && !local_var_status.is_server_error() {
let local_var_entity: Option<ValidateAndReloadSuccess> = serde_json::from_str(&local_var_content).ok();
let local_var_result = ResponseContent { status: local_var_status, content: local_var_content, entity: local_var_entity };
let local_var_entity: Option<ValidateAndReloadSuccess> =
serde_json::from_str(&local_var_content).ok();
let local_var_result = ResponseContent {
status: local_var_status,
content: local_var_content,
entity: local_var_entity,
};
Ok(local_var_result)
} else {
let local_var_entity: Option<ValidateAndReloadError> = serde_json::from_str(&local_var_content).ok();
let local_var_error = ResponseContent { status: local_var_status, content: local_var_content, entity: local_var_entity };
let local_var_entity: Option<ValidateAndReloadError> =
serde_json::from_str(&local_var_content).ok();
let local_var_error = ResponseContent {
status: local_var_status,
content: local_var_content,
entity: local_var_entity,
};
Err(Error::ResponseError(local_var_error))
}
}
async fn write_config(&self, params: WriteConfigParams ) -> Result<ResponseContent<WriteConfigSuccess>, Error<WriteConfigError>> {
let WriteConfigParams {
write_config_body,
} = params;
async fn write_config(
&self,
params: WriteConfigParams,
) -> Result<ResponseContent<WriteConfigSuccess>, Error<WriteConfigError>> {
let WriteConfigParams { write_config_body } = params;
let local_var_configuration = &self.configuration;
let local_var_client = &local_var_configuration.client;
let local_var_uri_str = format!("{}/write_config", local_var_configuration.base_path);
let mut local_var_req_builder = local_var_client.request(reqwest::Method::POST, local_var_uri_str.as_str());
let mut local_var_req_builder =
local_var_client.request(reqwest::Method::POST, local_var_uri_str.as_str());
if let Some(ref local_var_user_agent) = local_var_configuration.user_agent {
local_var_req_builder = local_var_req_builder.header(reqwest::header::USER_AGENT, local_var_user_agent.clone());
local_var_req_builder = local_var_req_builder
.header(reqwest::header::USER_AGENT, local_var_user_agent.clone());
}
local_var_req_builder = local_var_req_builder.json(&write_config_body);
@@ -205,16 +246,25 @@ impl NginxAgentApi for NginxAgentApiClient {
let local_var_content = local_var_resp.text().await?;
if !local_var_status.is_client_error() && !local_var_status.is_server_error() {
let local_var_entity: Option<WriteConfigSuccess> = serde_json::from_str(&local_var_content).ok();
let local_var_result = ResponseContent { status: local_var_status, content: local_var_content, entity: local_var_entity };
let local_var_entity: Option<WriteConfigSuccess> =
serde_json::from_str(&local_var_content).ok();
let local_var_result = ResponseContent {
status: local_var_status,
content: local_var_content,
entity: local_var_entity,
};
Ok(local_var_result)
} else {
let local_var_entity: Option<WriteConfigError> = serde_json::from_str(&local_var_content).ok();
let local_var_error = ResponseContent { status: local_var_status, content: local_var_content, entity: local_var_entity };
let local_var_entity: Option<WriteConfigError> =
serde_json::from_str(&local_var_content).ok();
let local_var_error = ResponseContent {
status: local_var_status,
content: local_var_content,
entity: local_var_entity,
};
Err(Error::ResponseError(local_var_error))
}
}
}
/// struct for typed successes of method [`NginxAgentApi::status`]
@@ -277,4 +327,3 @@ pub enum WriteConfigError {
Status500(serde_json::Value),
UnknownValue(serde_json::Value),
}

View File

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

View File

@@ -19,9 +19,6 @@ pub struct StatusResp {
impl StatusResp {
pub fn new(ok: bool) -> StatusResp {
StatusResp {
ok,
}
StatusResp { ok }
}
}

View File

@@ -27,4 +27,3 @@ impl ValidateAndReloadBody {
}
}
}

View File

@@ -21,10 +21,6 @@ pub struct ValidateAndReloadResp {
impl ValidateAndReloadResp {
pub fn new(rc: i32, ro: String) -> ValidateAndReloadResp {
ValidateAndReloadResp {
rc,
ro,
}
ValidateAndReloadResp { rc, ro }
}
}

View File

@@ -27,4 +27,3 @@ impl ValidateBody {
}
}
}

View File

@@ -30,4 +30,3 @@ impl WriteConfigBody {
}
}
}

View File

@@ -0,0 +1,46 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0.0-rc.18
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "location")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
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_protocol: Option<String>,
pub proxy_pass_host: Option<String>,
pub proxy_pass_port: Option<i64>,
pub preserve_host_header: Option<bool>,
#[sea_orm(column_type = "JsonBinary", nullable)]
pub allowed_methods: Option<Json>,
#[sea_orm(column_type = "Text", nullable)]
pub custom_config: Option<String>,
pub enabled: bool,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(
belongs_to,
from = "host_id",
to = "id",
on_update = "Cascade",
on_delete = "Cascade"
)]
pub proxy_host: HasOne<super::proxy_host::Entity>,
#[sea_orm(
belongs_to,
from = "upstream_id",
to = "id",
on_update = "Cascade",
on_delete = "SetNull"
)]
pub upstream: HasOne<super::upstream::Entity>,
}
impl ActiveModelBehavior for ActiveModel {}

View File

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

View File

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

View File

@@ -0,0 +1,48 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0.0-rc.18
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "proxy_host")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
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 enabled: bool,
#[sea_orm(column_type = "JsonBinary", nullable)]
pub meta: Option<Json>,
pub default_upstream_id: Option<Uuid>,
pub created_by: Option<Uuid>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(has_many)]
pub locations: HasMany<super::location::Entity>,
#[sea_orm(
belongs_to,
from = "default_upstream_id",
to = "id",
on_update = "Cascade",
on_delete = "SetNull"
)]
pub upstream: HasOne<super::upstream::Entity>,
#[sea_orm(
belongs_to,
from = "created_by",
to = "id",
on_update = "Cascade",
on_delete = "SetNull"
)]
pub user: HasOne<super::user::Entity>,
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,27 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0.0-rc.18
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "upstream")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub name: String,
pub protocol: String,
pub algorithm: String,
pub sticky_session: bool,
pub created_by: Option<Uuid>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(has_many)]
pub locations: HasMany<super::location::Entity>,
#[sea_orm(has_many)]
pub proxy_hosts: HasMany<super::proxy_host::Entity>,
#[sea_orm(has_many)]
pub upstream_targets: HasMany<super::upstream_target::Entity>,
}
impl ActiveModelBehavior for ActiveModel {}

View File

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

View File

@@ -18,6 +18,8 @@ pub struct Model {
pub last_login_at: Option<DateTimeUtc>,
pub deleted_at: Option<DateTimeUtc>,
#[sea_orm(has_many)]
pub proxy_hosts: HasMany<super::proxy_host::Entity>,
#[sea_orm(has_many)]
pub user_identities: HasMany<super::user_identity::Entity>,
}

View File

@@ -0,0 +1,208 @@
# Migration Tables → nginx mapping
This document explains the purpose of each migration table added under `public/migration/src/migrations` and how the rows map to generated nginx configuration (HTTP `http {}` and `stream {}` contexts).
Summary of tables covered:
- `upstream`
- `upstream_target`
- `proxy_host`
- `location`
- `stream_service`
- `access_list`
- `access_list_entry`
- `audit_log`
---
## `upstream`
Purpose: A named backend pool of servers. Shared by HTTP and stream services.
Key fields:
- `id`: UUID primary key
- `name`: identifier used when generating nginx `upstream <name> {}`
- `protocol`: `http` | `tcp` | `udp` — determines how nginx will use the pool
- `algorithm`: load balancing strategy (`round_robin`, `least_conn`, `ip_hash`)
- `sticky_session`: whether to enable sticky behavior when supported
- `health_check`: optional JSON describing health probes
nginx mapping (HTTP):
```nginx
upstream <name> {
server 10.0.0.5:8080 weight=2;
server 10.0.0.6:8080 backup;
# optional LB settings generated from `algorithm` and `sticky_session`
}
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://<name>;
}
}
```
nginx mapping (stream):
```nginx
stream {
upstream <name> {
server 10.0.0.5:3306;
server 10.0.0.6:3306 backup;
}
server {
listen 3306;
proxy_pass <name>;
}
}
```
Notes: `upstream.protocol` selects which block and directive forms to generate;
---
## `upstream_target`
Purpose: One row per backend server in an `upstream` pool.
Key fields:
- `upstream_id`: FK to `upstream`
- `target_host`, `target_port`
- `weight`, `is_backup`, `enabled`
nginx mapping: each row becomes a `server` line in the generated `upstream` block (weights and backup flags applied). Disabled targets are omitted.
Example generated line:
```nginx
server 10.0.0.5:8080 weight=3;
server 10.0.0.6:8080 backup;
```
---
## `proxy_host`
Purpose: Represents an HTTP(S) host (a top-level `server` block in nginx `http` context).
Key fields:
- `domain`: `server_name` value (may be a wildcard)
- `listen_port`: port to listen on (80/443)
- `scheme`: http|https (informs UI; TLS handled elsewhere)
- `forward_host/forward_port` or `default_upstream_id`: host-level forwarding fallback
- `preserve_host_header`: whether to forward original `Host` header
- `enable_websocket`: toggles websocket header handling
- `meta`: JSON for optional host-level settings (timeouts, client_max_body_size, custom snippets)
nginx mapping (host-level default):
```nginx
server {
listen <listen_port>;
server_name <domain>;
# host-level fallback if no matching location
location / {
proxy_pass http://<default_upstream_name>;
}
}
```
If `forward_host`/`forward_port` is set instead of `default_upstream_id`, generate `proxy_pass http://forward_host:forward_port;`.
`meta` entries are injected into the `server` block (careful: snippets can break reloads).
---
## `location`
Purpose: Path-level routing (`location` blocks inside a `server`). More specific than `proxy_host` default.
Key fields:
- `host_id`: FK to `proxy_host`
- `path`: `location` match (e.g., `/api`, `~^/assets/`)
- `match_type`: `prefix` | `exact` | `regex`
- `upstream_id` or `proxy_pass_host`/`proxy_pass_port`
- `allowed_methods`: optional method whitelist
- `custom_config`: raw nginx snippet inserted inside the `location`
nginx mapping:
```nginx
location /api {
proxy_pass http://api_upstream;
# optional custom_config injected here
}
```
Ordering and match type produce correct nginx `location` selection semantics; `order` field can break ties for equal specificity.
---
## `stream_service`
Purpose: A TCP/UDP service in nginx `stream` context — corresponds to a `server` block inside `stream {}`.
Key fields:
- `listen_host`, `listen_port`
- `protocol`: `tcp` | `udp`
- `mode`: `direct` | `upstream` (direct forwards to `forward_host:forward_port`, `upstream` uses `upstream_id` pool)
- `preserved_client_ip`: whether to enable proxy_protocol or other client-ip forwarding
- `meta`: JSON for advanced stream options (ssl_preread, proxy_timeout, buffer sizes)
nginx mapping (stream):
```nginx
stream {
upstream <name> {
server 10.0.0.5:3306;
}
server {
listen 3306;
proxy_pass <name>; # or proxy_pass 10.0.0.10:3306 for direct
}
}
```
Notes: Stream services bypass HTTP processing. Use `meta` for `proxy_protocol` and `ssl_preread` toggles.
---
## `access_list` and `access_list_entry`
Purpose: Nameable allow/deny lists for IP/CIDR or other entry types that can be applied to hosts/locations/stream services.
Key fields (access_list): `id`, `name`, `description`.
Key fields (entry): `access_list_id`, `entry_type` (e.g., `allow`, `deny`, `note`), `value` (IP or CIDR), `comment`.
nginx mapping (HTTP example):
```nginx
location /admin {
allow 10.0.0.0/24;
deny all;
proxy_pass http://admin_upstream;
}
```
nginx mapping (stream example):
```nginx
server {
listen 3306;
allow 10.0.0.0/24;
deny all;
proxy_pass backend_pool;
}
```

View File

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

View File

@@ -1,3 +1,7 @@
pub mod m20251011_000001_create_config_table;
pub mod m20251011_000002_create_user_table;
pub mod m20251011_000003_create_user_identity_table;
pub mod m20251223_000004_create_upstream_table;
pub mod m20251223_000005_create_upstream_target_table;
pub mod m20260102_000006_create_proxy_table;
pub mod m20260102_000007_create_location_table;

View File

@@ -0,0 +1,66 @@
use sea_orm_migration::{prelude::*, schema::*};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[forbid(dead_code)]
#[derive(DeriveIden)]
pub enum Upstream {
Table,
Id,
Name,
Protocol,
Algorithm,
StickySession,
CreatedBy,
CreatedAt,
UpdatedAt,
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(Upstream::Table)
.if_not_exists()
.col(pk_uuid(Upstream::Id))
.col(ColumnDef::new(Upstream::Name).string().not_null())
.col(ColumnDef::new(Upstream::Protocol).string().not_null())
.col(
ColumnDef::new(Upstream::Algorithm)
.string()
.default("round_robin")
.not_null(),
)
.col(
ColumnDef::new(Upstream::StickySession)
.boolean()
.default(false)
.not_null(),
)
.col(ColumnDef::new(Upstream::CreatedBy).uuid().null())
.col(
ColumnDef::new(Upstream::CreatedAt)
.timestamp()
.default(SimpleExpr::Keyword(Keyword::CurrentTimestamp))
.not_null(),
)
.col(
ColumnDef::new(Upstream::UpdatedAt)
.timestamp()
.default(SimpleExpr::Keyword(Keyword::CurrentTimestamp))
.not_null(),
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(Upstream::Table).to_owned())
.await
}
}

View File

@@ -0,0 +1,92 @@
use sea_orm_migration::{prelude::*, schema::*};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[forbid(dead_code)]
#[derive(DeriveIden)]
pub enum UpstreamTarget {
Table,
Id,
UpstreamId,
TargetHost,
TargetPort,
Weight,
IsBackup,
Enabled,
CreatedAt,
UpdatedAt,
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(UpstreamTarget::Table)
.if_not_exists()
.col(pk_uuid(UpstreamTarget::Id))
.col(ColumnDef::new(UpstreamTarget::UpstreamId).uuid().not_null())
.foreign_key(
ForeignKey::create()
.name("fk-upstream-target-upstream-id")
.from(UpstreamTarget::Table, UpstreamTarget::UpstreamId)
.to(
super::m20251223_000004_create_upstream_table::Upstream::Table,
super::m20251223_000004_create_upstream_table::Upstream::Id,
)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.col(
ColumnDef::new(UpstreamTarget::TargetHost)
.string()
.not_null(),
)
.col(
ColumnDef::new(UpstreamTarget::TargetPort)
.integer()
.not_null(),
)
.col(
ColumnDef::new(UpstreamTarget::Weight)
.integer()
.default(1)
.not_null(),
)
.col(
ColumnDef::new(UpstreamTarget::IsBackup)
.boolean()
.default(false)
.not_null(),
)
.col(
ColumnDef::new(UpstreamTarget::Enabled)
.boolean()
.default(true)
.not_null(),
)
.col(
ColumnDef::new(UpstreamTarget::CreatedAt)
.timestamp()
.default(SimpleExpr::Keyword(Keyword::CurrentTimestamp))
.not_null(),
)
.col(
ColumnDef::new(UpstreamTarget::UpdatedAt)
.timestamp()
.default(SimpleExpr::Keyword(Keyword::CurrentTimestamp))
.not_null(),
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(UpstreamTarget::Table).to_owned())
.await
}
}

View File

@@ -0,0 +1,124 @@
use sea_orm_migration::{prelude::*, schema::*};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[forbid(dead_code)]
#[derive(DeriveIden)]
pub enum ProxyHost {
Table,
Id,
Name,
Domain,
Scheme,
ListenPort,
ForwardScheme,
ForwardHost,
ForwardPort,
PreserveHostHeader,
EnableWebsocket,
Enabled,
Meta,
DefaultUpstreamId,
CreatedBy,
CreatedAt,
UpdatedAt,
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(ProxyHost::Table)
.if_not_exists()
.col(pk_uuid(ProxyHost::Id))
.col(ColumnDef::new(ProxyHost::Name).string().null())
.col(ColumnDef::new(ProxyHost::Domain).string().not_null())
.col(
ColumnDef::new(ProxyHost::Scheme)
.string()
.default("http")
.not_null(),
)
.col(
ColumnDef::new(ProxyHost::ListenPort)
.integer()
.default(80)
.not_null(),
)
.col(
ColumnDef::new(ProxyHost::ForwardScheme)
.string()
.default("http")
.not_null(),
)
.col(ColumnDef::new(ProxyHost::ForwardHost).string().null())
.col(ColumnDef::new(ProxyHost::ForwardPort).integer().null())
.col(
ColumnDef::new(ProxyHost::PreserveHostHeader)
.boolean()
.default(false)
.not_null(),
)
.col(
ColumnDef::new(ProxyHost::EnableWebsocket)
.boolean()
.default(false)
.not_null(),
)
.col(
ColumnDef::new(ProxyHost::Enabled)
.boolean()
.default(true)
.not_null(),
)
.col(ColumnDef::new(ProxyHost::Meta).json_binary().null())
.col(ColumnDef::new(ProxyHost::DefaultUpstreamId).uuid().null())
.foreign_key(
ForeignKey::create()
.name("fk-proxy-host-default-upstream-id")
.from(ProxyHost::Table, ProxyHost::DefaultUpstreamId)
.to(
super::m20251223_000004_create_upstream_table::Upstream::Table,
super::m20251223_000004_create_upstream_table::Upstream::Id,
)
.on_delete(ForeignKeyAction::SetNull)
.on_update(ForeignKeyAction::Cascade),
)
.col(ColumnDef::new(ProxyHost::CreatedBy).uuid().null())
.foreign_key(
ForeignKey::create()
.name("fk-proxy-host-created-by")
.from(ProxyHost::Table, ProxyHost::CreatedBy)
.to(
super::m20251011_000002_create_user_table::User::Table,
super::m20251011_000002_create_user_table::User::Id,
)
.on_delete(ForeignKeyAction::SetNull)
.on_update(ForeignKeyAction::Cascade),
)
.col(
ColumnDef::new(ProxyHost::CreatedAt)
.timestamp()
.default(SimpleExpr::Keyword(Keyword::CurrentTimestamp))
.not_null(),
)
.col(
ColumnDef::new(ProxyHost::UpdatedAt)
.timestamp()
.default(SimpleExpr::Keyword(Keyword::CurrentTimestamp))
.not_null(),
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(ProxyHost::Table).to_owned())
.await
}
}

View File

@@ -0,0 +1,115 @@
use sea_orm_migration::{prelude::*, schema::*};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[forbid(dead_code)]
#[derive(DeriveIden)]
pub enum Location {
Table,
Id,
HostId,
Path,
MatchType,
Order,
UpstreamId,
ProxyPassProtocol,
ProxyPassHost,
ProxyPassPort,
PreserveHostHeader,
AllowedMethods,
CustomConfig,
Enabled,
CreatedAt,
UpdatedAt,
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(Location::Table)
.if_not_exists()
.col(pk_uuid(Location::Id))
.col(ColumnDef::new(Location::HostId).uuid().not_null())
.foreign_key(
ForeignKey::create()
.name("fk-location-host-id")
.from(Location::Table, Location::HostId)
.to(
super::m20260102_000006_create_proxy_table::ProxyHost::Table,
super::m20260102_000006_create_proxy_table::ProxyHost::Id,
)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.col(ColumnDef::new(Location::Path).string().not_null())
.col(
ColumnDef::new(Location::MatchType)
.string()
.default("prefix")
.not_null(),
)
.col(
ColumnDef::new(Location::Order)
.integer()
.default(0)
.not_null(),
)
.col(ColumnDef::new(Location::UpstreamId).uuid().null())
.foreign_key(
ForeignKey::create()
.name("fk-location-upstream-id")
.from(Location::Table, Location::UpstreamId)
.to(
super::m20251223_000004_create_upstream_table::Upstream::Table,
super::m20251223_000004_create_upstream_table::Upstream::Id,
)
.on_delete(ForeignKeyAction::SetNull)
.on_update(ForeignKeyAction::Cascade),
)
.col(ColumnDef::new(Location::ProxyPassProtocol).string().null())
.col(ColumnDef::new(Location::ProxyPassHost).string().null())
.col(ColumnDef::new(Location::ProxyPassPort).integer().null())
.col(
ColumnDef::new(Location::PreserveHostHeader)
.boolean()
.null(),
)
.col(
ColumnDef::new(Location::AllowedMethods)
.json_binary()
.null(),
)
.col(ColumnDef::new(Location::CustomConfig).text().null())
.col(
ColumnDef::new(Location::Enabled)
.boolean()
.default(true)
.not_null(),
)
.col(
ColumnDef::new(Location::CreatedAt)
.timestamp()
.default(SimpleExpr::Keyword(Keyword::CurrentTimestamp))
.not_null(),
)
.col(
ColumnDef::new(Location::UpdatedAt)
.timestamp()
.default(SimpleExpr::Keyword(Keyword::CurrentTimestamp))
.not_null(),
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(Location::Table).to_owned())
.await
}
}