70 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
GW_MC
0b64538754 added verify for untracked generated files 2025-12-28 15:56:04 +08:00
GW_MC
8009ca20ff feat: added verify generated agent code in action workflows 2025-12-28 15:55:30 +08:00
GW_MC
7a5b9471e0 feat: added agent rust client generation 2025-12-28 15:16:47 +08:00
GW_MC
bb55e37b49 feat: added openapi generation for agent 2025-12-28 15:15:42 +08:00
123 changed files with 16320 additions and 1506 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: jobs:
# setup is now handled by a composite action used by downstream jobs to keep
# the workflow DRY. The composite action performs checkout, cache restore and
# toolchain setup.
test: test-crates:
needs: frontend-build needs: frontend-build
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -39,7 +36,7 @@ jobs:
- name: Run tests - name: Run tests
run: cargo test --all-features run: cargo test --all-features
lint: lint-crates:
needs: frontend-build needs: frontend-build
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -83,7 +80,7 @@ jobs:
with: with:
node-version: 22 node-version: 22
cache: 'pnpm' cache: 'pnpm'
cache-dependency-path: apps/frontend/pnpm-lock.yaml cache-dependency-path: pnpm-lock.yaml
- name: Install frontend dependencies - name: Install frontend dependencies
run: | run: |
@@ -114,7 +111,7 @@ jobs:
with: with:
node-version: 22 node-version: 22
cache: 'pnpm' cache: 'pnpm'
cache-dependency-path: apps/frontend/pnpm-lock.yaml cache-dependency-path: pnpm-lock.yaml
- name: Install frontend dependencies - name: Install frontend dependencies
run: | run: |
@@ -142,12 +139,12 @@ jobs:
with: with:
node-version: 22 node-version: 22
cache: 'pnpm' cache: 'pnpm'
cache-dependency-path: apps/frontend/pnpm-lock.yaml cache-dependency-path: pnpm-lock.yaml
- name: Install frontend dependencies - name: Install frontend dependencies
run: | run: |
cd apps/frontend cd apps/frontend
pnpm install pnpm install --frozen-lockfile
- name: Build frontend - name: Build frontend
run: | run: |

View File

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

3
.gitignore vendored
View File

@@ -27,3 +27,6 @@ target
.env.generated .env.generated
generated-config.yaml generated-config.yaml
node_modules/
.pnpm-store/

680
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

@@ -6,6 +6,7 @@ edition = "2024"
[dependencies] [dependencies]
database = { path = "../../public/database" } database = { path = "../../public/database" }
migration = { path = "../../public/migration" } migration = { path = "../../public/migration" }
agent_client = { path = "../../public/agent-client" }
axum = { version = "0.8.7", features = ["form", "http1", "http2", "json", "matched-path", "original-uri", "query", "tokio", "tower-log", "tracing", "macros"] } axum = { version = "0.8.7", features = ["form", "http1", "http2", "json", "matched-path", "original-uri", "query", "tokio", "tower-log", "tracing", "macros"] }
axum-extra = { version = "0.12.2", features = ["cookie"] } axum-extra = { version = "0.12.2", features = ["cookie"] }
@@ -28,4 +29,14 @@ argon2 = { version = "0.5.3", features = ["std"] }
jsonwebtoken = { version = "10.2.0", features = ["rust_crypto"] } jsonwebtoken = { version = "10.2.0", features = ["rust_crypto"] }
uuid = { version = "1.19.0", features = ["v4", "serde", "fast-rng"] } uuid = { version = "1.19.0", features = ["v4", "serde", "fast-rng"] }
tower-http = { version = "0.6.8", features = ["cors"] } tower-http = { version = "0.6.8", features = ["cors"] }
reqwest = { version = "^0.12", features = ["json", "multipart", "stream"] }
serde_urlencoded = { version = "0.7.1" }
[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, _matches: &clap::ArgMatches,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> { ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
let output_path = _matches.get_one::<String>("output_path"); let output_path = _matches.get_one::<String>("output_path");
let output_path = output_path.unwrap().to_string(); let output_path = output_path.expect("output_path is required").to_string();
Box::pin(async move { Box::pin(async move {
tracing::subscriber::with_default(log::make_temporary_subscriber(), || { tracing::subscriber::with_default(log::make_temporary_subscriber(), || {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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; use sea_orm::DbErr;
#[derive(Debug)] #[derive(Debug)]
@@ -37,3 +38,23 @@ impl From<DbErr> for ServiceError {
} }
} }
} }
impl IntoResponse for ServiceError {
fn into_response(self) -> axum::response::Response {
let (status, message) = match &self {
ServiceError::NotFound(msg) => (axum::http::StatusCode::NOT_FOUND, msg.clone()),
ServiceError::DatabaseError(msg) => {
(axum::http::StatusCode::INTERNAL_SERVER_ERROR, msg.clone())
}
ServiceError::Unauthorized(msg) => (axum::http::StatusCode::UNAUTHORIZED, msg.clone()),
ServiceError::InternalError(msg) => {
(axum::http::StatusCode::INTERNAL_SERVER_ERROR, msg.clone())
}
ServiceError::BadRequest(msg) => (axum::http::StatusCode::BAD_REQUEST, msg.clone()),
};
let body = axum::Json(serde_json::json!({
"error": message,
}));
(status, body).into_response()
}
}

View File

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

View File

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

View File

@@ -1,6 +1,34 @@
use axum::{extract::FromRequestParts, http::StatusCode};
use uuid::Uuid; use uuid::Uuid;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct RequestInfo { pub struct RequestInfo {
pub user_id: Option<Uuid>, pub user_id: Option<Uuid>,
} }
pub struct AuthenticatedRequestInfo {
pub user_id: Uuid,
}
impl<S> FromRequestParts<S> for AuthenticatedRequestInfo
where
S: Send + Sync,
{
type Rejection = StatusCode;
async fn from_request_parts(
parts: &mut axum::http::request::Parts,
_state: &S,
) -> Result<Self, Self::Rejection> {
let request_info = parts
.extensions
.get::<RequestInfo>()
.ok_or(StatusCode::UNAUTHORIZED)?;
if let Some(user_id) = request_info.user_id {
Ok(AuthenticatedRequestInfo { user_id })
} else {
Err(StatusCode::UNAUTHORIZED)
}
}
}

View File

@@ -68,3 +68,42 @@ async fn handle_unauthenticated() -> Result<Response, StatusCode> {
// TODO: log unauthenticated access attempts // TODO: log unauthenticated access attempts
Err(StatusCode::UNAUTHORIZED) Err(StatusCode::UNAUTHORIZED)
} }
#[cfg(test)]
pub mod mock {
use super::*;
pub const REQUEST_AUTH_USER_ID_HEADER: &str = "x-mock-authenticated-user-id";
pub const REQUEST_AUTH_USER_INVALID_HEADER: &str = "x-mock-authenticated-invalid";
pub async fn mock_require_auth(
req: Request<axum::body::Body>,
next: Next,
) -> Result<Response, StatusCode> {
let mut req = req;
let invalid_present = req
.headers()
.get(REQUEST_AUTH_USER_INVALID_HEADER)
.is_some();
let user_id_header = req.headers().get(REQUEST_AUTH_USER_ID_HEADER).cloned();
if invalid_present {
return handle_unauthenticated().await;
}
let user = req
.extensions_mut()
.get_or_insert_with(|| RequestInfo { user_id: None });
user.user_id = Some(if let Some(user_id_header) = user_id_header {
let user_id_str = user_id_header
.to_str()
.map_err(|_| StatusCode::UNAUTHORIZED)?;
Uuid::parse_str(user_id_str).map_err(|_| StatusCode::UNAUTHORIZED)?
} else {
Uuid::new_v4()
});
Ok(next.run(req).await)
}
}

View File

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

View File

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

View File

@@ -78,22 +78,12 @@ pub async fn get_health_info(
#[cfg(test)] #[cfg(test)]
mod 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 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::body::to_bytes;
use axum::{ use axum::{
Router, Router,
@@ -114,17 +104,10 @@ mod test {
let app_state = Arc::new(AppState { let app_state = Arc::new(AppState {
database_connection: db.clone(), database_connection: db.clone(),
config: Arc::new(crate::configs::ProgramSettings::mock()), config: Arc::new(crate::configs::ProgramSettings::mock()),
service: Arc::new(crate::routes::AppService { service: Arc::new(get_app_service(
settings: Arc::new(SettingsService::new(db.clone())), &db.clone(),
auth_state: crate::routes::AuthState { &crate::configs::ProgramSettings::mock(),
strategy: crate::routes::AuthStrategy { )),
password: Arc::new(PasswordStrategy::new(db.clone())),
},
authentication: Arc::new(AuthenticationServiceImpl::new(None)),
},
user: Arc::new(UserServiceImpl::new(db.clone())),
server_state: Arc::new(ServerStateService::new(db.clone())),
}),
}); });
let app = Router::new() let app = Router::new()
@@ -135,13 +118,21 @@ mod test {
})); }));
let response = app let response = app
.oneshot(Request::builder().uri("/info").body(Body::empty()).unwrap()) .oneshot(
Request::builder()
.uri("/info")
.body(Body::empty())
.expect("Failed to build request"),
)
.await .await
.unwrap(); .unwrap();
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), 1024 * 1024).await.unwrap(); // Set limit to 1 MB let body = to_bytes(response.into_body(), 1024 * 1024)
let health_info: HealthInfo = serde_json::from_slice(&body).unwrap(); .await
.expect("Failed to read response body"); // Set limit to 1 MB
let health_info: HealthInfo =
serde_json::from_slice(&body).expect("Failed to deserialize response body");
assert_eq!(health_info.status, STATUS_HEALTHY); assert_eq!(health_info.status, STATUS_HEALTHY);
assert_eq!(health_info.version, env!("CARGO_PKG_VERSION")); assert_eq!(health_info.version, env!("CARGO_PKG_VERSION"));
assert!(health_info.errors.is_none()); assert!(health_info.errors.is_none());

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
pub mod nginx;
pub mod user; pub mod user;
use std::sync::Arc; use std::sync::Arc;
@@ -9,6 +10,7 @@ use crate::{middlewares::require_auth::require_auth, routes::AppState};
pub fn get_restricted_router(state: Arc<AppState>) -> Router { pub fn get_restricted_router(state: Arc<AppState>) -> Router {
Router::new() Router::new()
.nest("/user", user::get_user_router(state.clone())) .nest("/user", user::get_user_router(state.clone()))
.nest("/nginx", nginx::get_nginx_router(state.clone()))
.layer(axum::middleware::from_fn_with_state( .layer(axum::middleware::from_fn_with_state(
state.clone(), state.clone(),
require_auth, require_auth,

View File

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

View File

@@ -1,3 +1,85 @@
pub mod agent_client;
pub mod auth; pub mod auth;
pub mod nginx;
pub mod server_state; pub mod server_state;
pub mod settings; pub mod settings;
use std::sync::Arc;
use ::agent_client::apis::configuration::Configuration;
#[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 let (token, _) = service
.generate_jwt(user_id, 60) .generate_jwt(user_id, 60)
.await .await
.expect("generate jwt"); .expect("Failed to generate jwt");
let valid = service let valid = service
.is_valid_jwt(&token, None) .is_valid_jwt(&token, None)
.await .await
.expect("validate jwt"); .expect("Failed to validate jwt");
assert!(valid.is_some(), "Generated token should be valid"); assert!(valid.is_some(), "Generated token should be valid");
let claims = service.parse_jwt(&token).await.expect("parse jwt"); let claims = service
.parse_jwt(&token)
.await
.expect("Failed to parse jwt");
assert_eq!(claims.sub, user_id.to_string()); assert_eq!(claims.sub, user_id.to_string());
} }
@@ -213,10 +216,16 @@ mod tests {
let service = AuthenticationServiceImpl::new(Some("secret".to_string())); let service = AuthenticationServiceImpl::new(Some("secret".to_string()));
let user_id = Uuid::new_v4(); let user_id = Uuid::new_v4();
let (token, _) = service.generate_jwt(user_id, 60).await.unwrap(); let (token, _) = service
.generate_jwt(user_id, 60)
.await
.expect("Failed to generate jwt");
let other_sub = Uuid::new_v4().to_string(); let other_sub = Uuid::new_v4().to_string();
let valid = service.is_valid_jwt(&token, Some(other_sub)).await.unwrap(); let valid = service
.is_valid_jwt(&token, Some(other_sub))
.await
.expect("jwt is not valid");
assert!( assert!(
valid.is_none(), valid.is_none(),
"Token should be invalid for a different subject" "Token should be invalid for a different subject"
@@ -236,10 +245,19 @@ mod tests {
let service = AuthenticationServiceImpl::new(Some("secret".to_string())); let service = AuthenticationServiceImpl::new(Some("secret".to_string()));
let user_id = Uuid::new_v4(); let user_id = Uuid::new_v4();
let (token, _) = service.generate_jwt(user_id, 60).await.unwrap(); let (token, _) = service
let new_token = service.refresh_jwt(&token, 120).await.unwrap(); .generate_jwt(user_id, 60)
.await
.expect("Failed to generate jwt");
let new_token = service
.refresh_jwt(&token, 120)
.await
.expect("Failed to refresh jwt");
let claims = service.parse_jwt(&new_token).await.unwrap(); let claims = service
.parse_jwt(&new_token)
.await
.expect("Failed to parse refreshed jwt");
assert_eq!(claims.sub, user_id.to_string()); assert_eq!(claims.sub, user_id.to_string());
assert_eq!(claims.exp - claims.iat, 120); assert_eq!(claims.exp - claims.iat, 120);
} }
@@ -249,10 +267,16 @@ mod tests {
let service = AuthenticationServiceImpl::new(Some("secret".to_string())); let service = AuthenticationServiceImpl::new(Some("secret".to_string()));
let user_id = Uuid::new_v4(); let user_id = Uuid::new_v4();
let (token, claims) = service.generate_jwt(user_id, 1).await.unwrap(); let (token, claims) = service
.generate_jwt(user_id, 1)
.await
.expect("Failed to generate jwt");
sleep(Duration::from_secs(2)).await; sleep(Duration::from_secs(2)).await;
let valid = service.is_valid_jwt(&token, None).await.unwrap(); let valid = service
.is_valid_jwt(&token, None)
.await
.expect("Failed to validate jwt");
assert!( assert!(
valid.is_none(), valid.is_none(),
"Token should be expired and thus invalid. Current time: {:?}. Diff: {}", "Token should be expired and thus invalid. Current time: {:?}. Diff: {}",
@@ -266,9 +290,15 @@ mod tests {
let service = AuthenticationServiceImpl::new(Some("secret".to_string())); let service = AuthenticationServiceImpl::new(Some("secret".to_string()));
let user_id = Uuid::new_v4(); let user_id = Uuid::new_v4();
let (token, _) = service.generate_jwt(user_id, 1).await.unwrap(); let (token, _) = service
.generate_jwt(user_id, 1)
.await
.expect("Failed to generate jwt");
service.invalidate_jwt(&token).await.unwrap(); service
.invalidate_jwt(&token)
.await
.expect("Failed to invalidate jwt");
// ensure entry is present // ensure entry is present
{ {

View File

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

View File

@@ -0,0 +1,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 { export namespace Schemas {
// <Schemas> // <Schemas>
export type AdminInitRequest = { password: string; setup_secret: string; username: string }; export type AdminInitRequest = { password: string; setup_secret: string; username: string };
export type 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 = { export type HealthInfo = {
errors?: (Array<string> | null) | undefined; errors?: (Array<string> | null) | undefined;
is_initialized: boolean; is_initialized: boolean;
@@ -8,7 +86,115 @@ export namespace Schemas {
up_since: string; up_since: string;
version: 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 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 }; export type UserInfo = { id: string; username: string };
// </Schemas> // </Schemas>
@@ -42,6 +228,180 @@ export namespace Endpoints {
parameters: never; parameters: never;
responses: { 200: Schemas.HealthInfo; 404: unknown }; 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 = { export type get_Get_user_info = {
method: "GET"; method: "GET";
path: "/api/user/me"; path: "/api/user/me";
@@ -58,11 +418,33 @@ export type EndpointByMethod = {
post: { post: {
"/api/auth/init_admin": Endpoints.post_Init_admin; "/api/auth/init_admin": Endpoints.post_Init_admin;
"/api/auth/login": Endpoints.post_Login; "/api/auth/login": Endpoints.post_Login;
"/api/nginx/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: { get: {
"/api/health/info": Endpoints.get_Get_health_info; "/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; "/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> // </EndpointByMethod>
@@ -70,6 +452,8 @@ export type EndpointByMethod = {
// <EndpointByMethod.Shorthands> // <EndpointByMethod.Shorthands>
export type PostEndpoints = EndpointByMethod["post"]; export type PostEndpoints = EndpointByMethod["post"];
export type GetEndpoints = EndpointByMethod["get"]; export type GetEndpoints = EndpointByMethod["get"];
export type DeleteEndpoints = EndpointByMethod["delete"];
export type PatchEndpoints = EndpointByMethod["patch"];
// </EndpointByMethod.Shorthands> // </EndpointByMethod.Shorthands>
// <ApiClientTypes> // <ApiClientTypes>
@@ -364,6 +748,68 @@ export class ApiClient {
} }
// </ApiClient.get> // </ApiClient.get>
// <ApiClient.delete>
delete<Path extends keyof DeleteEndpoints, TEndpoint extends DeleteEndpoints[Path]>(
path: Path,
...params: MaybeOptionalArg<
TEndpoint extends { parameters: infer UParams }
? NotNever<UParams> extends true
? UParams & { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean }
: { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean }
: { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean }
>
): Promise<Extract<InferResponseByStatus<TEndpoint, SuccessStatusCode>, { data: {} }>["data"]>;
delete<Path extends keyof DeleteEndpoints, TEndpoint extends DeleteEndpoints[Path]>(
path: Path,
...params: MaybeOptionalArg<
TEndpoint extends { parameters: infer UParams }
? NotNever<UParams> extends true
? UParams & { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean }
: { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean }
: { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean }
>
): Promise<SafeApiResponse<TEndpoint>>;
delete<Path extends keyof DeleteEndpoints, _TEndpoint extends DeleteEndpoints[Path]>(
path: Path,
...params: MaybeOptionalArg<any>
): Promise<any> {
return this.request("delete", path, ...params);
}
// </ApiClient.delete>
// <ApiClient.patch>
patch<Path extends keyof PatchEndpoints, TEndpoint extends PatchEndpoints[Path]>(
path: Path,
...params: MaybeOptionalArg<
TEndpoint extends { parameters: infer UParams }
? NotNever<UParams> extends true
? UParams & { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean }
: { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean }
: { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean }
>
): Promise<Extract<InferResponseByStatus<TEndpoint, SuccessStatusCode>, { data: {} }>["data"]>;
patch<Path extends keyof PatchEndpoints, TEndpoint extends PatchEndpoints[Path]>(
path: Path,
...params: MaybeOptionalArg<
TEndpoint extends { parameters: infer UParams }
? NotNever<UParams> extends true
? UParams & { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean }
: { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean }
: { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean }
>
): Promise<SafeApiResponse<TEndpoint>>;
patch<Path extends keyof PatchEndpoints, _TEndpoint extends PatchEndpoints[Path]>(
path: Path,
...params: MaybeOptionalArg<any>
): Promise<any> {
return this.request("patch", path, ...params);
}
// </ApiClient.patch>
// <ApiClient.request> // <ApiClient.request>
/** /**
* Generic request method with full type-safety for any endpoint * Generic request method with full type-safety for any endpoint

View File

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

View File

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

7
openapitools.json Normal file
View File

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

6
package.json Normal file
View File

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

File diff suppressed because it is too large Load Diff

7
pnpm-workspace.yaml Normal file
View File

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

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

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
7.18.0

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More