From 91d0e1cd7ccf58bf384bcd44d598e42e1110b34d Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Mon, 29 Dec 2025 12:05:22 +0800 Subject: [PATCH 01/43] init nginx related database schema --- .../src/generated/entities/access_list.rs | 34 +++ .../generated/entities/access_list_entry.rs | 28 +++ .../src/generated/entities/audit_log.rs | 29 +++ .../src/generated/entities/location.rs | 45 ++++ public/database/src/generated/entities/mod.rs | 9 + .../src/generated/entities/prelude.rs | 9 + .../src/generated/entities/proxy_host.rs | 50 +++++ .../entities/proxy_host_access_list.rs | 33 +++ .../src/generated/entities/stream_service.rs | 47 ++++ .../entities/stream_service_access_list.rs | 33 +++ .../src/generated/entities/upstream.rs | 29 +++ .../src/generated/entities/upstream_target.rs | 30 +++ .../database/src/generated/entities/user.rs | 6 + public/migration/doc/nginx-tables.md | 208 ++++++++++++++++++ public/migration/src/lib.rs | 9 + public/migration/src/migrations.rs | 9 + .../m20251223_000004_create_upstream_table.rs | 66 ++++++ ...223_000005_create_upstream_target_table.rs | 92 ++++++++ ...20251223_000006_create_proxy_host_table.rs | 124 +++++++++++ .../m20251223_000007_create_location_table.rs | 100 +++++++++ ...1223_000008_create_stream_service_table.rs | 112 ++++++++++ ...0251223_000009_create_access_list_table.rs | 63 ++++++ ...3_000010_create_access_list_entry_table.rs | 58 +++++ ...011_create_proxy_host_access_list_table.rs | 65 ++++++ ...create_stream_service_access_list_table.rs | 65 ++++++ 25 files changed, 1353 insertions(+) create mode 100644 public/database/src/generated/entities/access_list.rs create mode 100644 public/database/src/generated/entities/access_list_entry.rs create mode 100644 public/database/src/generated/entities/audit_log.rs create mode 100644 public/database/src/generated/entities/location.rs create mode 100644 public/database/src/generated/entities/proxy_host.rs create mode 100644 public/database/src/generated/entities/proxy_host_access_list.rs create mode 100644 public/database/src/generated/entities/stream_service.rs create mode 100644 public/database/src/generated/entities/stream_service_access_list.rs create mode 100644 public/database/src/generated/entities/upstream.rs create mode 100644 public/database/src/generated/entities/upstream_target.rs create mode 100644 public/migration/doc/nginx-tables.md create mode 100644 public/migration/src/migrations/m20251223_000004_create_upstream_table.rs create mode 100644 public/migration/src/migrations/m20251223_000005_create_upstream_target_table.rs create mode 100644 public/migration/src/migrations/m20251223_000006_create_proxy_host_table.rs create mode 100644 public/migration/src/migrations/m20251223_000007_create_location_table.rs create mode 100644 public/migration/src/migrations/m20251223_000008_create_stream_service_table.rs create mode 100644 public/migration/src/migrations/m20251223_000009_create_access_list_table.rs create mode 100644 public/migration/src/migrations/m20251223_000010_create_access_list_entry_table.rs create mode 100644 public/migration/src/migrations/m20251223_000011_create_proxy_host_access_list_table.rs create mode 100644 public/migration/src/migrations/m20251223_000012_create_stream_service_access_list_table.rs diff --git a/public/database/src/generated/entities/access_list.rs b/public/database/src/generated/entities/access_list.rs new file mode 100644 index 0000000..93fd0e5 --- /dev/null +++ b/public/database/src/generated/entities/access_list.rs @@ -0,0 +1,34 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0.0-rc.18 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "access_list")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub name: String, + #[sea_orm(column_type = "Text", nullable)] + pub description: Option, + pub created_by: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[sea_orm(has_many)] + pub access_list_entries: HasMany, + #[sea_orm(has_many)] + pub proxy_host_access_lists: HasMany, + #[sea_orm(has_many)] + pub stream_service_access_lists: HasMany, + #[sea_orm( + belongs_to, + from = "created_by", + to = "id", + on_update = "Cascade", + on_delete = "SetNull" + )] + pub user: HasOne, +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/public/database/src/generated/entities/access_list_entry.rs b/public/database/src/generated/entities/access_list_entry.rs new file mode 100644 index 0000000..403ae92 --- /dev/null +++ b/public/database/src/generated/entities/access_list_entry.rs @@ -0,0 +1,28 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0.0-rc.18 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "access_list_entry")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub access_list_id: Uuid, + pub entry_type: String, + pub value: String, + #[sea_orm(column_type = "Text", nullable)] + pub comment: Option, + pub created_at: DateTimeUtc, + #[sea_orm( + belongs_to, + from = "access_list_id", + to = "id", + on_update = "Cascade", + on_delete = "Cascade" + )] + pub access_list: HasOne, +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/public/database/src/generated/entities/audit_log.rs b/public/database/src/generated/entities/audit_log.rs new file mode 100644 index 0000000..960aee7 --- /dev/null +++ b/public/database/src/generated/entities/audit_log.rs @@ -0,0 +1,29 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0.0-rc.18 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "audit_log")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub actor_id: Option, + pub action: String, + pub resource_type: String, + pub resource_id: String, + #[sea_orm(column_type = "JsonBinary", nullable)] + pub details: Option, + pub created_at: DateTimeUtc, + #[sea_orm( + belongs_to, + from = "actor_id", + to = "id", + on_update = "Cascade", + on_delete = "SetNull" + )] + pub user: HasOne, +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/public/database/src/generated/entities/location.rs b/public/database/src/generated/entities/location.rs new file mode 100644 index 0000000..99f1c32 --- /dev/null +++ b/public/database/src/generated/entities/location.rs @@ -0,0 +1,45 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0.0-rc.18 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "location")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub host_id: Uuid, + pub path: String, + pub match_type: String, + pub order: i64, + pub upstream_id: Option, + pub proxy_pass_host: Option, + pub proxy_pass_port: Option, + pub preserve_host_header: Option, + #[sea_orm(column_type = "JsonBinary", nullable)] + pub allowed_methods: Option, + #[sea_orm(column_type = "Text", nullable)] + pub custom_config: Option, + pub enabled: bool, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[sea_orm( + belongs_to, + from = "host_id", + to = "id", + on_update = "Cascade", + on_delete = "Cascade" + )] + pub proxy_host: HasOne, + #[sea_orm( + belongs_to, + from = "upstream_id", + to = "id", + on_update = "Cascade", + on_delete = "SetNull" + )] + pub upstream: HasOne, +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/public/database/src/generated/entities/mod.rs b/public/database/src/generated/entities/mod.rs index 1917343..33598a4 100644 --- a/public/database/src/generated/entities/mod.rs +++ b/public/database/src/generated/entities/mod.rs @@ -2,6 +2,15 @@ pub mod prelude; +pub mod access_list; +pub mod access_list_entry; pub mod config; +pub mod location; +pub mod proxy_host; +pub mod proxy_host_access_list; +pub mod stream_service; +pub mod stream_service_access_list; +pub mod upstream; +pub mod upstream_target; pub mod user; pub mod user_identity; diff --git a/public/database/src/generated/entities/prelude.rs b/public/database/src/generated/entities/prelude.rs index f0df089..d09f1c3 100644 --- a/public/database/src/generated/entities/prelude.rs +++ b/public/database/src/generated/entities/prelude.rs @@ -1,5 +1,14 @@ //! `SeaORM` Entity, @generated by sea-orm-codegen 2.0.0-rc.18 +pub use super::access_list::Entity as AccessList; +pub use super::access_list_entry::Entity as AccessListEntry; pub use super::config::Entity as Config; +pub use super::location::Entity as Location; +pub use super::proxy_host::Entity as ProxyHost; +pub use super::proxy_host_access_list::Entity as ProxyHostAccessList; +pub use super::stream_service::Entity as StreamService; +pub use super::stream_service_access_list::Entity as StreamServiceAccessList; +pub use super::upstream::Entity as Upstream; +pub use super::upstream_target::Entity as UpstreamTarget; pub use super::user::Entity as User; pub use super::user_identity::Entity as UserIdentity; diff --git a/public/database/src/generated/entities/proxy_host.rs b/public/database/src/generated/entities/proxy_host.rs new file mode 100644 index 0000000..b521b02 --- /dev/null +++ b/public/database/src/generated/entities/proxy_host.rs @@ -0,0 +1,50 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0.0-rc.18 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "proxy_host")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub name: Option, + pub domain: String, + pub scheme: String, + pub listen_port: i64, + pub forward_scheme: String, + pub forward_host: Option, + pub forward_port: Option, + pub preserve_host_header: bool, + pub enable_websocket: bool, + pub enabled: bool, + #[sea_orm(column_type = "JsonBinary", nullable)] + pub meta: Option, + pub default_upstream_id: Option, + pub created_by: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[sea_orm(has_many)] + pub locations: HasMany, + #[sea_orm(has_many)] + pub proxy_host_access_lists: HasMany, + #[sea_orm( + belongs_to, + from = "default_upstream_id", + to = "id", + on_update = "Cascade", + on_delete = "SetNull" + )] + pub upstream: HasOne, + #[sea_orm( + belongs_to, + from = "created_by", + to = "id", + on_update = "Cascade", + on_delete = "SetNull" + )] + pub user: HasOne, +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/public/database/src/generated/entities/proxy_host_access_list.rs b/public/database/src/generated/entities/proxy_host_access_list.rs new file mode 100644 index 0000000..0818588 --- /dev/null +++ b/public/database/src/generated/entities/proxy_host_access_list.rs @@ -0,0 +1,33 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0.0-rc.18 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "proxy_host_access_list")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub proxy_host_id: Uuid, + pub access_list_id: Uuid, + pub created_at: DateTimeUtc, + #[sea_orm( + belongs_to, + from = "access_list_id", + to = "id", + on_update = "Cascade", + on_delete = "Cascade" + )] + pub access_list: HasOne, + #[sea_orm( + belongs_to, + from = "proxy_host_id", + to = "id", + on_update = "Cascade", + on_delete = "Cascade" + )] + pub proxy_host: HasOne, +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/public/database/src/generated/entities/stream_service.rs b/public/database/src/generated/entities/stream_service.rs new file mode 100644 index 0000000..08a92fe --- /dev/null +++ b/public/database/src/generated/entities/stream_service.rs @@ -0,0 +1,47 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0.0-rc.18 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "stream_service")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub name: Option, + pub listen_host: String, + pub listen_port: i64, + pub protocol: String, + pub mode: String, + pub forward_host: Option, + pub forward_port: Option, + pub upstream_id: Option, + pub preserved_client_ip: bool, + pub enabled: bool, + #[sea_orm(column_type = "JsonBinary", nullable)] + pub meta: Option, + pub created_by: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[sea_orm(has_many)] + pub stream_service_access_lists: HasMany, + #[sea_orm( + belongs_to, + from = "upstream_id", + to = "id", + on_update = "Cascade", + on_delete = "SetNull" + )] + pub upstream: HasOne, + #[sea_orm( + belongs_to, + from = "created_by", + to = "id", + on_update = "Cascade", + on_delete = "SetNull" + )] + pub user: HasOne, +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/public/database/src/generated/entities/stream_service_access_list.rs b/public/database/src/generated/entities/stream_service_access_list.rs new file mode 100644 index 0000000..9ec8043 --- /dev/null +++ b/public/database/src/generated/entities/stream_service_access_list.rs @@ -0,0 +1,33 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0.0-rc.18 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "stream_service_access_list")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub stream_service_id: Uuid, + pub access_list_id: Uuid, + pub created_at: DateTimeUtc, + #[sea_orm( + belongs_to, + from = "access_list_id", + to = "id", + on_update = "Cascade", + on_delete = "Cascade" + )] + pub access_list: HasOne, + #[sea_orm( + belongs_to, + from = "stream_service_id", + to = "id", + on_update = "Cascade", + on_delete = "Cascade" + )] + pub stream_service: HasOne, +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/public/database/src/generated/entities/upstream.rs b/public/database/src/generated/entities/upstream.rs new file mode 100644 index 0000000..3490112 --- /dev/null +++ b/public/database/src/generated/entities/upstream.rs @@ -0,0 +1,29 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0.0-rc.18 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "upstream")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub name: String, + pub protocol: String, + pub algorithm: String, + pub sticky_session: bool, + pub created_by: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[sea_orm(has_many)] + pub locations: HasMany, + #[sea_orm(has_many)] + pub proxy_hosts: HasMany, + #[sea_orm(has_many)] + pub stream_services: HasMany, + #[sea_orm(has_many)] + pub upstream_targets: HasMany, +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/public/database/src/generated/entities/upstream_target.rs b/public/database/src/generated/entities/upstream_target.rs new file mode 100644 index 0000000..a35acf7 --- /dev/null +++ b/public/database/src/generated/entities/upstream_target.rs @@ -0,0 +1,30 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0.0-rc.18 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "upstream_target")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub upstream_id: Uuid, + pub target_host: String, + pub target_port: i64, + pub weight: i64, + pub is_backup: bool, + pub enabled: bool, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[sea_orm( + belongs_to, + from = "upstream_id", + to = "id", + on_update = "Cascade", + on_delete = "Cascade" + )] + pub upstream: HasOne, +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/public/database/src/generated/entities/user.rs b/public/database/src/generated/entities/user.rs index ab2edb2..6e043f4 100644 --- a/public/database/src/generated/entities/user.rs +++ b/public/database/src/generated/entities/user.rs @@ -18,6 +18,12 @@ pub struct Model { pub last_login_at: Option, pub deleted_at: Option, #[sea_orm(has_many)] + pub access_lists: HasMany, + #[sea_orm(has_many)] + pub proxy_hosts: HasMany, + #[sea_orm(has_many)] + pub stream_services: HasMany, + #[sea_orm(has_many)] pub user_identities: HasMany, } diff --git a/public/migration/doc/nginx-tables.md b/public/migration/doc/nginx-tables.md new file mode 100644 index 0000000..2e09164 --- /dev/null +++ b/public/migration/doc/nginx-tables.md @@ -0,0 +1,208 @@ +# Migration Tables → nginx mapping + +This document explains the purpose of each migration table added under `public/migration/src/migrations` and how the rows map to generated nginx configuration (HTTP `http {}` and `stream {}` contexts). + +Summary of tables covered: + +- `upstream` +- `upstream_target` +- `proxy_host` +- `location` +- `stream_service` +- `access_list` +- `access_list_entry` +- `audit_log` + +--- + +## `upstream` + +Purpose: A named backend pool of servers. Shared by HTTP and stream services. + +Key fields: + +- `id`: UUID primary key +- `name`: identifier used when generating nginx `upstream {}` +- `protocol`: `http` | `tcp` | `udp` — determines how nginx will use the pool +- `algorithm`: load balancing strategy (`round_robin`, `least_conn`, `ip_hash`) +- `sticky_session`: whether to enable sticky behavior when supported +- `health_check`: optional JSON describing health probes + +nginx mapping (HTTP): + +```nginx +upstream { + server 10.0.0.5:8080 weight=2; + server 10.0.0.6:8080 backup; + # optional LB settings generated from `algorithm` and `sticky_session` +} + +server { + listen 80; + server_name example.com; + + location / { + proxy_pass http://; + } +} +``` + +nginx mapping (stream): + +```nginx +stream { + upstream { + server 10.0.0.5:3306; + server 10.0.0.6:3306 backup; + } + + server { + listen 3306; + proxy_pass ; + } +} +``` + +Notes: `upstream.protocol` selects which block and directive forms to generate; + +--- + +## `upstream_target` + +Purpose: One row per backend server in an `upstream` pool. + +Key fields: + +- `upstream_id`: FK to `upstream` +- `target_host`, `target_port` +- `weight`, `is_backup`, `enabled` + +nginx mapping: each row becomes a `server` line in the generated `upstream` block (weights and backup flags applied). Disabled targets are omitted. + +Example generated line: + +```nginx +server 10.0.0.5:8080 weight=3; +server 10.0.0.6:8080 backup; +``` + +--- + +## `proxy_host` + +Purpose: Represents an HTTP(S) host (a top-level `server` block in nginx `http` context). + +Key fields: + +- `domain`: `server_name` value (may be a wildcard) +- `listen_port`: port to listen on (80/443) +- `scheme`: http|https (informs UI; TLS handled elsewhere) +- `forward_host/forward_port` or `default_upstream_id`: host-level forwarding fallback +- `preserve_host_header`: whether to forward original `Host` header +- `enable_websocket`: toggles websocket header handling +- `meta`: JSON for optional host-level settings (timeouts, client_max_body_size, custom snippets) + +nginx mapping (host-level default): + +```nginx +server { + listen ; + server_name ; + + # host-level fallback if no matching location + location / { + proxy_pass http://; + } +} +``` + +If `forward_host`/`forward_port` is set instead of `default_upstream_id`, generate `proxy_pass http://forward_host:forward_port;`. + +`meta` entries are injected into the `server` block (careful: snippets can break reloads). + +--- + +## `location` + +Purpose: Path-level routing (`location` blocks inside a `server`). More specific than `proxy_host` default. + +Key fields: + +- `host_id`: FK to `proxy_host` +- `path`: `location` match (e.g., `/api`, `~^/assets/`) +- `match_type`: `prefix` | `exact` | `regex` +- `upstream_id` or `proxy_pass_host`/`proxy_pass_port` +- `allowed_methods`: optional method whitelist +- `custom_config`: raw nginx snippet inserted inside the `location` + +nginx mapping: + +```nginx +location /api { + proxy_pass http://api_upstream; + # optional custom_config injected here +} +``` + +Ordering and match type produce correct nginx `location` selection semantics; `order` field can break ties for equal specificity. + +--- + +## `stream_service` + +Purpose: A TCP/UDP service in nginx `stream` context — corresponds to a `server` block inside `stream {}`. + +Key fields: + +- `listen_host`, `listen_port` +- `protocol`: `tcp` | `udp` +- `mode`: `direct` | `upstream` (direct forwards to `forward_host:forward_port`, `upstream` uses `upstream_id` pool) +- `preserved_client_ip`: whether to enable proxy_protocol or other client-ip forwarding +- `meta`: JSON for advanced stream options (ssl_preread, proxy_timeout, buffer sizes) + +nginx mapping (stream): + +```nginx +stream { + upstream { + server 10.0.0.5:3306; + } + + server { + listen 3306; + proxy_pass ; # or proxy_pass 10.0.0.10:3306 for direct + } +} +``` + +Notes: Stream services bypass HTTP processing. Use `meta` for `proxy_protocol` and `ssl_preread` toggles. + +--- + +## `access_list` and `access_list_entry` + +Purpose: Nameable allow/deny lists for IP/CIDR or other entry types that can be applied to hosts/locations/stream services. + +Key fields (access_list): `id`, `name`, `description`. +Key fields (entry): `access_list_id`, `entry_type` (e.g., `allow`, `deny`, `note`), `value` (IP or CIDR), `comment`. + +nginx mapping (HTTP example): + +```nginx +location /admin { + allow 10.0.0.0/24; + deny all; + proxy_pass http://admin_upstream; +} +``` + +nginx mapping (stream example): + +```nginx +server { + listen 3306; + allow 10.0.0.0/24; + deny all; + proxy_pass backend_pool; +} +``` diff --git a/public/migration/src/lib.rs b/public/migration/src/lib.rs index 83e0779..920a97d 100644 --- a/public/migration/src/lib.rs +++ b/public/migration/src/lib.rs @@ -13,6 +13,15 @@ impl MigratorTrait for Migrator { Box::new(m20251011_000001_create_config_table::Migration), Box::new(m20251011_000002_create_user_table::Migration), Box::new(m20251011_000003_create_user_identity_table::Migration), + Box::new(m20251223_000004_create_upstream_table::Migration), + Box::new(m20251223_000005_create_upstream_target_table::Migration), + Box::new(m20251223_000006_create_proxy_host_table::Migration), + Box::new(m20251223_000007_create_location_table::Migration), + Box::new(m20251223_000008_create_stream_service_table::Migration), + Box::new(m20251223_000009_create_access_list_table::Migration), + Box::new(m20251223_000010_create_access_list_entry_table::Migration), + Box::new(m20251223_000011_create_proxy_host_access_list_table::Migration), + Box::new(m20251223_000012_create_stream_service_access_list_table::Migration), ] } } diff --git a/public/migration/src/migrations.rs b/public/migration/src/migrations.rs index 2ff1c48..1501f2a 100644 --- a/public/migration/src/migrations.rs +++ b/public/migration/src/migrations.rs @@ -1,3 +1,12 @@ pub mod m20251011_000001_create_config_table; pub mod m20251011_000002_create_user_table; pub mod m20251011_000003_create_user_identity_table; +pub mod m20251223_000004_create_upstream_table; +pub mod m20251223_000005_create_upstream_target_table; +pub mod m20251223_000006_create_proxy_host_table; +pub mod m20251223_000007_create_location_table; +pub mod m20251223_000008_create_stream_service_table; +pub mod m20251223_000009_create_access_list_table; +pub mod m20251223_000010_create_access_list_entry_table; +pub mod m20251223_000011_create_proxy_host_access_list_table; +pub mod m20251223_000012_create_stream_service_access_list_table; diff --git a/public/migration/src/migrations/m20251223_000004_create_upstream_table.rs b/public/migration/src/migrations/m20251223_000004_create_upstream_table.rs new file mode 100644 index 0000000..e6df994 --- /dev/null +++ b/public/migration/src/migrations/m20251223_000004_create_upstream_table.rs @@ -0,0 +1,66 @@ +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[forbid(dead_code)] +#[derive(DeriveIden)] +pub enum Upstream { + Table, + Id, + Name, + Protocol, + Algorithm, + StickySession, + CreatedBy, + CreatedAt, + UpdatedAt, +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Upstream::Table) + .if_not_exists() + .col(pk_uuid(Upstream::Id)) + .col(ColumnDef::new(Upstream::Name).string().not_null()) + .col(ColumnDef::new(Upstream::Protocol).string().not_null()) + .col( + ColumnDef::new(Upstream::Algorithm) + .string() + .default("round_robin") + .not_null(), + ) + .col( + ColumnDef::new(Upstream::StickySession) + .boolean() + .default(false) + .not_null(), + ) + .col(ColumnDef::new(Upstream::CreatedBy).uuid().null()) + .col( + ColumnDef::new(Upstream::CreatedAt) + .timestamp() + .default(SimpleExpr::Keyword(Keyword::CurrentTimestamp)) + .not_null(), + ) + .col( + ColumnDef::new(Upstream::UpdatedAt) + .timestamp() + .default(SimpleExpr::Keyword(Keyword::CurrentTimestamp)) + .not_null(), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Upstream::Table).to_owned()) + .await + } +} diff --git a/public/migration/src/migrations/m20251223_000005_create_upstream_target_table.rs b/public/migration/src/migrations/m20251223_000005_create_upstream_target_table.rs new file mode 100644 index 0000000..0d1567a --- /dev/null +++ b/public/migration/src/migrations/m20251223_000005_create_upstream_target_table.rs @@ -0,0 +1,92 @@ +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[forbid(dead_code)] +#[derive(DeriveIden)] +pub enum UpstreamTarget { + Table, + Id, + UpstreamId, + TargetHost, + TargetPort, + Weight, + IsBackup, + Enabled, + CreatedAt, + UpdatedAt, +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(UpstreamTarget::Table) + .if_not_exists() + .col(pk_uuid(UpstreamTarget::Id)) + .col(ColumnDef::new(UpstreamTarget::UpstreamId).uuid().not_null()) + .foreign_key( + ForeignKey::create() + .name("fk-upstream-target-upstream-id") + .from(UpstreamTarget::Table, UpstreamTarget::UpstreamId) + .to( + super::m20251223_000004_create_upstream_table::Upstream::Table, + super::m20251223_000004_create_upstream_table::Upstream::Id, + ) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::Cascade), + ) + .col( + ColumnDef::new(UpstreamTarget::TargetHost) + .string() + .not_null(), + ) + .col( + ColumnDef::new(UpstreamTarget::TargetPort) + .integer() + .not_null(), + ) + .col( + ColumnDef::new(UpstreamTarget::Weight) + .integer() + .default(1) + .not_null(), + ) + .col( + ColumnDef::new(UpstreamTarget::IsBackup) + .boolean() + .default(false) + .not_null(), + ) + .col( + ColumnDef::new(UpstreamTarget::Enabled) + .boolean() + .default(true) + .not_null(), + ) + .col( + ColumnDef::new(UpstreamTarget::CreatedAt) + .timestamp() + .default(SimpleExpr::Keyword(Keyword::CurrentTimestamp)) + .not_null(), + ) + .col( + ColumnDef::new(UpstreamTarget::UpdatedAt) + .timestamp() + .default(SimpleExpr::Keyword(Keyword::CurrentTimestamp)) + .not_null(), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(UpstreamTarget::Table).to_owned()) + .await + } +} diff --git a/public/migration/src/migrations/m20251223_000006_create_proxy_host_table.rs b/public/migration/src/migrations/m20251223_000006_create_proxy_host_table.rs new file mode 100644 index 0000000..bcdd3fa --- /dev/null +++ b/public/migration/src/migrations/m20251223_000006_create_proxy_host_table.rs @@ -0,0 +1,124 @@ +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[forbid(dead_code)] +#[derive(DeriveIden)] +pub enum ProxyHost { + Table, + Id, + Name, + Domain, + Scheme, + ListenPort, + ForwardScheme, + ForwardHost, + ForwardPort, + PreserveHostHeader, + EnableWebsocket, + Enabled, + Meta, + DefaultUpstreamId, + CreatedBy, + CreatedAt, + UpdatedAt, +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(ProxyHost::Table) + .if_not_exists() + .col(pk_uuid(ProxyHost::Id)) + .col(ColumnDef::new(ProxyHost::Name).string().null()) + .col(ColumnDef::new(ProxyHost::Domain).string().not_null()) + .col( + ColumnDef::new(ProxyHost::Scheme) + .string() + .default("http") + .not_null(), + ) + .col( + ColumnDef::new(ProxyHost::ListenPort) + .integer() + .default(80) + .not_null(), + ) + .col( + ColumnDef::new(ProxyHost::ForwardScheme) + .string() + .default("http") + .not_null(), + ) + .col(ColumnDef::new(ProxyHost::ForwardHost).string().null()) + .col(ColumnDef::new(ProxyHost::ForwardPort).integer().null()) + .col( + ColumnDef::new(ProxyHost::PreserveHostHeader) + .boolean() + .default(false) + .not_null(), + ) + .col( + ColumnDef::new(ProxyHost::EnableWebsocket) + .boolean() + .default(false) + .not_null(), + ) + .col( + ColumnDef::new(ProxyHost::Enabled) + .boolean() + .default(true) + .not_null(), + ) + .col(ColumnDef::new(ProxyHost::Meta).json_binary().null()) + .col(ColumnDef::new(ProxyHost::DefaultUpstreamId).uuid().null()) + .foreign_key( + ForeignKey::create() + .name("fk-proxy-host-default-upstream-id") + .from(ProxyHost::Table, ProxyHost::DefaultUpstreamId) + .to( + super::m20251223_000004_create_upstream_table::Upstream::Table, + super::m20251223_000004_create_upstream_table::Upstream::Id, + ) + .on_delete(ForeignKeyAction::SetNull) + .on_update(ForeignKeyAction::Cascade), + ) + .col(ColumnDef::new(ProxyHost::CreatedBy).uuid().null()) + .foreign_key( + ForeignKey::create() + .name("fk-proxy-host-created-by") + .from(ProxyHost::Table, ProxyHost::CreatedBy) + .to( + super::m20251011_000002_create_user_table::User::Table, + super::m20251011_000002_create_user_table::User::Id, + ) + .on_delete(ForeignKeyAction::SetNull) + .on_update(ForeignKeyAction::Cascade), + ) + .col( + ColumnDef::new(ProxyHost::CreatedAt) + .timestamp() + .default(SimpleExpr::Keyword(Keyword::CurrentTimestamp)) + .not_null(), + ) + .col( + ColumnDef::new(ProxyHost::UpdatedAt) + .timestamp() + .default(SimpleExpr::Keyword(Keyword::CurrentTimestamp)) + .not_null(), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(ProxyHost::Table).to_owned()) + .await + } +} diff --git a/public/migration/src/migrations/m20251223_000007_create_location_table.rs b/public/migration/src/migrations/m20251223_000007_create_location_table.rs new file mode 100644 index 0000000..5da97d4 --- /dev/null +++ b/public/migration/src/migrations/m20251223_000007_create_location_table.rs @@ -0,0 +1,100 @@ +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[forbid(dead_code)] +#[derive(DeriveIden)] +pub enum Location { + Table, + Id, + HostId, + Path, + MatchType, + Order, + UpstreamId, + ProxyPassHost, + ProxyPassPort, + PreserveHostHeader, + AllowedMethods, + CustomConfig, + Enabled, + CreatedAt, + UpdatedAt, +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Location::Table) + .if_not_exists() + .col(pk_uuid(Location::Id)) + .col(ColumnDef::new(Location::HostId).uuid().not_null()) + .foreign_key( + ForeignKey::create() + .name("fk-location-host-id") + .from(Location::Table, Location::HostId) + .to( + super::m20251223_000006_create_proxy_host_table::ProxyHost::Table, + super::m20251223_000006_create_proxy_host_table::ProxyHost::Id, + ) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::Cascade), + ) + .col(ColumnDef::new(Location::Path).string().not_null()) + .col( + ColumnDef::new(Location::MatchType) + .string() + .default("prefix") + .not_null(), + ) + .col(ColumnDef::new(Location::Order).integer().default(0).not_null()) + .col(ColumnDef::new(Location::UpstreamId).uuid().null()) + .foreign_key( + ForeignKey::create() + .name("fk-location-upstream-id") + .from(Location::Table, Location::UpstreamId) + .to( + super::m20251223_000004_create_upstream_table::Upstream::Table, + super::m20251223_000004_create_upstream_table::Upstream::Id, + ) + .on_delete(ForeignKeyAction::SetNull) + .on_update(ForeignKeyAction::Cascade), + ) + .col(ColumnDef::new(Location::ProxyPassHost).string().null()) + .col(ColumnDef::new(Location::ProxyPassPort).integer().null()) + .col(ColumnDef::new(Location::PreserveHostHeader).boolean().null()) + .col(ColumnDef::new(Location::AllowedMethods).json_binary().null()) + .col(ColumnDef::new(Location::CustomConfig).text().null()) + .col( + ColumnDef::new(Location::Enabled) + .boolean() + .default(true) + .not_null(), + ) + .col( + ColumnDef::new(Location::CreatedAt) + .timestamp() + .default(SimpleExpr::Keyword(Keyword::CurrentTimestamp)) + .not_null(), + ) + .col( + ColumnDef::new(Location::UpdatedAt) + .timestamp() + .default(SimpleExpr::Keyword(Keyword::CurrentTimestamp)) + .not_null(), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Location::Table).to_owned()) + .await + } +} diff --git a/public/migration/src/migrations/m20251223_000008_create_stream_service_table.rs b/public/migration/src/migrations/m20251223_000008_create_stream_service_table.rs new file mode 100644 index 0000000..e99ae60 --- /dev/null +++ b/public/migration/src/migrations/m20251223_000008_create_stream_service_table.rs @@ -0,0 +1,112 @@ +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[forbid(dead_code)] +#[derive(DeriveIden)] +pub enum StreamService { + Table, + Id, + Name, + ListenHost, + ListenPort, + Protocol, + Mode, + ForwardHost, + ForwardPort, + UpstreamId, + PreservedClientIp, + Enabled, + Meta, + CreatedBy, + CreatedAt, + UpdatedAt, +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(StreamService::Table) + .if_not_exists() + .col(pk_uuid(StreamService::Id)) + .col(ColumnDef::new(StreamService::Name).string().null()) + .col( + ColumnDef::new(StreamService::ListenHost) + .string() + .default("0.0.0.0") + .not_null(), + ) + .col(ColumnDef::new(StreamService::ListenPort).integer().not_null()) + .col(ColumnDef::new(StreamService::Protocol).string().not_null()) + .col( + ColumnDef::new(StreamService::Mode) + .string() + .default("direct") + .not_null(), + ) + .col(ColumnDef::new(StreamService::ForwardHost).string().null()) + .col(ColumnDef::new(StreamService::ForwardPort).integer().null()) + .col(ColumnDef::new(StreamService::UpstreamId).uuid().null()) + .foreign_key( + ForeignKey::create() + .name("fk-stream-service-upstream-id") + .from(StreamService::Table, StreamService::UpstreamId) + .to( + super::m20251223_000004_create_upstream_table::Upstream::Table, + super::m20251223_000004_create_upstream_table::Upstream::Id, + ) + .on_delete(ForeignKeyAction::SetNull) + .on_update(ForeignKeyAction::Cascade), + ) + .col( + ColumnDef::new(StreamService::PreservedClientIp) + .boolean() + .default(false) + .not_null(), + ) + .col( + ColumnDef::new(StreamService::Enabled) + .boolean() + .default(true) + .not_null(), + ) + .col(ColumnDef::new(StreamService::Meta).json_binary().null()) + .col(ColumnDef::new(StreamService::CreatedBy).uuid().null()) + .foreign_key( + ForeignKey::create() + .name("fk-stream-service-created-by") + .from(StreamService::Table, StreamService::CreatedBy) + .to( + super::m20251011_000002_create_user_table::User::Table, + super::m20251011_000002_create_user_table::User::Id, + ) + .on_delete(ForeignKeyAction::SetNull) + .on_update(ForeignKeyAction::Cascade), + ) + .col( + ColumnDef::new(StreamService::CreatedAt) + .timestamp() + .default(SimpleExpr::Keyword(Keyword::CurrentTimestamp)) + .not_null(), + ) + .col( + ColumnDef::new(StreamService::UpdatedAt) + .timestamp() + .default(SimpleExpr::Keyword(Keyword::CurrentTimestamp)) + .not_null(), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(StreamService::Table).to_owned()) + .await + } +} diff --git a/public/migration/src/migrations/m20251223_000009_create_access_list_table.rs b/public/migration/src/migrations/m20251223_000009_create_access_list_table.rs new file mode 100644 index 0000000..7d947b7 --- /dev/null +++ b/public/migration/src/migrations/m20251223_000009_create_access_list_table.rs @@ -0,0 +1,63 @@ +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[forbid(dead_code)] +#[derive(DeriveIden)] +pub enum AccessList { + Table, + Id, + Name, + Description, + CreatedBy, + CreatedAt, + UpdatedAt, +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(AccessList::Table) + .if_not_exists() + .col(pk_uuid(AccessList::Id)) + .col(ColumnDef::new(AccessList::Name).string().not_null()) + .col(ColumnDef::new(AccessList::Description).text().null()) + .col(ColumnDef::new(AccessList::CreatedBy).uuid().null()) + .foreign_key( + ForeignKey::create() + .name("fk-access-list-created-by") + .from(AccessList::Table, AccessList::CreatedBy) + .to( + super::m20251011_000002_create_user_table::User::Table, + super::m20251011_000002_create_user_table::User::Id, + ) + .on_delete(ForeignKeyAction::SetNull) + .on_update(ForeignKeyAction::Cascade), + ) + .col( + ColumnDef::new(AccessList::CreatedAt) + .timestamp() + .default(SimpleExpr::Keyword(Keyword::CurrentTimestamp)) + .not_null(), + ) + .col( + ColumnDef::new(AccessList::UpdatedAt) + .timestamp() + .default(SimpleExpr::Keyword(Keyword::CurrentTimestamp)) + .not_null(), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(AccessList::Table).to_owned()) + .await + } +} diff --git a/public/migration/src/migrations/m20251223_000010_create_access_list_entry_table.rs b/public/migration/src/migrations/m20251223_000010_create_access_list_entry_table.rs new file mode 100644 index 0000000..2dea34e --- /dev/null +++ b/public/migration/src/migrations/m20251223_000010_create_access_list_entry_table.rs @@ -0,0 +1,58 @@ +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[forbid(dead_code)] +#[derive(DeriveIden)] +pub enum AccessListEntry { + Table, + Id, + AccessListId, + EntryType, + Value, + Comment, + CreatedAt, +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(AccessListEntry::Table) + .if_not_exists() + .col(pk_uuid(AccessListEntry::Id)) + .col(ColumnDef::new(AccessListEntry::AccessListId).uuid().not_null()) + .foreign_key( + ForeignKey::create() + .name("fk-access-list-entry-access-list-id") + .from(AccessListEntry::Table, AccessListEntry::AccessListId) + .to( + super::m20251223_000009_create_access_list_table::AccessList::Table, + super::m20251223_000009_create_access_list_table::AccessList::Id, + ) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::Cascade), + ) + .col(ColumnDef::new(AccessListEntry::EntryType).string().not_null()) + .col(ColumnDef::new(AccessListEntry::Value).string().not_null()) + .col(ColumnDef::new(AccessListEntry::Comment).text().null()) + .col( + ColumnDef::new(AccessListEntry::CreatedAt) + .timestamp() + .default(SimpleExpr::Keyword(Keyword::CurrentTimestamp)) + .not_null(), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(AccessListEntry::Table).to_owned()) + .await + } +} diff --git a/public/migration/src/migrations/m20251223_000011_create_proxy_host_access_list_table.rs b/public/migration/src/migrations/m20251223_000011_create_proxy_host_access_list_table.rs new file mode 100644 index 0000000..27f085e --- /dev/null +++ b/public/migration/src/migrations/m20251223_000011_create_proxy_host_access_list_table.rs @@ -0,0 +1,65 @@ +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[forbid(dead_code)] +#[derive(DeriveIden)] +pub enum ProxyHostAccessList { + Table, + Id, + ProxyHostId, + AccessListId, + CreatedAt, +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(ProxyHostAccessList::Table) + .if_not_exists() + .col(pk_uuid(ProxyHostAccessList::Id)) + .col(ColumnDef::new(ProxyHostAccessList::ProxyHostId).uuid().not_null()) + .foreign_key( + ForeignKey::create() + .name("fk-proxy-host-access-list-host-id") + .from(ProxyHostAccessList::Table, ProxyHostAccessList::ProxyHostId) + .to( + super::m20251223_000006_create_proxy_host_table::ProxyHost::Table, + super::m20251223_000006_create_proxy_host_table::ProxyHost::Id, + ) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::Cascade), + ) + .col(ColumnDef::new(ProxyHostAccessList::AccessListId).uuid().not_null()) + .foreign_key( + ForeignKey::create() + .name("fk-proxy-host-access-list-access-list-id") + .from(ProxyHostAccessList::Table, ProxyHostAccessList::AccessListId) + .to( + super::m20251223_000009_create_access_list_table::AccessList::Table, + super::m20251223_000009_create_access_list_table::AccessList::Id, + ) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::Cascade), + ) + .col( + ColumnDef::new(ProxyHostAccessList::CreatedAt) + .timestamp() + .default(SimpleExpr::Keyword(Keyword::CurrentTimestamp)) + .not_null(), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(ProxyHostAccessList::Table).to_owned()) + .await + } +} diff --git a/public/migration/src/migrations/m20251223_000012_create_stream_service_access_list_table.rs b/public/migration/src/migrations/m20251223_000012_create_stream_service_access_list_table.rs new file mode 100644 index 0000000..84d8b41 --- /dev/null +++ b/public/migration/src/migrations/m20251223_000012_create_stream_service_access_list_table.rs @@ -0,0 +1,65 @@ +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[forbid(dead_code)] +#[derive(DeriveIden)] +pub enum StreamServiceAccessList { + Table, + Id, + StreamServiceId, + AccessListId, + CreatedAt, +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(StreamServiceAccessList::Table) + .if_not_exists() + .col(pk_uuid(StreamServiceAccessList::Id)) + .col(ColumnDef::new(StreamServiceAccessList::StreamServiceId).uuid().not_null()) + .foreign_key( + ForeignKey::create() + .name("fk-stream-service-access-list-service-id") + .from(StreamServiceAccessList::Table, StreamServiceAccessList::StreamServiceId) + .to( + super::m20251223_000008_create_stream_service_table::StreamService::Table, + super::m20251223_000008_create_stream_service_table::StreamService::Id, + ) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::Cascade), + ) + .col(ColumnDef::new(StreamServiceAccessList::AccessListId).uuid().not_null()) + .foreign_key( + ForeignKey::create() + .name("fk-stream-service-access-list-access-list-id") + .from(StreamServiceAccessList::Table, StreamServiceAccessList::AccessListId) + .to( + super::m20251223_000009_create_access_list_table::AccessList::Table, + super::m20251223_000009_create_access_list_table::AccessList::Id, + ) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::Cascade), + ) + .col( + ColumnDef::new(StreamServiceAccessList::CreatedAt) + .timestamp() + .default(SimpleExpr::Keyword(Keyword::CurrentTimestamp)) + .not_null(), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(StreamServiceAccessList::Table).to_owned()) + .await + } +} -- 2.49.1 From 35fadb46f60c9045297ed812d9110fbf7cd3713c Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Mon, 29 Dec 2025 12:16:09 +0800 Subject: [PATCH 02/43] feat: add pagination helper and integrate serde_urlencoded for query extraction --- Cargo.lock | 1 + apps/api/Cargo.toml | 3 +- apps/api/src/routes/api.rs | 1 + apps/api/src/routes/api/helper.rs | 1 + apps/api/src/routes/api/helper/pagination.rs | 65 ++++++++++++++++++++ 5 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/routes/api/helper.rs create mode 100644 apps/api/src/routes/api/helper/pagination.rs diff --git a/Cargo.lock b/Cargo.lock index 5259b53..a4d3ce4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5446,6 +5446,7 @@ dependencies = [ "sea-orm", "serde", "serde_json", + "serde_urlencoded", "tempfile", "tokio", "tower", diff --git a/apps/api/Cargo.toml b/apps/api/Cargo.toml index 81a05be..4af67d5 100644 --- a/apps/api/Cargo.toml +++ b/apps/api/Cargo.toml @@ -30,9 +30,10 @@ jsonwebtoken = { version = "10.2.0", features = ["rust_crypto"] } uuid = { version = "1.19.0", features = ["v4", "serde", "fast-rng"] } tower-http = { version = "0.6.8", features = ["cors"] } reqwest = { version = "^0.12", features = ["json", "multipart", "stream"] } +serde_urlencoded = { version = "0.7.1" } [dev-dependencies] tempfile = "3" [lints.clippy] -unwrap_used = "deny" \ No newline at end of file +unwrap_used = "deny" diff --git a/apps/api/src/routes/api.rs b/apps/api/src/routes/api.rs index 3546a2b..451722e 100644 --- a/apps/api/src/routes/api.rs +++ b/apps/api/src/routes/api.rs @@ -1,5 +1,6 @@ mod auth; mod health; +mod helper; mod openapi; mod restricted; diff --git a/apps/api/src/routes/api/helper.rs b/apps/api/src/routes/api/helper.rs new file mode 100644 index 0000000..bc8665b --- /dev/null +++ b/apps/api/src/routes/api/helper.rs @@ -0,0 +1 @@ +pub mod pagination; diff --git a/apps/api/src/routes/api/helper/pagination.rs b/apps/api/src/routes/api/helper/pagination.rs new file mode 100644 index 0000000..4228be0 --- /dev/null +++ b/apps/api/src/routes/api/helper/pagination.rs @@ -0,0 +1,65 @@ +use axum::{ + extract::FromRequestParts, + http::{StatusCode, request::Parts}, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, utoipa::ToSchema)] +/// 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, + } + } +} + +#[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 FromRequestParts for ExtractPagination +where + S: Send + Sync, +{ + type Rejection = (StatusCode, &'static str); + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + 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)) + } +} -- 2.49.1 From 7ac336871545279d575305b521e2ccadf20e9f86 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Mon, 29 Dec 2025 15:18:40 +0800 Subject: [PATCH 03/43] feat: added intoResponse --- apps/api/src/errors/service_error.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/api/src/errors/service_error.rs b/apps/api/src/errors/service_error.rs index bd99ad8..f04ea40 100644 --- a/apps/api/src/errors/service_error.rs +++ b/apps/api/src/errors/service_error.rs @@ -1,3 +1,4 @@ +use axum::response::IntoResponse; use sea_orm::DbErr; #[derive(Debug)] @@ -37,3 +38,23 @@ impl From 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() + } +} -- 2.49.1 From 814f76291cfdfaa27abcd540f94016cb8833c820 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Mon, 29 Dec 2025 15:19:06 +0800 Subject: [PATCH 04/43] fix: update test cases to use mock --- apps/api/src/routes/api/health/info.rs | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/apps/api/src/routes/api/health/info.rs b/apps/api/src/routes/api/health/info.rs index f5d2ecf..403ca5e 100644 --- a/apps/api/src/routes/api/health/info.rs +++ b/apps/api/src/routes/api/health/info.rs @@ -80,6 +80,7 @@ pub async fn get_health_info( mod test { use crate::configs::FromConfig; use crate::services::agent_client::AgentService; + use crate::services::get_app_service; use crate::{ routes::{AppState, api::health::state::HealthState}, services::{ @@ -116,18 +117,10 @@ mod test { let app_state = Arc::new(AppState { database_connection: db.clone(), config: Arc::new(crate::configs::ProgramSettings::mock()), - service: Arc::new(crate::routes::AppService { - settings: Arc::new(SettingsService::new(db.clone())), - auth_state: crate::routes::AuthState { - strategy: crate::routes::AuthStrategy { - password: Arc::new(PasswordStrategy::new(db.clone())), - }, - authentication: Arc::new(AuthenticationServiceImpl::new(None)), - }, - user: Arc::new(UserServiceImpl::new(db.clone())), - server_state: Arc::new(ServerStateService::new(db.clone())), - agent_client: Arc::new(AgentService::new(Configuration::default())), - }), + service: Arc::new(get_app_service( + &db.clone(), + &crate::configs::ProgramSettings::mock(), + )), }); let app = Router::new() -- 2.49.1 From 238c3db92bfbc827f6030ef0e78db6a3e66e7121 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Mon, 29 Dec 2025 15:21:02 +0800 Subject: [PATCH 05/43] feat: implement Nginx service with upstream management and configuration generation --- Cargo.lock | 12 + apps/api/Cargo.toml | 1 + apps/api/src/helpers.rs | 1 + apps/api/src/helpers/database.rs | 17 ++ apps/api/src/helpers/macros.rs | 9 + apps/api/src/routes/api/helper/pagination.rs | 13 +- apps/api/src/services.rs | 5 + apps/api/src/services/nginx.rs | 31 +++ apps/api/src/services/nginx/builder.rs | 47 ++++ apps/api/src/services/nginx/info.rs | 2 + apps/api/src/services/nginx/info/upstream.rs | 148 ++++++++++++ .../services/nginx/info/upstream_target.rs | 118 +++++++++ apps/api/src/services/nginx/traits.rs | 1 + .../src/services/nginx/traits/indentable.rs | 31 +++ apps/api/src/services/nginx/upstream.rs | 226 ++++++++++++++++++ 15 files changed, 661 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/helpers/macros.rs create mode 100644 apps/api/src/services/nginx.rs create mode 100644 apps/api/src/services/nginx/builder.rs create mode 100644 apps/api/src/services/nginx/info.rs create mode 100644 apps/api/src/services/nginx/info/upstream.rs create mode 100644 apps/api/src/services/nginx/info/upstream_target.rs create mode 100644 apps/api/src/services/nginx/traits.rs create mode 100644 apps/api/src/services/nginx/traits/indentable.rs create mode 100644 apps/api/src/services/nginx/upstream.rs diff --git a/Cargo.lock b/Cargo.lock index a4d3ce4..8b78390 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2377,6 +2377,17 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "optfield" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "969ccca8ffc4fb105bd131a228107d5c9dd89d9d627edf3295cbe979156f9712" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "ordered-float" version = "4.6.0" @@ -5442,6 +5453,7 @@ dependencies = [ "migration", "mime_guess", "once_cell", + "optfield", "reqwest", "sea-orm", "serde", diff --git a/apps/api/Cargo.toml b/apps/api/Cargo.toml index 4af67d5..0a3b4df 100644 --- a/apps/api/Cargo.toml +++ b/apps/api/Cargo.toml @@ -31,6 +31,7 @@ uuid = { version = "1.19.0", features = ["v4", "serde", "fast-rng"] } tower-http = { version = "0.6.8", features = ["cors"] } reqwest = { version = "^0.12", features = ["json", "multipart", "stream"] } serde_urlencoded = { version = "0.7.1" } +optfield = { version = "0.4.0" } [dev-dependencies] tempfile = "3" diff --git a/apps/api/src/helpers.rs b/apps/api/src/helpers.rs index 6fbb533..4cf5a18 100644 --- a/apps/api/src/helpers.rs +++ b/apps/api/src/helpers.rs @@ -1,2 +1,3 @@ pub mod constants; pub mod database; +pub mod macros; diff --git a/apps/api/src/helpers/database.rs b/apps/api/src/helpers/database.rs index da8f12c..757121c 100644 --- a/apps/api/src/helpers/database.rs +++ b/apps/api/src/helpers/database.rs @@ -11,3 +11,20 @@ macro_rules! with_conn { } }}; } + +pub struct Filters { + pub pagination: Option, +} + +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) + } +} diff --git a/apps/api/src/helpers/macros.rs b/apps/api/src/helpers/macros.rs new file mode 100644 index 0000000..22bf75c --- /dev/null +++ b/apps/api/src/helpers/macros.rs @@ -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, + } + }; +} diff --git a/apps/api/src/routes/api/helper/pagination.rs b/apps/api/src/routes/api/helper/pagination.rs index 4228be0..8fca585 100644 --- a/apps/api/src/routes/api/helper/pagination.rs +++ b/apps/api/src/routes/api/helper/pagination.rs @@ -4,7 +4,9 @@ use axum::{ }; use serde::{Deserialize, Serialize}; -#[derive(Serialize, Deserialize, utoipa::ToSchema)] +use crate::helpers::database::PaginationFilter; + +#[derive(Serialize, Deserialize, utoipa::ToSchema, Clone)] /// Pagination parameters for API requests pub struct Pagination { /// Page number (1-based) @@ -22,6 +24,15 @@ impl Default for Pagination { } } +impl From 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 { diff --git a/apps/api/src/services.rs b/apps/api/src/services.rs index 8960c58..aa61504 100644 --- a/apps/api/src/services.rs +++ b/apps/api/src/services.rs @@ -1,5 +1,6 @@ pub mod agent_client; pub mod auth; +pub mod nginx; pub mod server_state; pub mod settings; @@ -15,6 +16,7 @@ use crate::{ authentication::{AuthenticationServiceImpl, strategies::password::PasswordStrategy}, user::{UserService, UserServiceImpl}, }, + nginx::NginxService, server_state::{ServerStateService, ServerStateStore}, settings::{SettingsService, SettingsStore}, }, @@ -28,6 +30,8 @@ pub struct AppService { pub user: ServiceState, pub server_state: ServiceState, #[allow(dead_code)] + pub nginx: ServiceState, + #[allow(dead_code)] pub agent_client: ServiceState, } @@ -47,6 +51,7 @@ pub fn get_app_service( )), }, user: Arc::new(UserServiceImpl::new(db_connection.clone())), + nginx: Arc::new(NginxService::new(db_connection.clone())), agent_client: Arc::new(agent_client::AgentService::new(Configuration::from( settings.agent.clone(), ))), diff --git a/apps/api/src/services/nginx.rs b/apps/api/src/services/nginx.rs new file mode 100644 index 0000000..383c4cc --- /dev/null +++ b/apps/api/src/services/nginx.rs @@ -0,0 +1,31 @@ +pub mod builder; +pub mod info; +pub mod traits; + +pub mod upstream; + +use std::sync::Arc; + +use sea_orm::DatabaseConnection; + +use upstream::UpstreamService; + +pub struct NginxService { + connection: Arc, + // + upstream_service: Arc, +} + +impl NginxService { + pub fn new(connection: Arc) -> Self { + Self { + connection: connection.clone(), + // + upstream_service: Arc::new(UpstreamService::new(connection.clone())), + } + } + + pub fn get_upstream_service(&self) -> Arc { + self.upstream_service.clone() + } +} diff --git a/apps/api/src/services/nginx/builder.rs b/apps/api/src/services/nginx/builder.rs new file mode 100644 index 0000000..972d0b7 --- /dev/null +++ b/apps/api/src/services/nginx/builder.rs @@ -0,0 +1,47 @@ +use crate::services::nginx::info::upstream::UpstreamInfo; + +pub const INDENT_SIZE: usize = 2; + +pub trait NginxConfigProvider { + fn to_nginx_config(&self, indent: Option) -> String; +} + +pub struct NginxConfigBuilder { + upstreams: Vec, +} + +impl NginxConfigBuilder { + pub fn new() -> Self { + Self { + upstreams: Vec::new(), + } + } + + pub fn add_upstream(&mut self, upstream: UpstreamInfo) { + self.upstreams.push(upstream); + } + + pub fn add_upstreams(&mut self, upstreams: Vec) { + for upstream in upstreams { + self.add_upstream(upstream); + } + } +} + +impl NginxConfigProvider for NginxConfigBuilder { + fn to_nginx_config(&self, indent: Option) -> String { + let mut config = format!( + "# Nginx Config Generated by YANPM at {}", + chrono::Utc::now() + ); + + for upstream in &self.upstreams { + config.push('\n'); + config.push_str(&upstream.to_nginx_config(indent)); + } + + // TODO: Add other sections like servers, locations, etc. + + config + } +} diff --git a/apps/api/src/services/nginx/info.rs b/apps/api/src/services/nginx/info.rs new file mode 100644 index 0000000..b74b8b1 --- /dev/null +++ b/apps/api/src/services/nginx/info.rs @@ -0,0 +1,2 @@ +pub mod upstream; +pub mod upstream_target; diff --git a/apps/api/src/services/nginx/info/upstream.rs b/apps/api/src/services/nginx/info/upstream.rs new file mode 100644 index 0000000..1756e6d --- /dev/null +++ b/apps/api/src/services/nginx/info/upstream.rs @@ -0,0 +1,148 @@ +use chrono::{DateTime, Utc}; +use optfield::optfield; + +use database::generated::entities::{upstream, upstream_target}; +use uuid::Uuid; + +use crate::{ + services::nginx::{ + builder::{INDENT_SIZE, NginxConfigProvider}, + info::upstream_target as upstream_target_info, + traits::indentable::Indentable, + }, + set_if_some, +}; + +#[optfield(pub UpdateUpstreamInfo)] +#[derive(Clone)] +pub struct UpstreamInfo { + pub id: uuid::Uuid, + pub name: String, + pub protocol: String, + pub algorithm: String, + pub sticky_session: bool, + pub created_by: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + // + pub upstream_targets: Vec, +} + +pub struct UpstreamCreateInfo { + pub name: String, + pub protocol: String, + pub algorithm: String, + pub sticky_session: bool, + pub created_by: Option, + // + pub upstream_targets: Vec, +} + +impl NginxConfigProvider for UpstreamInfo { + fn to_nginx_config(&self, indent: Option) -> String { + let targets_config: Vec = self + .upstream_targets + .iter() + .map(|target| target.to_nginx_config(Some(indent.unwrap_or(0) + INDENT_SIZE))) + .collect(); + + format!( + "upstream {} {{\n{}\n}}", + self.name, + targets_config.join("\n".indent(indent.unwrap_or(0) + INDENT_SIZE).as_str()) + ) + .indent(indent.unwrap_or(0)) + } +} + +impl From for upstream::ActiveModel { + fn from(val: UpstreamCreateInfo) -> Self { + upstream::ActiveModel { + id: sea_orm::ActiveValue::Set(Uuid::new_v4()), + 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()), + } + } +} + +impl From for UpstreamInfo { + fn from(model: upstream::Model) -> Self { + Self { + id: model.id, + name: model.name, + protocol: model.protocol, + algorithm: model.algorithm, + sticky_session: model.sticky_session, + created_by: model.created_by, + created_at: model.created_at, + updated_at: model.updated_at, + upstream_targets: Vec::new(), + } + } +} + +impl From<(upstream::Model, Vec)> for UpstreamInfo { + fn from(data: (upstream::Model, Vec)) -> 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 for (upstream::ActiveModel, Vec) { + fn from(val: UpstreamInfo) -> Self { + ( + upstream::ActiveModel { + id: sea_orm::ActiveValue::Set(val.id), + name: sea_orm::ActiveValue::Set(val.name), + protocol: sea_orm::ActiveValue::Set(val.protocol), + algorithm: sea_orm::ActiveValue::Set(val.algorithm), + sticky_session: sea_orm::ActiveValue::Set(val.sticky_session), + created_by: sea_orm::ActiveValue::Set(val.created_by), + created_at: sea_orm::ActiveValue::Set(val.created_at), + updated_at: sea_orm::ActiveValue::Set(val.updated_at), + }, + val.upstream_targets + .into_iter() + .map(|target| target.into()) + .collect(), + ) + } +} + +impl UpdateUpstreamInfo { + pub fn apply_to_model(self, current_model: upstream::Model) -> upstream::ActiveModel { + upstream::ActiveModel { + id: sea_orm::ActiveValue::Unchanged(current_model.id), + name: set_if_some!(self.name), + protocol: set_if_some!(self.protocol), + algorithm: set_if_some!(self.algorithm), + sticky_session: set_if_some!(self.sticky_session), + created_by: set_if_some!(if self.created_by.is_some() { + Some(self.created_by) + } else { + None + }), + created_at: set_if_some!(self.created_at), + updated_at: set_if_some!(self.updated_at), + } + } +} diff --git a/apps/api/src/services/nginx/info/upstream_target.rs b/apps/api/src/services/nginx/info/upstream_target.rs new file mode 100644 index 0000000..93585f5 --- /dev/null +++ b/apps/api/src/services/nginx/info/upstream_target.rs @@ -0,0 +1,118 @@ +use chrono::{DateTime, Utc}; +use optfield::optfield; + +use sea_orm::ActiveValue::{Set, Unchanged}; +use uuid::Uuid; + +use database::generated::entities::upstream_target; + +use crate::{ + services::nginx::{builder::NginxConfigProvider, traits::indentable::Indentable}, + set_if_some, +}; + +#[optfield(pub UpdateUpstreamTargetInfo)] +#[derive(Clone)] +pub struct UpstreamTargetInfo { + pub id: uuid::Uuid, + pub target_host: String, + pub target_port: i64, + pub weight: i64, + pub is_backup: bool, + pub enabled: bool, + pub created_at: DateTime, + pub updated_at: DateTime, + // + pub upstream_id: uuid::Uuid, +} + +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 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, + } + } +} + +impl From 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 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) -> String { + format!( + "{}:{} weight={}{}{}", + self.target_host, + self.target_port, + self.weight, + if self.is_backup { " backup" } else { "" }, + if !self.enabled { " down" } else { "" }, + ) + .indent(indent.unwrap_or(0)) + } +} + +impl UpdateUpstreamTargetInfo { + pub fn apply_to_model( + self, + current_model: upstream_target::Model, + ) -> upstream_target::ActiveModel { + upstream_target::ActiveModel { + id: Unchanged(current_model.id), + target_host: set_if_some!(self.target_host), + target_port: set_if_some!(self.target_port), + weight: set_if_some!(self.weight), + is_backup: set_if_some!(self.is_backup), + enabled: set_if_some!(self.enabled), + created_at: set_if_some!(self.created_at), + updated_at: set_if_some!(self.updated_at), + upstream_id: set_if_some!(self.upstream_id), + } + } +} diff --git a/apps/api/src/services/nginx/traits.rs b/apps/api/src/services/nginx/traits.rs new file mode 100644 index 0000000..66ccbfd --- /dev/null +++ b/apps/api/src/services/nginx/traits.rs @@ -0,0 +1 @@ +pub mod indentable; diff --git a/apps/api/src/services/nginx/traits/indentable.rs b/apps/api/src/services/nginx/traits/indentable.rs new file mode 100644 index 0000000..d8113b5 --- /dev/null +++ b/apps/api/src/services/nginx/traits/indentable.rs @@ -0,0 +1,31 @@ +pub trait Indentable { + fn indent(&self, spaces: T) -> String; +} + +impl Indentable for &str { + fn indent(&self, spaces: usize) -> String { + let indent_str = " ".repeat(spaces); + self.lines() + .map(|line| format!("{}{}", indent_str, line)) + .collect::>() + .join("\n") + } +} + +impl Indentable> for String { + fn indent(&self, spaces: Option) -> String { + self.as_str().indent(spaces.unwrap_or(0)) + } +} + +impl Indentable for String { + fn indent(&self, spaces: usize) -> String { + self.as_str().indent(spaces) + } +} + +impl Indentable> for &str { + fn indent(&self, spaces: Option) -> String { + self.indent(spaces.unwrap_or(0)) + } +} diff --git a/apps/api/src/services/nginx/upstream.rs b/apps/api/src/services/nginx/upstream.rs new file mode 100644 index 0000000..335be4f --- /dev/null +++ b/apps/api/src/services/nginx/upstream.rs @@ -0,0 +1,226 @@ +use std::{option, sync::Arc}; + +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DatabaseConnection, DatabaseTransaction, EntityTrait, + ModelTrait, QueryFilter, QuerySelect, +}; + +use database::generated::entities::{upstream, upstream_target}; + +use crate::{ + errors::service_error::ServiceError, + helpers::database::PaginationFilter, + services::nginx::info::{ + upstream::{UpdateUpstreamInfo, UpstreamCreateInfo, UpstreamInfo}, + upstream_target::{UpdateUpstreamTargetInfo, UpstreamTargetCreateInfo, UpstreamTargetInfo}, + }, + with_conn, +}; + +pub struct UpstreamService { + connection: Arc, +} + +#[derive(Default)] +pub struct GetUpstreamOptions { + pub include_targets: bool, +} + +impl UpstreamService { + pub fn new(connection: Arc) -> Self { + Self { connection } + } + // + // + pub async fn create_upstream( + &self, + create_info: UpstreamCreateInfo, + tx: Option<&mut DatabaseTransaction>, + ) -> Result { + let model: upstream::ActiveModel = create_info.into(); + let r = with_conn!(&*self.connection, tx, conn, { model.insert(*conn).await? }); + Ok(r.into()) + } + + pub async fn get_upstream( + &self, + upstream_id: uuid::Uuid, + options: Option, + tx: Option<&mut DatabaseTransaction>, + ) -> Result { + let concrete_options = options.unwrap_or_default(); + let info: UpstreamInfo = if concrete_options.include_targets { + let (up_model, targets) = with_conn!(&*self.connection, tx, conn, { + let up = upstream::Entity::find_by_id(upstream_id) + .one(*conn) + .await? + .ok_or(ServiceError::NotFound(format!( + "Upstream with id {} not found", + upstream_id + )))?; + let targets = upstream_target::Entity::find() + .filter(upstream_target::Column::UpstreamId.eq(upstream_id)) + .all(*conn) + .await?; + (up, targets) + }); + (up_model, targets).into() + } else { + with_conn!(&*self.connection, tx, conn, { + upstream::Entity::find_by_id(upstream_id) + .one(*conn) + .await? + .ok_or(ServiceError::NotFound(format!( + "Upstream with id {} not found", + upstream_id + )))? + }) + .into() + }; + Ok(info) + } + + pub async fn get_upstreams( + &self, + pagination: Option, + tx: Option<&mut DatabaseTransaction>, + ) -> Result, ServiceError> { + let r = with_conn!(&*self.connection, tx, conn, { + let find_query = upstream::Entity::find(); + let find_query = if let Some(pagination) = pagination { + let (offset, limit) = pagination.get_offset_limit(); + find_query.offset(offset).limit(limit) + } else { + find_query + }; + find_query.all(*conn).await? + }); + + Ok(r.into_iter().map(|m| m.into()).collect()) + } + + pub async fn update_upstream( + &self, + id: uuid::Uuid, + upstream: UpdateUpstreamInfo, + tx: Option<&mut DatabaseTransaction>, + ) -> Result { + let current_model = with_conn!(&*self.connection, tx, conn, { + upstream::Entity::find_by_id(id) + .one(*conn) + .await? + .ok_or(ServiceError::NotFound(format!( + "Upstream with id {} not found", + id + )))? + }); + let active_model = upstream.apply_to_model(current_model); + + let r = active_model.update(&*self.connection).await?; + Ok(r.into()) + } + + pub async fn delete_upstream( + &self, + upstream_id: uuid::Uuid, + tx: Option<&mut DatabaseTransaction>, + ) -> Result<(), ServiceError> { + let model = with_conn!(&*self.connection, tx, conn, { + upstream::Entity::find_by_id(upstream_id) + .one(*conn) + .await? + .ok_or(ServiceError::NotFound(format!( + "Upstream with id {} not found", + upstream_id + )))? + }); + with_conn!(&*self.connection, tx, conn, { + model.delete(*conn).await?; + Ok(()) + }) + } + + // + // + pub async fn create_upstream_target( + &self, + create_info: UpstreamTargetCreateInfo, + tx: Option<&mut DatabaseTransaction>, + ) -> Result { + let model: upstream_target::ActiveModel = create_info.into(); + let r = with_conn!(&*self.connection, tx, conn, { model.insert(*conn).await? }); + Ok(r.into()) + } + + pub async fn get_upstream_target( + &self, + target_id: uuid::Uuid, + tx: Option<&mut DatabaseTransaction>, + ) -> Result { + let r = 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 + )))? + }); + Ok(r.into()) + } + + pub async fn get_upstream_targets_by_upstream( + &self, + upstream_id: uuid::Uuid, + tx: Option<&mut DatabaseTransaction>, + ) -> Result, ServiceError> { + let r = with_conn!(&*self.connection, tx, conn, { + upstream_target::Entity::find() + .filter(upstream_target::Column::UpstreamId.eq(upstream_id)) + .all(*conn) + .await? + }); + Ok(r.into_iter().map(|m| m.into()).collect()) + } + + pub async fn update_upstream_target( + &self, + id: uuid::Uuid, + target: UpdateUpstreamTargetInfo, + tx: Option<&mut DatabaseTransaction>, + ) -> Result { + let current_model = with_conn!(&*self.connection, tx, conn, { + upstream_target::Entity::find_by_id(id) + .one(*conn) + .await? + .ok_or(ServiceError::NotFound(format!( + "Upstream target with id {} not found", + id + )))? + }); + let active_model = target.apply_to_model(current_model); + + let r = active_model.update(&*self.connection).await?; + Ok(r.into()) + } + + pub async fn delete_upstream_target( + &self, + target_id: uuid::Uuid, + tx: Option<&mut DatabaseTransaction>, + ) -> Result<(), ServiceError> { + let model = with_conn!(&*self.connection, tx, conn, { + upstream_target::Entity::find_by_id(target_id) + .one(*conn) + .await? + .ok_or(ServiceError::NotFound(format!( + "Upstream target with id {} not found", + target_id + )))? + }); + with_conn!(&*self.connection, tx, conn, { + model.delete(*conn).await?; + Ok(()) + }) + } +} -- 2.49.1 From f8b13be650faf3f91a569b6f120b1a8727978aed Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Mon, 29 Dec 2025 15:21:40 +0800 Subject: [PATCH 06/43] feat: add Nginx upstream read management routes and handlers --- apps/api/src/routes/api/restricted.rs | 2 + apps/api/src/routes/api/restricted/nginx.rs | 11 ++ .../routes/api/restricted/nginx/upstream.rs | 14 ++ .../restricted/nginx/upstream/get_upstream.rs | 155 ++++++++++++++++++ 4 files changed, 182 insertions(+) create mode 100644 apps/api/src/routes/api/restricted/nginx.rs create mode 100644 apps/api/src/routes/api/restricted/nginx/upstream.rs create mode 100644 apps/api/src/routes/api/restricted/nginx/upstream/get_upstream.rs diff --git a/apps/api/src/routes/api/restricted.rs b/apps/api/src/routes/api/restricted.rs index 17be841..023d8f3 100644 --- a/apps/api/src/routes/api/restricted.rs +++ b/apps/api/src/routes/api/restricted.rs @@ -1,3 +1,4 @@ +pub mod nginx; pub mod user; use std::sync::Arc; @@ -9,6 +10,7 @@ use crate::{middlewares::require_auth::require_auth, routes::AppState}; pub fn get_restricted_router(state: Arc) -> Router { Router::new() .nest("/user", user::get_user_router(state.clone())) + .nest("/nginx", nginx::get_nginx_router(state.clone())) .layer(axum::middleware::from_fn_with_state( state.clone(), require_auth, diff --git a/apps/api/src/routes/api/restricted/nginx.rs b/apps/api/src/routes/api/restricted/nginx.rs new file mode 100644 index 0000000..9f66b9d --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx.rs @@ -0,0 +1,11 @@ +pub mod upstream; + +use std::sync::Arc; + +use axum::Router; + +use crate::routes::AppState; + +pub fn get_nginx_router(state: Arc) -> Router { + Router::new().nest("/upstream", upstream::get_upstream_router(state.clone())) +} diff --git a/apps/api/src/routes/api/restricted/nginx/upstream.rs b/apps/api/src/routes/api/restricted/nginx/upstream.rs new file mode 100644 index 0000000..1b1f797 --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/upstream.rs @@ -0,0 +1,14 @@ +pub mod get_upstream; + +use std::sync::Arc; + +use axum::{Router, routing::get}; + +use crate::routes::AppState; + +pub fn get_upstream_router(state: Arc) -> Router { + Router::new() + .route("upstreams", get(get_upstream::get_upstream_list)) + .route("upstreams/{upstream_id}", get(get_upstream::get_upstream)) + .with_state(state) +} diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream.rs b/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream.rs new file mode 100644 index 0000000..6a475f7 --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream.rs @@ -0,0 +1,155 @@ +use std::sync::Arc; + +use axum::{ + Json, + extract::{Path, Query, State}, + response::Result as AxumResult, +}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + errors::service_error::ServiceError, + routes::{ + AppState, + api::helper::pagination::{ExtractPagination, PaginationInfo}, + }, + services::nginx::upstream::GetUpstreamOptions, +}; + +#[derive(Serialize, Deserialize, utoipa::ToSchema)] +pub struct GetUpstreamParams { + pub include_targets: Option, +} + +pub struct ConcreteGetUpstreamParams { + pub include_targets: bool, +} + +impl From for ConcreteGetUpstreamParams { + fn from(params: GetUpstreamParams) -> Self { + Self { + include_targets: params.include_targets.unwrap_or(false), + } + } +} + +#[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, + pub updated_at: DateTime, +} + +impl From + 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, + pub created_at: DateTime, + pub updated_at: DateTime, + // + pub upstream_targets: Vec, +} + +impl From 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, + pub pagination: PaginationInfo, +} + +pub async fn get_upstream_list( + ExtractPagination(pagination): ExtractPagination, + State(state): State>, +) -> AxumResult, ServiceError> { + let upstream_service = &state.service.nginx.get_upstream_service(); + let upstreams = upstream_service + .get_upstreams(Some(pagination.clone().into()), None) + .await?; + + // + Ok(Json(UpstreamListResponse { + items: upstreams.into_iter().map(|u| u.into()).collect(), + pagination: PaginationInfo { + total_items: 0, + total_pages: 0, + current_page: pagination.page, + per_page: pagination.per_page, + }, + })) +} + +pub async fn get_upstream( + Path(upstream_id): Path, + Query(params): Query, + State(_state): State>, +) -> AxumResult, ServiceError> { + let concrete_params: ConcreteGetUpstreamParams = params.into(); + let upstream_service = &_state.service.nginx.get_upstream_service(); + let upstream_info = if concrete_params.include_targets { + upstream_service + .get_upstream( + upstream_id, + Some(GetUpstreamOptions { + include_targets: true, + }), + None, + ) + .await? + } else { + upstream_service + .get_upstream(upstream_id, None, None) + .await? + }; + + // + Ok(Json(upstream_info.into())) +} -- 2.49.1 From a0621e24075596affdb220339ef22d6662839fc8 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Mon, 29 Dec 2025 16:37:57 +0800 Subject: [PATCH 07/43] test: added test cases for upstream service --- apps/api/src/services/nginx/upstream.rs | 461 +++++++++++++++++++++++- 1 file changed, 450 insertions(+), 11 deletions(-) diff --git a/apps/api/src/services/nginx/upstream.rs b/apps/api/src/services/nginx/upstream.rs index 335be4f..a3d2752 100644 --- a/apps/api/src/services/nginx/upstream.rs +++ b/apps/api/src/services/nginx/upstream.rs @@ -1,4 +1,4 @@ -use std::{option, sync::Arc}; +use std::sync::Arc; use sea_orm::{ ActiveModelTrait, ColumnTrait, DatabaseConnection, DatabaseTransaction, EntityTrait, @@ -26,6 +26,11 @@ pub struct GetUpstreamOptions { pub include_targets: bool, } +#[derive(Default)] +pub struct GetUpstreamTargetOptions { + pub include_upstream: bool, +} + impl UpstreamService { pub fn new(connection: Arc) -> Self { Self { connection } @@ -155,18 +160,44 @@ impl UpstreamService { pub async fn get_upstream_target( &self, target_id: uuid::Uuid, + options: Option, tx: Option<&mut DatabaseTransaction>, ) -> Result { - let r = 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 - )))? - }); - Ok(r.into()) + let concrete_options = options.unwrap_or_default(); + let info: UpstreamTargetInfo = if concrete_options.include_upstream { + match with_conn!(&*self.connection, tx, conn, { + upstream_target::Entity::find_by_id(target_id) + .find_also_related(upstream::Entity) + .one(*conn) + .await? + }) { + Some((target_model, Some(upstream_model))) => (target_model, upstream_model).into(), + Some((_target_model, None)) => { + return Err(ServiceError::InternalError(format!( + "Inconsistent data: Upstream target with id {} has no associated upstream", + target_id + ))); + } + None => { + return Err(ServiceError::NotFound(format!( + "Upstream target with id {} not found", + target_id + ))); + } + } + } else { + with_conn!(&*self.connection, tx, conn, { + upstream_target::Entity::find_by_id(target_id) + .one(*conn) + .await? + .ok_or(ServiceError::NotFound(format!( + "Upstream target with id {} not found", + target_id + )))? + }) + .into() + }; + Ok(info) } pub async fn get_upstream_targets_by_upstream( @@ -224,3 +255,411 @@ impl UpstreamService { }) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + use sea_orm::MockExecResult; + use sea_orm::{DatabaseBackend, MockDatabase}; + + use database::generated::entities::{upstream, upstream_target}; + + #[tokio::test] + async fn create_upstream_returns_info() { + let up_model = upstream::Model { + id: uuid::Uuid::new_v4(), + name: "test_upstream".to_string(), + protocol: "http".to_string(), + algorithm: "round_robin".to_string(), + sticky_session: false, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![vec![up_model.clone()]]) + .into_connection(); + + let svc = UpstreamService::new(Arc::new(db)); + + let create_info = crate::services::nginx::info::upstream::UpstreamCreateInfo { + name: "test_upstream".to_string(), + protocol: "http".to_string(), + algorithm: "round_robin".to_string(), + sticky_session: false, + created_by: None, + upstream_targets: Vec::new(), + }; + + let res = svc.create_upstream(create_info, None).await; + assert!(res.is_ok()); + let info = res.expect("Failed to create upstream"); + assert_eq!(info.name, "test_upstream"); + } + + #[tokio::test] + async fn get_upstream_with_targets_returns_targets() { + let up_id = uuid::Uuid::new_v4(); + + let up_model = upstream::Model { + id: up_id, + name: "with_targets".to_string(), + protocol: "http".to_string(), + algorithm: "least_conn".to_string(), + sticky_session: true, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let target_model = upstream_target::Model { + id: uuid::Uuid::new_v4(), + upstream_id: up_id, + target_host: "127.0.0.1".to_string(), + target_port: 8080, + weight: 1, + is_backup: false, + enabled: true, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + // find_by_id -> returns upstream model + .append_query_results(vec![vec![up_model.clone()]]) + // find targets -> returns the target(s) + .append_query_results(vec![vec![target_model.clone()]]) + .into_connection(); + + let svc = UpstreamService::new(Arc::new(db)); + + let res = svc + .get_upstream( + up_id, + Some(GetUpstreamOptions { + include_targets: true, + }), + None, + ) + .await; + + assert!(res.is_ok()); + let info = res.expect("Failed to get upstream with targets"); + assert_eq!(info.id, up_id); + assert_eq!(info.upstream_targets.len(), 1); + assert_eq!(info.upstream_targets[0].target_host, "127.0.0.1"); + } + + #[tokio::test] + async fn get_upstream_not_found_returns_not_found() { + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![Vec::::new()]) + .into_connection(); + + let svc = UpstreamService::new(Arc::new(db)); + + let res = svc.get_upstream(uuid::Uuid::new_v4(), None, None).await; + + assert!(matches!(res, Err(ServiceError::NotFound(_)))); + } + + #[tokio::test] + async fn get_upstreams_returns_list() { + let u1 = upstream::Model { + id: uuid::Uuid::new_v4(), + name: "u1".to_string(), + protocol: "http".to_string(), + algorithm: "rr".to_string(), + sticky_session: false, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + let u2 = upstream::Model { + id: uuid::Uuid::new_v4(), + name: "u2".to_string(), + protocol: "http".to_string(), + algorithm: "rr".to_string(), + sticky_session: false, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![vec![u1.clone(), u2.clone()]]) + .into_connection(); + + let svc = UpstreamService::new(Arc::new(db)); + + let res = svc.get_upstreams(None, None).await; + assert!(res.is_ok()); + let list = res.expect("Failed to get upstreams"); + assert_eq!(list.len(), 2); + } + + #[tokio::test] + async fn get_upstream_targets_by_upstream_returns_targets() { + let up_id = uuid::Uuid::new_v4(); + + let t = upstream_target::Model { + id: uuid::Uuid::new_v4(), + upstream_id: up_id, + target_host: "10.0.0.1".to_string(), + target_port: 80, + weight: 10, + is_backup: false, + enabled: true, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![vec![t.clone()]]) + .into_connection(); + + let svc = UpstreamService::new(Arc::new(db)); + + let res = svc.get_upstream_targets_by_upstream(up_id, None).await; + assert!(res.is_ok()); + let targets = res.expect("Failed to get upstream targets"); + assert_eq!(targets.len(), 1); + assert_eq!(targets[0].target_host, "10.0.0.1"); + } + + #[tokio::test] + async fn update_upstream_success() { + let id = uuid::Uuid::new_v4(); + let existing = upstream::Model { + id, + name: "old".to_string(), + protocol: "http".to_string(), + algorithm: "rr".to_string(), + sticky_session: false, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + let updated = upstream::Model { + id, + name: "new".to_string(), + protocol: "http".to_string(), + algorithm: "rr".to_string(), + sticky_session: false, + created_by: None, + created_at: existing.created_at, + updated_at: chrono::Utc::now(), + }; + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![vec![existing.clone()]]) // find_by_id + .append_query_results(vec![vec![updated.clone()]]) // update result + .into_connection(); + + let svc = UpstreamService::new(Arc::new(db)); + + let update_info = crate::services::nginx::info::upstream::UpdateUpstreamInfo { + id: None, + name: None, + protocol: None, + algorithm: None, + sticky_session: None, + created_by: None, + created_at: None, + updated_at: None, + upstream_targets: None, + }; + let res = svc.update_upstream(id, update_info, None).await; + assert!(res.is_ok()); + let got = res.expect("Failed to update upstream"); + assert_eq!(got.name, "new"); + } + + #[tokio::test] + async fn update_upstream_not_found() { + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![Vec::::new()]) + .into_connection(); + + let svc = UpstreamService::new(Arc::new(db)); + + let res = svc + .update_upstream( + uuid::Uuid::new_v4(), + crate::services::nginx::info::upstream::UpdateUpstreamInfo { + id: None, + name: None, + protocol: None, + algorithm: None, + sticky_session: None, + created_by: None, + created_at: None, + updated_at: None, + upstream_targets: None, + }, + None, + ) + .await; + + assert!(matches!(res, Err(ServiceError::NotFound(_)))); + } + + #[tokio::test] + async fn delete_upstream_success() { + let id = uuid::Uuid::new_v4(); + let existing = upstream::Model { + id, + name: "todelete".to_string(), + protocol: "http".to_string(), + algorithm: "rr".to_string(), + sticky_session: false, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![vec![existing.clone()]]) + .append_exec_results(vec![MockExecResult { + rows_affected: 1, + last_insert_id: 0, + }]) + .into_connection(); + + let svc = UpstreamService::new(Arc::new(db)); + + let res = svc.delete_upstream(id, None).await; + assert!(res.is_ok()); + } + + #[tokio::test] + async fn delete_upstream_not_found() { + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![Vec::::new()]) + .into_connection(); + + let svc = UpstreamService::new(Arc::new(db)); + + let res = svc.delete_upstream(uuid::Uuid::new_v4(), None).await; + assert!(matches!(res, Err(ServiceError::NotFound(_)))); + } + + #[tokio::test] + async fn create_upstream_target_success() { + let id = uuid::Uuid::new_v4(); + let upstream_id = uuid::Uuid::new_v4(); + let created = upstream_target::Model { + id, + upstream_id, + target_host: "1.2.3.4".to_string(), + target_port: 8080, + weight: 5, + is_backup: false, + enabled: true, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![vec![created.clone()]]) + .into_connection(); + + let svc = UpstreamService::new(Arc::new(db)); + + let create_info = crate::services::nginx::info::upstream_target::UpstreamTargetCreateInfo { + target_host: "1.2.3.4".to_string(), + target_port: 8080, + weight: 5, + is_backup: false, + enabled: true, + upstream_id, + }; + + let res = svc.create_upstream_target(create_info, None).await; + assert!(res.is_ok()); + let t = res.expect("Failed to create target"); + assert_eq!(t.target_host, "1.2.3.4"); + } + + #[tokio::test] + async fn update_upstream_target_success() { + let id = uuid::Uuid::new_v4(); + let existing = upstream_target::Model { + id, + upstream_id: uuid::Uuid::new_v4(), + target_host: "old".to_string(), + target_port: 80, + weight: 1, + is_backup: false, + enabled: true, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + let updated = upstream_target::Model { + id, + upstream_id: existing.upstream_id, + target_host: "new".to_string(), + target_port: 80, + weight: 1, + is_backup: false, + enabled: true, + created_at: existing.created_at, + updated_at: chrono::Utc::now(), + }; + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![vec![existing.clone()]]) + .append_query_results(vec![vec![updated.clone()]]) + .into_connection(); + + let svc = UpstreamService::new(Arc::new(db)); + + let update_info = crate::services::nginx::info::upstream_target::UpdateUpstreamTargetInfo { + id: None, + target_host: None, + target_port: None, + weight: None, + is_backup: None, + enabled: None, + created_at: None, + updated_at: None, + upstream_id: None, + upstream: None, + }; + let res = svc.update_upstream_target(id, update_info, None).await; + assert!(res.is_ok()); + let got = res.expect("Failed to update target"); + assert_eq!(got.target_host, "new"); + } + + #[tokio::test] + async fn delete_upstream_target_success() { + let id = uuid::Uuid::new_v4(); + let existing = upstream_target::Model { + id, + upstream_id: uuid::Uuid::new_v4(), + target_host: "del".to_string(), + target_port: 80, + weight: 1, + is_backup: false, + enabled: true, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![vec![existing.clone()]]) + .append_exec_results(vec![MockExecResult { + rows_affected: 1, + last_insert_id: 0, + }]) + .into_connection(); + + let svc = UpstreamService::new(Arc::new(db)); + let res = svc.delete_upstream_target(id, None).await; + assert!(res.is_ok()); + } +} -- 2.49.1 From 921165476c45ec1b02b18392fb8933b99c5409ed Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Mon, 29 Dec 2025 16:38:33 +0800 Subject: [PATCH 08/43] feat: enhance UpstreamTargetInfo with upstream details and refactor imports --- .../services/nginx/info/upstream_target.rs | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/apps/api/src/services/nginx/info/upstream_target.rs b/apps/api/src/services/nginx/info/upstream_target.rs index 93585f5..3331098 100644 --- a/apps/api/src/services/nginx/info/upstream_target.rs +++ b/apps/api/src/services/nginx/info/upstream_target.rs @@ -4,7 +4,7 @@ use optfield::optfield; use sea_orm::ActiveValue::{Set, Unchanged}; use uuid::Uuid; -use database::generated::entities::upstream_target; +use database::generated::entities::{upstream, upstream_target}; use crate::{ services::nginx::{builder::NginxConfigProvider, traits::indentable::Indentable}, @@ -24,6 +24,17 @@ pub struct UpstreamTargetInfo { pub updated_at: DateTime, // pub upstream_id: uuid::Uuid, + pub upstream: Option, +} + +#[derive(Clone)] +pub struct UpstreamBasicInfo { + pub id: uuid::Uuid, + pub name: String, + pub protocol: String, + // + pub created_at: DateTime, + pub updated_at: DateTime, } pub struct UpstreamTargetCreateInfo { @@ -48,6 +59,31 @@ impl From for UpstreamTargetInfo { 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, + }), } } } -- 2.49.1 From 7d83838da36bf0c77f529da7af715e015b57045e Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Mon, 29 Dec 2025 16:38:43 +0800 Subject: [PATCH 09/43] feat: add get_upstream_target endpoint and update router for upstream targets --- .../routes/api/restricted/nginx/upstream.rs | 5 + .../nginx/upstream/get_upstream_target.rs | 99 +++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 apps/api/src/routes/api/restricted/nginx/upstream/get_upstream_target.rs diff --git a/apps/api/src/routes/api/restricted/nginx/upstream.rs b/apps/api/src/routes/api/restricted/nginx/upstream.rs index 1b1f797..14e18fd 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream.rs @@ -1,4 +1,5 @@ pub mod get_upstream; +pub mod get_upstream_target; use std::sync::Arc; @@ -10,5 +11,9 @@ pub fn get_upstream_router(state: Arc) -> Router { Router::new() .route("upstreams", get(get_upstream::get_upstream_list)) .route("upstreams/{upstream_id}", get(get_upstream::get_upstream)) + .route( + "upstream_targets/{upstream_target_id}", + get(get_upstream_target::get_upstream_target), + ) .with_state(state) } diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream_target.rs b/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream_target.rs new file mode 100644 index 0000000..bb2ac5a --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream_target.rs @@ -0,0 +1,99 @@ +use std::sync::Arc; + +use axum::{ + Json, + extract::{Path, Query, State}, + response::Result as AxumResult, +}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{errors::service_error::ServiceError, routes::AppState}; + +#[derive(Serialize, Deserialize, utoipa::ToSchema)] +pub struct GetUpstreamTargetsParams { + pub include_upstream: Option, +} + +pub struct ConcreteGetUpstreamTargetsParams { + pub include_upstream: bool, +} + +impl From for ConcreteGetUpstreamTargetsParams { + fn from(params: GetUpstreamTargetsParams) -> Self { + Self { + include_upstream: params.include_upstream.unwrap_or(false), + } + } +} + +#[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, + pub updated_at: DateTime, + // + pub upstream_id: Uuid, + pub upstream: Option, +} + +#[derive(Serialize, Deserialize, utoipa::ToSchema)] +pub struct UpstreamBasicInfo { + pub id: uuid::Uuid, + pub name: String, + pub protocol: String, + // + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl From + 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: None, + } + } +} + +pub async fn get_upstream_target( + Path(upstream_target_id): Path, + Query(params): Query, + State(_state): State>, +) -> AxumResult, ServiceError> { + 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())) +} -- 2.49.1 From 6eb0d9060b97dfdba8facdcd8345003b4aa8a162 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Mon, 29 Dec 2025 17:24:50 +0800 Subject: [PATCH 10/43] fix: path not starting with / --- apps/api/src/routes/api/restricted/nginx/upstream.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/api/src/routes/api/restricted/nginx/upstream.rs b/apps/api/src/routes/api/restricted/nginx/upstream.rs index 14e18fd..2ec6f0b 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream.rs @@ -9,10 +9,10 @@ use crate::routes::AppState; pub fn get_upstream_router(state: Arc) -> Router { Router::new() - .route("upstreams", get(get_upstream::get_upstream_list)) - .route("upstreams/{upstream_id}", get(get_upstream::get_upstream)) + .route("/upstreams", get(get_upstream::get_upstream_list)) + .route("/upstreams/{upstream_id}", get(get_upstream::get_upstream)) .route( - "upstream_targets/{upstream_target_id}", + "/upstream_targets/{upstream_target_id}", get(get_upstream_target::get_upstream_target), ) .with_state(state) -- 2.49.1 From 41023939db51eb7649c0297f1acf3aec78352327 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Mon, 29 Dec 2025 17:44:41 +0800 Subject: [PATCH 11/43] feat: add tests for upstream and upstream target handlers --- Cargo.lock | 147 +++++++++++++ apps/api/Cargo.toml | 1 + .../restricted/nginx/upstream/get_upstream.rs | 199 ++++++++++++++++++ .../nginx/upstream/get_upstream_target.rs | 124 +++++++++++ 4 files changed, 471 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 8b78390..86a92e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -292,6 +292,35 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "axum-test" +version = "18.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3290e73c56c5cc4701cdd7d46b9ced1b4bd61c7e9f9c769a9e9e87ff617d75d2" +dependencies = [ + "anyhow", + "axum", + "bytes", + "bytesize", + "cookie", + "expect-json", + "http", + "http-body-util", + "hyper", + "hyper-util", + "mime", + "pretty_assertions", + "reserve-port", + "rust-multipart-rfc7578_2", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "tokio", + "tower", + "url", +] + [[package]] name = "base16ct" version = "0.2.0" @@ -509,6 +538,12 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +[[package]] +name = "bytesize" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3" + [[package]] name = "cc" version = "1.2.45" @@ -994,6 +1029,12 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.10.7" @@ -1123,6 +1164,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1191,6 +1241,35 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "expect-json" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "422e7906e79941e5ac58c64dfd2da03e6ae3de62227f87606fbbe125d91080f9" +dependencies = [ + "chrono", + "email_address", + "expect-json-macros", + "num", + "regex", + "serde", + "serde_json", + "thiserror", + "typetag", + "uuid", +] + +[[package]] +name = "expect-json-macros" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6b515b7f10f1e61bfd938522e9884509b82060af2016153f5b3d6f44d6da89c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -1925,6 +2004,15 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "inventory" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e" +dependencies = [ + "rustversion", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -2752,6 +2840,16 @@ dependencies = [ "termtree", ] +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -3126,6 +3224,15 @@ dependencies = [ "webpki-roots 1.0.4", ] +[[package]] +name = "reserve-port" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21918d6644020c6f6ef1993242989bf6d4952d2e025617744f184c02df51c356" +dependencies = [ + "thiserror", +] + [[package]] name = "rfc6979" version = "0.4.0" @@ -3223,6 +3330,21 @@ dependencies = [ "ordered-multimap", ] +[[package]] +name = "rust-multipart-rfc7578_2" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c839d037155ebc06a571e305af66ff9fd9063a6e662447051737e1ac75beea41" +dependencies = [ + "bytes", + "futures-core", + "futures-util", + "http", + "mime", + "rand 0.9.2", + "thiserror", +] + [[package]] name = "rust_decimal" version = "1.39.0" @@ -4695,6 +4817,30 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "typetag" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be2212c8a9b9bcfca32024de14998494cf9a5dfa59ea1b829de98bac374b86bf" +dependencies = [ + "erased-serde", + "inventory", + "once_cell", + "serde", + "typetag-impl", +] + +[[package]] +name = "typetag-impl" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27a7a9b72ba121f6f1f6c3632b85604cac41aedb5ddc70accbebb6cac83de846" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "ucd-trie" version = "0.1.7" @@ -5444,6 +5590,7 @@ dependencies = [ "async-trait", "axum", "axum-extra", + "axum-test", "chrono", "clap", "config", diff --git a/apps/api/Cargo.toml b/apps/api/Cargo.toml index 0a3b4df..b71f3a1 100644 --- a/apps/api/Cargo.toml +++ b/apps/api/Cargo.toml @@ -35,6 +35,7 @@ optfield = { version = "0.4.0" } [dev-dependencies] tempfile = "3" +axum-test = "18.4.1" [lints.clippy] unwrap_used = "deny" diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream.rs b/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream.rs index 6a475f7..44cb0e4 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream.rs @@ -153,3 +153,202 @@ pub async fn get_upstream( // Ok(Json(upstream_info.into())) } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + use axum::http::StatusCode; + use axum_test::TestServer; + use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase}; + + use database::generated::entities::{upstream, upstream_target}; + + use crate::configs::{FromConfig, ProgramSettings}; + + use crate::routes::api::restricted::nginx::upstream::get_upstream_router; + use crate::services::get_app_service; + + fn get_router_with_state(db: DatabaseConnection) -> axum::Router { + let program_settings = ProgramSettings::mock(); + let app_service = get_app_service(&Arc::new(db.clone()), &program_settings); + let state = Arc::new(crate::routes::AppState { + database_connection: Arc::new(db), + service: Arc::new(app_service), + config: Arc::new(program_settings), + }); + get_upstream_router(state) + } + + #[tokio::test] + async fn handler_get_upstream_list_returns_list() { + let u1 = upstream::Model { + id: uuid::Uuid::new_v4(), + name: "u1".to_string(), + protocol: "http".to_string(), + algorithm: "rr".to_string(), + sticky_session: false, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + let u2 = upstream::Model { + id: uuid::Uuid::new_v4(), + name: "u2".to_string(), + protocol: "http".to_string(), + algorithm: "rr".to_string(), + sticky_session: false, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![vec![u1.clone(), u2.clone()]]) + .into_connection(); + + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let res = server.get("/upstreams").await; + res.assert_status_ok(); + let body = res.json::(); + assert_eq!(body.items.len(), 2); + assert_eq!(body.pagination.current_page, 1u32); + } + + #[tokio::test] + async fn handler_get_upstream_with_targets_returns_targets() { + let up_id = uuid::Uuid::new_v4(); + + let up_model = upstream::Model { + id: up_id, + name: "with_targets".to_string(), + protocol: "http".to_string(), + algorithm: "least_conn".to_string(), + sticky_session: true, + created_by: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let target_model = upstream_target::Model { + id: uuid::Uuid::new_v4(), + upstream_id: up_id, + target_host: "127.0.0.1".to_string(), + target_port: 8080, + weight: 1, + is_backup: false, + enabled: true, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + // find_by_id -> returns upstream model + .append_query_results(vec![vec![up_model.clone()]]) + // find targets -> returns the target(s) + .append_query_results(vec![vec![target_model.clone()]]) + .into_connection(); + + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let url = format!("/upstreams/{}?include_targets=true", up_id); + let res = server.get(&url).await; + res.assert_status_ok(); + let body = res.json::(); + 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::::new()]) + .into_connection(); + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + // page = 0 should be rejected + let res = server.get("/upstreams?page=0&per_page=10").await; + res.assert_status(StatusCode::BAD_REQUEST); + + // per_page out of range should be rejected + let res = server.get("/upstreams?page=1&per_page=0").await; + res.assert_status(StatusCode::BAD_REQUEST); + + // valid values accepted + let res = server.get("/upstreams?page=2&per_page=5").await; + res.assert_status_ok(); + let body = res.json::(); + 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::::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::(); + 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::::new()]) + .into_connection(); + + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let res = server.get("/upstreams?page=3&per_page=10").await; + res.assert_status_ok(); + let body = res.json::(); + assert_eq!(body.items.len(), 0); + assert_eq!(body.pagination.current_page, 3u32); + assert_eq!(body.pagination.per_page, 10u32); + } +} diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream_target.rs b/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream_target.rs index bb2ac5a..f1206dd 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream_target.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream_target.rs @@ -97,3 +97,127 @@ pub async fn get_upstream_target( .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(); + println!("response body: {}", 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::::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); + } +} -- 2.49.1 From 3cc6b40e6136d452f90f6856438ca90ec9a51d20 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Mon, 29 Dec 2025 17:45:16 +0800 Subject: [PATCH 12/43] Fix: missing upstream in From --- .../api/restricted/nginx/upstream/get_upstream_target.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream_target.rs b/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream_target.rs index f1206dd..5bf1045 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream_target.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream_target.rs @@ -70,7 +70,13 @@ impl From updated_at: info.updated_at, // upstream_id: info.upstream_id, - upstream: None, + 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, + }), } } } @@ -165,7 +171,6 @@ mod tests { let res = server.get(&url).await; res.assert_status_ok(); let text = res.text(); - println!("response body: {}", 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()); -- 2.49.1 From fea205cba88016273dd37115a75d769c648d4eae Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Mon, 29 Dec 2025 17:52:29 +0800 Subject: [PATCH 13/43] chore: delegate path prefix to router --- apps/api/src/routes/api/restricted/nginx.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/routes/api/restricted/nginx.rs b/apps/api/src/routes/api/restricted/nginx.rs index 9f66b9d..50803cf 100644 --- a/apps/api/src/routes/api/restricted/nginx.rs +++ b/apps/api/src/routes/api/restricted/nginx.rs @@ -7,5 +7,5 @@ use axum::Router; use crate::routes::AppState; pub fn get_nginx_router(state: Arc) -> Router { - Router::new().nest("/upstream", upstream::get_upstream_router(state.clone())) + Router::new().merge(upstream::get_upstream_router(state.clone())) } -- 2.49.1 From abeea4fad7b615c9babc785710e71ebdd9a0161d Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Mon, 29 Dec 2025 18:01:57 +0800 Subject: [PATCH 14/43] refactor: upstream info response structures and module --- .../routes/api/restricted/nginx/upstream.rs | 1 + .../restricted/nginx/upstream/get_upstream.rs | 80 +---------- .../nginx/upstream/get_upstream_target.rs | 59 +------- .../api/restricted/nginx/upstream/info.rs | 1 + .../nginx/upstream/info/response.rs | 129 ++++++++++++++++++ 5 files changed, 142 insertions(+), 128 deletions(-) create mode 100644 apps/api/src/routes/api/restricted/nginx/upstream/info.rs create mode 100644 apps/api/src/routes/api/restricted/nginx/upstream/info/response.rs diff --git a/apps/api/src/routes/api/restricted/nginx/upstream.rs b/apps/api/src/routes/api/restricted/nginx/upstream.rs index 2ec6f0b..408ca72 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream.rs @@ -1,5 +1,6 @@ pub mod get_upstream; pub mod get_upstream_target; +pub mod info; use std::sync::Arc; diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream.rs b/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream.rs index 44cb0e4..65777b7 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream.rs @@ -5,7 +5,6 @@ use axum::{ extract::{Path, Query, State}, response::Result as AxumResult, }; -use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -13,7 +12,12 @@ use crate::{ errors::service_error::ServiceError, routes::{ AppState, - api::helper::pagination::{ExtractPagination, PaginationInfo}, + api::{ + helper::pagination::{ExtractPagination, PaginationInfo}, + restricted::nginx::upstream::info::response::{ + UpstreamInfoResponse, UpstreamListResponse, + }, + }, }, services::nginx::upstream::GetUpstreamOptions, }; @@ -35,77 +39,6 @@ impl From for ConcreteGetUpstreamParams { } } -#[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, - pub updated_at: DateTime, -} - -impl From - 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, - pub created_at: DateTime, - pub updated_at: DateTime, - // - pub upstream_targets: Vec, -} - -impl From 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, - pub pagination: PaginationInfo, -} - pub async fn get_upstream_list( ExtractPagination(pagination): ExtractPagination, State(state): State>, @@ -168,6 +101,7 @@ mod tests { use crate::configs::{FromConfig, ProgramSettings}; use crate::routes::api::restricted::nginx::upstream::get_upstream_router; + use crate::routes::api::restricted::nginx::upstream::info::response::UpstreamInfoResponse; use crate::services::get_app_service; fn get_router_with_state(db: DatabaseConnection) -> axum::Router { diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream_target.rs b/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream_target.rs index 5bf1045..4d9de96 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream_target.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream_target.rs @@ -5,11 +5,13 @@ use axum::{ extract::{Path, Query, State}, response::Result as AxumResult, }; -use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::{errors::service_error::ServiceError, routes::AppState}; +use crate::{ + errors::service_error::ServiceError, + routes::{AppState, api::restricted::nginx::upstream::info::response::UpstreamTargetInfo}, +}; #[derive(Serialize, Deserialize, utoipa::ToSchema)] pub struct GetUpstreamTargetsParams { @@ -28,59 +30,6 @@ impl From for ConcreteGetUpstreamTargetsParams { } } -#[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, - pub updated_at: DateTime, - // - pub upstream_id: Uuid, - pub upstream: Option, -} - -#[derive(Serialize, Deserialize, utoipa::ToSchema)] -pub struct UpstreamBasicInfo { - pub id: uuid::Uuid, - pub name: String, - pub protocol: String, - // - pub created_at: DateTime, - pub updated_at: DateTime, -} - -impl From - 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, - }), - } - } -} - pub async fn get_upstream_target( Path(upstream_target_id): Path, Query(params): Query, diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/info.rs b/apps/api/src/routes/api/restricted/nginx/upstream/info.rs new file mode 100644 index 0000000..4c6f2cd --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/upstream/info.rs @@ -0,0 +1 @@ +pub mod response; diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/info/response.rs b/apps/api/src/routes/api/restricted/nginx/upstream/info/response.rs new file mode 100644 index 0000000..58d744d --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/upstream/info/response.rs @@ -0,0 +1,129 @@ +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, + pub updated_at: DateTime, + // + pub upstream_id: Uuid, + pub upstream: Option, +} + +#[derive(Serialize, Deserialize, utoipa::ToSchema)] +pub struct UpstreamBasicInfo { + pub id: Uuid, + pub name: String, + pub protocol: String, + // + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl From + 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, + pub updated_at: DateTime, +} + +impl From + 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, + pub created_at: DateTime, + pub updated_at: DateTime, + // + pub upstream_targets: Vec, +} + +impl From 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, + pub pagination: PaginationInfo, +} -- 2.49.1 From fdfd1c98e010ae53935b23e06d272d99b3a8f76b Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Mon, 29 Dec 2025 18:03:09 +0800 Subject: [PATCH 15/43] feat: introduce ApiError for improved error handling in upstream routes --- apps/api/src/errors.rs | 1 + apps/api/src/errors/api_error.rs | 21 +++++++++++++++++++ .../restricted/nginx/upstream/get_upstream.rs | 4 ++-- .../nginx/upstream/get_upstream_target.rs | 4 ++-- 4 files changed, 26 insertions(+), 4 deletions(-) create mode 100644 apps/api/src/errors/api_error.rs diff --git a/apps/api/src/errors.rs b/apps/api/src/errors.rs index ae13070..d261f2c 100644 --- a/apps/api/src/errors.rs +++ b/apps/api/src/errors.rs @@ -1 +1,2 @@ +pub mod api_error; pub mod service_error; diff --git a/apps/api/src/errors/api_error.rs b/apps/api/src/errors/api_error.rs new file mode 100644 index 0000000..8f16996 --- /dev/null +++ b/apps/api/src/errors/api_error.rs @@ -0,0 +1,21 @@ +use axum::response::IntoResponse; + +use crate::errors::service_error::ServiceError; + +pub enum ApiError { + ServiceError(ServiceError), +} + +impl From for ApiError { + fn from(err: ServiceError) -> Self { + ApiError::ServiceError(err) + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> axum::response::Response { + match self { + ApiError::ServiceError(service_error) => service_error.into_response(), + } + } +} diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream.rs b/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream.rs index 65777b7..e1cdd95 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::{ - errors::service_error::ServiceError, + errors::{api_error::ApiError, service_error::ServiceError}, routes::{ AppState, api::{ @@ -64,7 +64,7 @@ pub async fn get_upstream( Path(upstream_id): Path, Query(params): Query, State(_state): State>, -) -> AxumResult, ServiceError> { +) -> AxumResult, ApiError> { let concrete_params: ConcreteGetUpstreamParams = params.into(); let upstream_service = &_state.service.nginx.get_upstream_service(); let upstream_info = if concrete_params.include_targets { diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream_target.rs b/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream_target.rs index 4d9de96..fdc5d47 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream_target.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream_target.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::{ - errors::service_error::ServiceError, + errors::{api_error::ApiError, service_error::ServiceError}, routes::{AppState, api::restricted::nginx::upstream::info::response::UpstreamTargetInfo}, }; @@ -34,7 +34,7 @@ pub async fn get_upstream_target( Path(upstream_target_id): Path, Query(params): Query, State(_state): State>, -) -> AxumResult, ServiceError> { +) -> AxumResult, ApiError> { let concrete_params: ConcreteGetUpstreamTargetsParams = params.into(); let upstream_service = &_state.service.nginx.get_upstream_service(); let upstream_target_info = upstream_service -- 2.49.1 From 0cbc223b4e3aee072825c83c2370c72f961b7e99 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Mon, 29 Dec 2025 18:44:52 +0800 Subject: [PATCH 16/43] feat: implement AuthenticatedRequestInfo for user authentication handling --- apps/api/src/middlewares/request_info.rs | 25 ++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/apps/api/src/middlewares/request_info.rs b/apps/api/src/middlewares/request_info.rs index fb44b20..aec8c53 100644 --- a/apps/api/src/middlewares/request_info.rs +++ b/apps/api/src/middlewares/request_info.rs @@ -1,6 +1,31 @@ +use axum::{extract::FromRequestParts, http::StatusCode}; use uuid::Uuid; #[derive(Clone, Debug)] pub struct RequestInfo { pub user_id: Option, } + +pub struct AuthenticatedRequestInfo { + pub user_id: Uuid, +} + +impl FromRequestParts<()> for AuthenticatedRequestInfo { + type Rejection = StatusCode; + + async fn from_request_parts( + parts: &mut axum::http::request::Parts, + _state: &(), + ) -> Result { + let request_info = parts + .extensions + .get::() + .ok_or(StatusCode::UNAUTHORIZED)?; + + if let Some(user_id) = request_info.user_id { + Ok(AuthenticatedRequestInfo { user_id }) + } else { + Err(StatusCode::UNAUTHORIZED) + } + } +} -- 2.49.1 From 7a557d6e0016e1a527d3995a3339f7b778abdf8f Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Mon, 29 Dec 2025 18:45:03 +0800 Subject: [PATCH 17/43] feat: update UpstreamCreateInfo conversion to include upstream targets --- apps/api/src/services/nginx/info/upstream.rs | 21 ++++++++--- apps/api/src/services/nginx/upstream.rs | 38 ++++++++++++++++++-- 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/apps/api/src/services/nginx/info/upstream.rs b/apps/api/src/services/nginx/info/upstream.rs index 1756e6d..8389109 100644 --- a/apps/api/src/services/nginx/info/upstream.rs +++ b/apps/api/src/services/nginx/info/upstream.rs @@ -55,10 +55,11 @@ impl NginxConfigProvider for UpstreamInfo { } } -impl From for upstream::ActiveModel { - fn from(val: UpstreamCreateInfo) -> Self { - upstream::ActiveModel { - id: sea_orm::ActiveValue::Set(Uuid::new_v4()), +impl From for (upstream::ActiveModel, Vec) { + fn from(val: UpstreamCreateInfo) -> (upstream::ActiveModel, Vec) { + 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), @@ -66,7 +67,17 @@ impl From for upstream::ActiveModel { 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) } } diff --git a/apps/api/src/services/nginx/upstream.rs b/apps/api/src/services/nginx/upstream.rs index a3d2752..f9fd054 100644 --- a/apps/api/src/services/nginx/upstream.rs +++ b/apps/api/src/services/nginx/upstream.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use sea_orm::{ ActiveModelTrait, ColumnTrait, DatabaseConnection, DatabaseTransaction, EntityTrait, - ModelTrait, QueryFilter, QuerySelect, + ModelTrait, QueryFilter, QuerySelect, TransactionTrait, }; use database::generated::entities::{upstream, upstream_target}; @@ -42,8 +42,40 @@ impl UpstreamService { create_info: UpstreamCreateInfo, tx: Option<&mut DatabaseTransaction>, ) -> Result { - let model: upstream::ActiveModel = create_info.into(); - let r = with_conn!(&*self.connection, tx, conn, { model.insert(*conn).await? }); + let (upstream_model, upstream_target_models): ( + upstream::ActiveModel, + Vec, + ) = create_info.into(); + + // If a transaction was provided use it, otherwise create and own one here. + let mut maybe_owned_tx: Option = 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::>(), + ) + .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()) } -- 2.49.1 From 481abd7f26bb0b72bcd51a431e93a2cb50970284 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Mon, 29 Dec 2025 18:58:00 +0800 Subject: [PATCH 18/43] fix: AuthenticatedRequestInfo support any state --- apps/api/src/middlewares/request_info.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/api/src/middlewares/request_info.rs b/apps/api/src/middlewares/request_info.rs index aec8c53..65b35e0 100644 --- a/apps/api/src/middlewares/request_info.rs +++ b/apps/api/src/middlewares/request_info.rs @@ -10,12 +10,15 @@ pub struct AuthenticatedRequestInfo { pub user_id: Uuid, } -impl FromRequestParts<()> for AuthenticatedRequestInfo { +impl FromRequestParts for AuthenticatedRequestInfo +where + S: Send + Sync, +{ type Rejection = StatusCode; async fn from_request_parts( parts: &mut axum::http::request::Parts, - _state: &(), + _state: &S, ) -> Result { let request_info = parts .extensions -- 2.49.1 From 37f4163870f59b2b75064631635a1c09516f6227 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Mon, 29 Dec 2025 19:16:53 +0800 Subject: [PATCH 19/43] added mock for require_auth middleware --- apps/api/src/middlewares/require_auth.rs | 39 ++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/apps/api/src/middlewares/require_auth.rs b/apps/api/src/middlewares/require_auth.rs index 5cf3a6f..3c5db09 100644 --- a/apps/api/src/middlewares/require_auth.rs +++ b/apps/api/src/middlewares/require_auth.rs @@ -68,3 +68,42 @@ async fn handle_unauthenticated() -> Result { // TODO: log unauthenticated access attempts Err(StatusCode::UNAUTHORIZED) } + +#[cfg(test)] +pub mod mock { + + use super::*; + + pub const REQUEST_AUTH_USER_ID_HEADER: &str = "x-mock-authenticated-user-id"; + pub const REQUEST_AUTH_USER_INVALID_HEADER: &str = "x-mock-authenticated-invalid"; + + pub async fn mock_require_auth( + req: Request, + next: Next, + ) -> Result { + 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) + } +} -- 2.49.1 From dd79cbe0bb1a3513cb6c50abd58a5539245d5067 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Mon, 29 Dec 2025 20:12:01 +0800 Subject: [PATCH 20/43] feat: add create_upstream handler for upstream creation --- .../routes/api/restricted/nginx/upstream.rs | 6 +- .../nginx/upstream/create_upstream.rs | 249 ++++++++++++++++++ 2 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/routes/api/restricted/nginx/upstream/create_upstream.rs diff --git a/apps/api/src/routes/api/restricted/nginx/upstream.rs b/apps/api/src/routes/api/restricted/nginx/upstream.rs index 408ca72..2481de0 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream.rs @@ -1,3 +1,4 @@ +pub mod create_upstream; pub mod get_upstream; pub mod get_upstream_target; pub mod info; @@ -10,7 +11,10 @@ use crate::routes::AppState; pub fn get_upstream_router(state: Arc) -> Router { Router::new() - .route("/upstreams", get(get_upstream::get_upstream_list)) + .route( + "/upstreams", + get(get_upstream::get_upstream_list).post(create_upstream::create_upstream), + ) .route("/upstreams/{upstream_id}", get(get_upstream::get_upstream)) .route( "/upstream_targets/{upstream_target_id}", diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream.rs b/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream.rs new file mode 100644 index 0000000..45b79ca --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream.rs @@ -0,0 +1,249 @@ +use std::sync::Arc; + +use axum::{Json, extract::State, response::Result as AxumResult}; + +use crate::{ + errors::api_error::ApiError, + middlewares::request_info::AuthenticatedRequestInfo, + routes::{AppState, api::restricted::nginx::upstream::info::response::UpstreamInfoResponse}, + services::nginx::info::upstream::UpstreamCreateInfo, +}; + +#[derive(serde::Deserialize, utoipa::ToSchema, serde::Serialize)] +pub struct UpstreamTargetInfo { + pub host: String, + pub port: i64, + pub weight: Option, + pub is_backup: Option, + pub enabled: Option, +} + +pub struct ConcreteUpstreamTargetInfo { + pub host: String, + pub port: i64, + pub weight: i64, + pub is_backup: bool, + pub enabled: bool, +} + +impl From 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, + pub sticky_session: Option, + pub upstream_targets: Vec, +} + +struct ConcreteCreateUpstreamRequestBody { + pub name: String, + pub protocol: String, + pub algorithm: String, + pub sticky_session: bool, + pub upstream_targets: Vec, +} + +impl From for ConcreteCreateUpstreamRequestBody { + fn from(payload: CreateUpstreamRequestBody) -> Self { + Self { + name: payload.name, + protocol: payload.protocol, + algorithm: payload + .algorithm + .unwrap_or_else(|| "round_robin".to_string()), + sticky_session: payload.sticky_session.unwrap_or(false), + upstream_targets: payload + .upstream_targets + .into_iter() + .map(|target| target.into()) + .collect(), + } + } +} + +#[axum::debug_handler] +pub async fn create_upstream( + request_info: AuthenticatedRequestInfo, + State(state): State>, + Json(payload): Json, +) -> AxumResult, ApiError> { + let upstream_service = &state.service.nginx.get_upstream_service(); + let concrete_payload: ConcreteCreateUpstreamRequestBody = payload.into(); + + let create_info = UpstreamCreateInfo { + name: concrete_payload.name, + protocol: concrete_payload.protocol, + algorithm: concrete_payload.algorithm, + sticky_session: concrete_payload.sticky_session, + created_by: Some(request_info.user_id), + upstream_targets: concrete_payload + .upstream_targets + .into_iter() + .map( + |target| crate::services::nginx::info::upstream_target::UpstreamTargetCreateInfo { + target_host: target.host, + target_port: target.port, + weight: target.weight, + is_backup: target.is_backup, + enabled: target.enabled, + upstream_id: uuid::Uuid::nil(), // Placeholder, will be set in service + }, + ) + .collect(), + }; + + let upstream_info = upstream_service.create_upstream(create_info, None).await?; + + Ok(Json(upstream_info.into())) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use axum::http::StatusCode; + use axum_test::TestServer; + use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase}; + + use database::generated::entities::{upstream, upstream_target}; + + use crate::{ + configs::{FromConfig, ProgramSettings}, + middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER, + routes::api::restricted::nginx::upstream::{ + create_upstream::{CreateUpstreamRequestBody, UpstreamTargetInfo as ReqTarget}, + get_upstream_router, + }, + services::get_app_service, + }; + + fn get_router_with_state(db: DatabaseConnection) -> axum::Router { + let program_settings = ProgramSettings::mock(); + let app_service = get_app_service(&Arc::new(db.clone()), &program_settings); + let state = Arc::new(crate::routes::AppState { + database_connection: Arc::new(db), + service: Arc::new(app_service), + config: Arc::new(program_settings), + }); + get_upstream_router(state).layer(axum::middleware::from_fn( + crate::middlewares::require_auth::mock::mock_require_auth, + )) + } + + #[tokio::test] + async fn handler_create_upstream_succeeds_returns_created() { + let up_id = uuid::Uuid::new_v4(); + + let up_model = upstream::Model { + id: up_id, + name: "new_upstream".to_string(), + protocol: "http".to_string(), + algorithm: "round_robin".to_string(), + sticky_session: false, + created_by: Some(uuid::Uuid::new_v4()), + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let target_id = uuid::Uuid::new_v4(); + let target_model = upstream_target::Model { + id: target_id, + upstream_id: up_id, + target_host: "127.0.0.1".to_string(), + target_port: 8080, + weight: 1, + is_backup: false, + enabled: true, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + // service will likely perform an insert and then query to return created models + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![vec![up_model.clone()]]) + .append_query_results(vec![vec![target_model.clone()]]) + .into_connection(); + + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let payload = CreateUpstreamRequestBody { + name: "new_upstream".to_string(), + protocol: "http".to_string(), + algorithm: None, + sticky_session: None, + upstream_targets: vec![ReqTarget { + host: "127.0.0.1".to_string(), + port: 8080, + weight: None, + is_backup: None, + enabled: None, + }], + }; + + let res = server.post("/upstreams").json(&payload).await; + + res.assert_status_ok(); + let text = res.text(); + let body: crate::routes::api::restricted::nginx::upstream::info::response::UpstreamInfoResponse = + serde_json::from_str(&text).expect("failed to parse json"); + + assert_eq!(body.id, up_id); + assert_eq!(body.name, "new_upstream"); + assert_eq!(body.protocol, "http"); + assert_eq!(body.upstream_targets.len(), 1); + assert_eq!(body.upstream_targets[0].id, target_id); + } + + #[tokio::test] + async fn handler_create_upstream_invalid_payload_returns_bad_request() { + let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection(); + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + // missing required fields -> send empty object + let res = server.post("/upstreams").json(&serde_json::json!({})).await; + res.assert_status(StatusCode::UNPROCESSABLE_ENTITY); + } + + #[tokio::test] + async fn handler_create_upstream_unauthenticated_returns_unauthorized() { + let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection(); + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let payload = CreateUpstreamRequestBody { + name: "new_upstream".to_string(), + protocol: "http".to_string(), + algorithm: None, + sticky_session: None, + upstream_targets: vec![ReqTarget { + host: "127.0.0.1".to_string(), + port: 8080, + weight: None, + is_backup: None, + enabled: None, + }], + }; + + let res = server + .post("/upstreams") + .add_header(REQUEST_AUTH_USER_INVALID_HEADER, "true") + .json(&payload) + .await; + + res.assert_status(StatusCode::UNAUTHORIZED); + } +} -- 2.49.1 From 1b251fd89f444264a90a7541457be42127df284a Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Mon, 29 Dec 2025 21:34:40 +0800 Subject: [PATCH 21/43] feat: add add_upstream_target handler and response struct for upstream target management --- .../routes/api/restricted/nginx/upstream.rs | 10 +- .../nginx/upstream/create_upstream_target.rs | 189 ++++++++++++++++++ .../nginx/upstream/get_upstream_target.rs | 2 +- .../nginx/upstream/info/response.rs | 35 ++++ 4 files changed, 234 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/routes/api/restricted/nginx/upstream/create_upstream_target.rs diff --git a/apps/api/src/routes/api/restricted/nginx/upstream.rs b/apps/api/src/routes/api/restricted/nginx/upstream.rs index 2481de0..717a043 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream.rs @@ -1,11 +1,15 @@ pub mod create_upstream; +pub mod create_upstream_target; pub mod get_upstream; pub mod get_upstream_target; pub mod info; use std::sync::Arc; -use axum::{Router, routing::get}; +use axum::{ + Router, + routing::{get, post}, +}; use crate::routes::AppState; @@ -16,6 +20,10 @@ pub fn get_upstream_router(state: Arc) -> Router { get(get_upstream::get_upstream_list).post(create_upstream::create_upstream), ) .route("/upstreams/{upstream_id}", get(get_upstream::get_upstream)) + .route( + "/upstreams/{upstream_id}/targets", + post(create_upstream_target::add_upstream_target), + ) .route( "/upstream_targets/{upstream_target_id}", get(get_upstream_target::get_upstream_target), diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream_target.rs b/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream_target.rs new file mode 100644 index 0000000..86cfd6b --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream_target.rs @@ -0,0 +1,189 @@ +use std::sync::Arc; + +use axum::{Json, extract::State, response::Result as AxumResult}; + +use crate::{ + errors::api_error::ApiError, + middlewares::request_info::AuthenticatedRequestInfo, + routes::{ + AppState, api::restricted::nginx::upstream::info::response::UpstreamTargetInfoResponse, + }, + services::nginx::info::upstream_target::UpstreamTargetCreateInfo, +}; + +#[derive(serde::Deserialize, utoipa::ToSchema, serde::Serialize)] +pub struct CreateUpstreamTargetInfo { + pub upstream_id: uuid::Uuid, + pub host: String, + pub port: i64, + pub weight: Option, + pub is_backup: Option, + pub enabled: Option, +} + +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 for ConcreteCreateUpstreamTargetInfo { + fn from(info: CreateUpstreamTargetInfo) -> Self { + Self { + upstream_id: info.upstream_id, + host: info.host, + port: info.port, + weight: info.weight.unwrap_or(1), + is_backup: info.is_backup.unwrap_or(false), + enabled: info.enabled.unwrap_or(true), + } + } +} + +#[axum::debug_handler] +pub async fn add_upstream_target( + _request_info: AuthenticatedRequestInfo, + State(state): State>, + Json(payload): Json, +) -> AxumResult, ApiError> { + let upstream_service = &state.service.nginx.get_upstream_service(); + let concrete_payload: ConcreteCreateUpstreamTargetInfo = payload.into(); + + let create_info = UpstreamTargetCreateInfo { + weight: concrete_payload.weight, + is_backup: concrete_payload.is_backup, + enabled: concrete_payload.enabled, + target_host: concrete_payload.host, + target_port: concrete_payload.port, + upstream_id: concrete_payload.upstream_id, + }; + + let upstream_info = upstream_service + .create_upstream_target(create_info, None) + .await?; + + Ok(Json(upstream_info.into())) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use axum::http::StatusCode; + use axum_test::TestServer; + use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase}; + + use database::generated::entities::upstream_target; + + use crate::{ + configs::{FromConfig, ProgramSettings}, + middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER, + routes::api::restricted::nginx::upstream::{ + create_upstream_target::CreateUpstreamTargetInfo, get_upstream_router, + }, + services::get_app_service, + }; + + fn get_router_with_state(db: DatabaseConnection) -> axum::Router { + let program_settings = ProgramSettings::mock(); + let app_service = get_app_service(&Arc::new(db.clone()), &program_settings); + let state = Arc::new(crate::routes::AppState { + database_connection: Arc::new(db), + service: Arc::new(app_service), + config: Arc::new(program_settings), + }); + get_upstream_router(state).layer(axum::middleware::from_fn( + crate::middlewares::require_auth::mock::mock_require_auth, + )) + } + + #[tokio::test] + async fn handler_add_upstream_target_succeeds_returns_created() { + let up_id = uuid::Uuid::new_v4(); + + let target_id = uuid::Uuid::new_v4(); + let target_model = upstream_target::Model { + id: target_id, + upstream_id: up_id, + target_host: "127.0.0.1".to_string(), + target_port: 8080, + weight: 1, + is_backup: false, + enabled: true, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(vec![vec![target_model.clone()]]) + .into_connection(); + + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let payload = CreateUpstreamTargetInfo { + upstream_id: up_id, + host: "127.0.0.1".to_string(), + port: 8080, + weight: None, + is_backup: None, + enabled: None, + }; + + let res = server + .post(&format!("/upstreams/{}/targets", up_id)) + .json(&payload) + .await; + + res.assert_status_ok(); + let text = res.text(); + let body: crate::routes::api::restricted::nginx::upstream::info::response::UpstreamTargetInfoResponse = + serde_json::from_str(&text).expect("failed to parse json"); + + assert_eq!(body.id, target_id); + assert_eq!(body.host, "127.0.0.1"); + assert_eq!(body.port, 8080); + assert_eq!(body.upstream_id, up_id); + } + + #[tokio::test] + async fn handler_add_upstream_target_invalid_payload_returns_bad_request() { + let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection(); + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let res = server + .post(&format!("/upstreams/{}/targets", uuid::Uuid::new_v4())) + .json(&serde_json::json!({})) + .await; + + res.assert_status(StatusCode::UNPROCESSABLE_ENTITY); + } + + #[tokio::test] + async fn handler_add_upstream_target_unauthenticated_returns_unauthorized() { + let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection(); + let router = get_router_with_state(db.clone()); + let server = TestServer::new(router).expect("failed to create test server"); + + let payload = CreateUpstreamTargetInfo { + upstream_id: uuid::Uuid::new_v4(), + host: "127.0.0.1".to_string(), + port: 8080, + weight: None, + is_backup: None, + enabled: None, + }; + + let res = server + .post(&format!("/upstreams/{}/targets", payload.upstream_id)) + .add_header(REQUEST_AUTH_USER_INVALID_HEADER, "true") + .json(&payload) + .await; + + res.assert_status(StatusCode::UNAUTHORIZED); + } +} diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream_target.rs b/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream_target.rs index fdc5d47..07a4ac5 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream_target.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream_target.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::{ - errors::{api_error::ApiError, service_error::ServiceError}, + errors::api_error::ApiError, routes::{AppState, api::restricted::nginx::upstream::info::response::UpstreamTargetInfo}, }; diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/info/response.rs b/apps/api/src/routes/api/restricted/nginx/upstream/info/response.rs index 58d744d..efdc428 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream/info/response.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream/info/response.rs @@ -127,3 +127,38 @@ pub struct UpstreamListResponse { pub items: Vec, 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, + pub updated_at: DateTime, + // + pub upstream_id: Uuid, +} + +impl From + 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, + } + } +} -- 2.49.1 From b43f9fcb0028c22b72e5341f831daa98cc257726 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Tue, 30 Dec 2025 15:09:33 +0800 Subject: [PATCH 22/43] 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. --- .../src/generated/entities/access_list.rs | 34 ----- .../generated/entities/access_list_entry.rs | 28 ---- .../{audit_log.rs => edit_operation.rs} | 19 +-- .../src/generated/entities/editing_session.rs | 23 ++++ .../src/generated/entities/location.rs | 45 ------- public/database/src/generated/entities/mod.rs | 9 +- .../src/generated/entities/prelude.rs | 9 +- .../src/generated/entities/proxy_host.rs | 50 ------- .../entities/proxy_host_access_list.rs | 33 ----- .../src/generated/entities/session.rs | 29 ---- .../src/generated/entities/stream_service.rs | 47 ------- .../entities/stream_service_access_list.rs | 33 ----- .../src/generated/entities/upstream.rs | 6 - .../database/src/generated/entities/user.rs | 6 - public/migration/src/lib.rs | 8 +- public/migration/src/migrations.rs | 8 +- ...20251223_000006_create_proxy_host_table.rs | 124 ------------------ .../m20251223_000007_create_location_table.rs | 100 -------------- ...1223_000008_create_stream_service_table.rs | 112 ---------------- ...0251223_000009_create_access_list_table.rs | 63 --------- ...3_000010_create_access_list_entry_table.rs | 58 -------- ...011_create_proxy_host_access_list_table.rs | 65 --------- ...create_stream_service_access_list_table.rs | 65 --------- ...230_000006_create_editing_session_table.rs | 117 +++++++++++++++++ 24 files changed, 156 insertions(+), 935 deletions(-) delete mode 100644 public/database/src/generated/entities/access_list.rs delete mode 100644 public/database/src/generated/entities/access_list_entry.rs rename public/database/src/generated/entities/{audit_log.rs => edit_operation.rs} (59%) create mode 100644 public/database/src/generated/entities/editing_session.rs delete mode 100644 public/database/src/generated/entities/location.rs delete mode 100644 public/database/src/generated/entities/proxy_host.rs delete mode 100644 public/database/src/generated/entities/proxy_host_access_list.rs delete mode 100644 public/database/src/generated/entities/session.rs delete mode 100644 public/database/src/generated/entities/stream_service.rs delete mode 100644 public/database/src/generated/entities/stream_service_access_list.rs delete mode 100644 public/migration/src/migrations/m20251223_000006_create_proxy_host_table.rs delete mode 100644 public/migration/src/migrations/m20251223_000007_create_location_table.rs delete mode 100644 public/migration/src/migrations/m20251223_000008_create_stream_service_table.rs delete mode 100644 public/migration/src/migrations/m20251223_000009_create_access_list_table.rs delete mode 100644 public/migration/src/migrations/m20251223_000010_create_access_list_entry_table.rs delete mode 100644 public/migration/src/migrations/m20251223_000011_create_proxy_host_access_list_table.rs delete mode 100644 public/migration/src/migrations/m20251223_000012_create_stream_service_access_list_table.rs create mode 100644 public/migration/src/migrations/m20251230_000006_create_editing_session_table.rs diff --git a/public/database/src/generated/entities/access_list.rs b/public/database/src/generated/entities/access_list.rs deleted file mode 100644 index 93fd0e5..0000000 --- a/public/database/src/generated/entities/access_list.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0.0-rc.18 - -use sea_orm::entity::prelude::*; -use serde::{Deserialize, Serialize}; - -#[sea_orm::model] -#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] -#[sea_orm(table_name = "access_list")] -pub struct Model { - #[sea_orm(primary_key, auto_increment = false)] - pub id: Uuid, - pub name: String, - #[sea_orm(column_type = "Text", nullable)] - pub description: Option, - pub created_by: Option, - pub created_at: DateTimeUtc, - pub updated_at: DateTimeUtc, - #[sea_orm(has_many)] - pub access_list_entries: HasMany, - #[sea_orm(has_many)] - pub proxy_host_access_lists: HasMany, - #[sea_orm(has_many)] - pub stream_service_access_lists: HasMany, - #[sea_orm( - belongs_to, - from = "created_by", - to = "id", - on_update = "Cascade", - on_delete = "SetNull" - )] - pub user: HasOne, -} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/public/database/src/generated/entities/access_list_entry.rs b/public/database/src/generated/entities/access_list_entry.rs deleted file mode 100644 index 403ae92..0000000 --- a/public/database/src/generated/entities/access_list_entry.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0.0-rc.18 - -use sea_orm::entity::prelude::*; -use serde::{Deserialize, Serialize}; - -#[sea_orm::model] -#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] -#[sea_orm(table_name = "access_list_entry")] -pub struct Model { - #[sea_orm(primary_key, auto_increment = false)] - pub id: Uuid, - pub access_list_id: Uuid, - pub entry_type: String, - pub value: String, - #[sea_orm(column_type = "Text", nullable)] - pub comment: Option, - pub created_at: DateTimeUtc, - #[sea_orm( - belongs_to, - from = "access_list_id", - to = "id", - on_update = "Cascade", - on_delete = "Cascade" - )] - pub access_list: HasOne, -} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/public/database/src/generated/entities/audit_log.rs b/public/database/src/generated/entities/edit_operation.rs similarity index 59% rename from public/database/src/generated/entities/audit_log.rs rename to public/database/src/generated/entities/edit_operation.rs index 960aee7..98b4275 100644 --- a/public/database/src/generated/entities/audit_log.rs +++ b/public/database/src/generated/entities/edit_operation.rs @@ -5,25 +5,26 @@ use serde::{Deserialize, Serialize}; #[sea_orm::model] #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] -#[sea_orm(table_name = "audit_log")] +#[sea_orm(table_name = "edit_operation")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, - pub actor_id: Option, - pub action: String, + pub session_id: Uuid, pub resource_type: String, - pub resource_id: String, - #[sea_orm(column_type = "JsonBinary", nullable)] - pub details: Option, + pub resource_id: Option, + pub operation_type: String, + #[sea_orm(column_type = "JsonBinary")] + pub payload: Json, pub created_at: DateTimeUtc, + pub applied_at: Option, #[sea_orm( belongs_to, - from = "actor_id", + from = "session_id", to = "id", on_update = "Cascade", - on_delete = "SetNull" + on_delete = "Cascade" )] - pub user: HasOne, + pub editing_session: HasOne, } impl ActiveModelBehavior for ActiveModel {} diff --git a/public/database/src/generated/entities/editing_session.rs b/public/database/src/generated/entities/editing_session.rs new file mode 100644 index 0000000..595305d --- /dev/null +++ b/public/database/src/generated/entities/editing_session.rs @@ -0,0 +1,23 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0.0-rc.18 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "editing_session")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub name: Option, + pub created_by: Option, + pub status: String, + pub created_at: DateTimeUtc, + pub applied_at: Option, + pub applied_by: Option, + pub expires_at: Option, + #[sea_orm(has_many)] + pub edit_operations: HasMany, +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/public/database/src/generated/entities/location.rs b/public/database/src/generated/entities/location.rs deleted file mode 100644 index 99f1c32..0000000 --- a/public/database/src/generated/entities/location.rs +++ /dev/null @@ -1,45 +0,0 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0.0-rc.18 - -use sea_orm::entity::prelude::*; -use serde::{Deserialize, Serialize}; - -#[sea_orm::model] -#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] -#[sea_orm(table_name = "location")] -pub struct Model { - #[sea_orm(primary_key, auto_increment = false)] - pub id: Uuid, - pub host_id: Uuid, - pub path: String, - pub match_type: String, - pub order: i64, - pub upstream_id: Option, - pub proxy_pass_host: Option, - pub proxy_pass_port: Option, - pub preserve_host_header: Option, - #[sea_orm(column_type = "JsonBinary", nullable)] - pub allowed_methods: Option, - #[sea_orm(column_type = "Text", nullable)] - pub custom_config: Option, - pub enabled: bool, - pub created_at: DateTimeUtc, - pub updated_at: DateTimeUtc, - #[sea_orm( - belongs_to, - from = "host_id", - to = "id", - on_update = "Cascade", - on_delete = "Cascade" - )] - pub proxy_host: HasOne, - #[sea_orm( - belongs_to, - from = "upstream_id", - to = "id", - on_update = "Cascade", - on_delete = "SetNull" - )] - pub upstream: HasOne, -} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/public/database/src/generated/entities/mod.rs b/public/database/src/generated/entities/mod.rs index 33598a4..1f4ed58 100644 --- a/public/database/src/generated/entities/mod.rs +++ b/public/database/src/generated/entities/mod.rs @@ -2,14 +2,9 @@ pub mod prelude; -pub mod access_list; -pub mod access_list_entry; pub mod config; -pub mod location; -pub mod proxy_host; -pub mod proxy_host_access_list; -pub mod stream_service; -pub mod stream_service_access_list; +pub mod edit_operation; +pub mod editing_session; pub mod upstream; pub mod upstream_target; pub mod user; diff --git a/public/database/src/generated/entities/prelude.rs b/public/database/src/generated/entities/prelude.rs index d09f1c3..a66a6d5 100644 --- a/public/database/src/generated/entities/prelude.rs +++ b/public/database/src/generated/entities/prelude.rs @@ -1,13 +1,8 @@ //! `SeaORM` Entity, @generated by sea-orm-codegen 2.0.0-rc.18 -pub use super::access_list::Entity as AccessList; -pub use super::access_list_entry::Entity as AccessListEntry; pub use super::config::Entity as Config; -pub use super::location::Entity as Location; -pub use super::proxy_host::Entity as ProxyHost; -pub use super::proxy_host_access_list::Entity as ProxyHostAccessList; -pub use super::stream_service::Entity as StreamService; -pub use super::stream_service_access_list::Entity as StreamServiceAccessList; +pub use super::edit_operation::Entity as EditOperation; +pub use super::editing_session::Entity as EditingSession; pub use super::upstream::Entity as Upstream; pub use super::upstream_target::Entity as UpstreamTarget; pub use super::user::Entity as User; diff --git a/public/database/src/generated/entities/proxy_host.rs b/public/database/src/generated/entities/proxy_host.rs deleted file mode 100644 index b521b02..0000000 --- a/public/database/src/generated/entities/proxy_host.rs +++ /dev/null @@ -1,50 +0,0 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0.0-rc.18 - -use sea_orm::entity::prelude::*; -use serde::{Deserialize, Serialize}; - -#[sea_orm::model] -#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] -#[sea_orm(table_name = "proxy_host")] -pub struct Model { - #[sea_orm(primary_key, auto_increment = false)] - pub id: Uuid, - pub name: Option, - pub domain: String, - pub scheme: String, - pub listen_port: i64, - pub forward_scheme: String, - pub forward_host: Option, - pub forward_port: Option, - pub preserve_host_header: bool, - pub enable_websocket: bool, - pub enabled: bool, - #[sea_orm(column_type = "JsonBinary", nullable)] - pub meta: Option, - pub default_upstream_id: Option, - pub created_by: Option, - pub created_at: DateTimeUtc, - pub updated_at: DateTimeUtc, - #[sea_orm(has_many)] - pub locations: HasMany, - #[sea_orm(has_many)] - pub proxy_host_access_lists: HasMany, - #[sea_orm( - belongs_to, - from = "default_upstream_id", - to = "id", - on_update = "Cascade", - on_delete = "SetNull" - )] - pub upstream: HasOne, - #[sea_orm( - belongs_to, - from = "created_by", - to = "id", - on_update = "Cascade", - on_delete = "SetNull" - )] - pub user: HasOne, -} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/public/database/src/generated/entities/proxy_host_access_list.rs b/public/database/src/generated/entities/proxy_host_access_list.rs deleted file mode 100644 index 0818588..0000000 --- a/public/database/src/generated/entities/proxy_host_access_list.rs +++ /dev/null @@ -1,33 +0,0 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0.0-rc.18 - -use sea_orm::entity::prelude::*; -use serde::{Deserialize, Serialize}; - -#[sea_orm::model] -#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] -#[sea_orm(table_name = "proxy_host_access_list")] -pub struct Model { - #[sea_orm(primary_key, auto_increment = false)] - pub id: Uuid, - pub proxy_host_id: Uuid, - pub access_list_id: Uuid, - pub created_at: DateTimeUtc, - #[sea_orm( - belongs_to, - from = "access_list_id", - to = "id", - on_update = "Cascade", - on_delete = "Cascade" - )] - pub access_list: HasOne, - #[sea_orm( - belongs_to, - from = "proxy_host_id", - to = "id", - on_update = "Cascade", - on_delete = "Cascade" - )] - pub proxy_host: HasOne, -} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/public/database/src/generated/entities/session.rs b/public/database/src/generated/entities/session.rs deleted file mode 100644 index fc26f9f..0000000 --- a/public/database/src/generated/entities/session.rs +++ /dev/null @@ -1,29 +0,0 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0.0-rc.18 - -use sea_orm::entity::prelude::*; -use serde::{Deserialize, Serialize}; - -#[sea_orm::model] -#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] -#[sea_orm(table_name = "session")] -pub struct Model { - #[sea_orm(primary_key, auto_increment = false)] - pub id: Uuid, - pub user_id: Uuid, - #[sea_orm(unique)] - pub refresh_token_hash: Option, - pub expires_at: DateTimeUtc, - pub revoked_at: Option, - pub created_at: DateTimeUtc, - pub updated_at: DateTimeUtc, - #[sea_orm( - belongs_to, - from = "user_id", - to = "id", - on_update = "Cascade", - on_delete = "Cascade" - )] - pub user: HasOne, -} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/public/database/src/generated/entities/stream_service.rs b/public/database/src/generated/entities/stream_service.rs deleted file mode 100644 index 08a92fe..0000000 --- a/public/database/src/generated/entities/stream_service.rs +++ /dev/null @@ -1,47 +0,0 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0.0-rc.18 - -use sea_orm::entity::prelude::*; -use serde::{Deserialize, Serialize}; - -#[sea_orm::model] -#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] -#[sea_orm(table_name = "stream_service")] -pub struct Model { - #[sea_orm(primary_key, auto_increment = false)] - pub id: Uuid, - pub name: Option, - pub listen_host: String, - pub listen_port: i64, - pub protocol: String, - pub mode: String, - pub forward_host: Option, - pub forward_port: Option, - pub upstream_id: Option, - pub preserved_client_ip: bool, - pub enabled: bool, - #[sea_orm(column_type = "JsonBinary", nullable)] - pub meta: Option, - pub created_by: Option, - pub created_at: DateTimeUtc, - pub updated_at: DateTimeUtc, - #[sea_orm(has_many)] - pub stream_service_access_lists: HasMany, - #[sea_orm( - belongs_to, - from = "upstream_id", - to = "id", - on_update = "Cascade", - on_delete = "SetNull" - )] - pub upstream: HasOne, - #[sea_orm( - belongs_to, - from = "created_by", - to = "id", - on_update = "Cascade", - on_delete = "SetNull" - )] - pub user: HasOne, -} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/public/database/src/generated/entities/stream_service_access_list.rs b/public/database/src/generated/entities/stream_service_access_list.rs deleted file mode 100644 index 9ec8043..0000000 --- a/public/database/src/generated/entities/stream_service_access_list.rs +++ /dev/null @@ -1,33 +0,0 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0.0-rc.18 - -use sea_orm::entity::prelude::*; -use serde::{Deserialize, Serialize}; - -#[sea_orm::model] -#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] -#[sea_orm(table_name = "stream_service_access_list")] -pub struct Model { - #[sea_orm(primary_key, auto_increment = false)] - pub id: Uuid, - pub stream_service_id: Uuid, - pub access_list_id: Uuid, - pub created_at: DateTimeUtc, - #[sea_orm( - belongs_to, - from = "access_list_id", - to = "id", - on_update = "Cascade", - on_delete = "Cascade" - )] - pub access_list: HasOne, - #[sea_orm( - belongs_to, - from = "stream_service_id", - to = "id", - on_update = "Cascade", - on_delete = "Cascade" - )] - pub stream_service: HasOne, -} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/public/database/src/generated/entities/upstream.rs b/public/database/src/generated/entities/upstream.rs index 3490112..bbbe5de 100644 --- a/public/database/src/generated/entities/upstream.rs +++ b/public/database/src/generated/entities/upstream.rs @@ -17,12 +17,6 @@ pub struct Model { pub created_at: DateTimeUtc, pub updated_at: DateTimeUtc, #[sea_orm(has_many)] - pub locations: HasMany, - #[sea_orm(has_many)] - pub proxy_hosts: HasMany, - #[sea_orm(has_many)] - pub stream_services: HasMany, - #[sea_orm(has_many)] pub upstream_targets: HasMany, } diff --git a/public/database/src/generated/entities/user.rs b/public/database/src/generated/entities/user.rs index 6e043f4..ab2edb2 100644 --- a/public/database/src/generated/entities/user.rs +++ b/public/database/src/generated/entities/user.rs @@ -18,12 +18,6 @@ pub struct Model { pub last_login_at: Option, pub deleted_at: Option, #[sea_orm(has_many)] - pub access_lists: HasMany, - #[sea_orm(has_many)] - pub proxy_hosts: HasMany, - #[sea_orm(has_many)] - pub stream_services: HasMany, - #[sea_orm(has_many)] pub user_identities: HasMany, } diff --git a/public/migration/src/lib.rs b/public/migration/src/lib.rs index 920a97d..71c86cc 100644 --- a/public/migration/src/lib.rs +++ b/public/migration/src/lib.rs @@ -15,13 +15,7 @@ impl MigratorTrait for Migrator { Box::new(m20251011_000003_create_user_identity_table::Migration), Box::new(m20251223_000004_create_upstream_table::Migration), Box::new(m20251223_000005_create_upstream_target_table::Migration), - Box::new(m20251223_000006_create_proxy_host_table::Migration), - Box::new(m20251223_000007_create_location_table::Migration), - Box::new(m20251223_000008_create_stream_service_table::Migration), - Box::new(m20251223_000009_create_access_list_table::Migration), - Box::new(m20251223_000010_create_access_list_entry_table::Migration), - Box::new(m20251223_000011_create_proxy_host_access_list_table::Migration), - Box::new(m20251223_000012_create_stream_service_access_list_table::Migration), + Box::new(m20251230_000006_create_editing_session_table::Migration), ] } } diff --git a/public/migration/src/migrations.rs b/public/migration/src/migrations.rs index 1501f2a..55f0835 100644 --- a/public/migration/src/migrations.rs +++ b/public/migration/src/migrations.rs @@ -3,10 +3,4 @@ pub mod m20251011_000002_create_user_table; pub mod m20251011_000003_create_user_identity_table; pub mod m20251223_000004_create_upstream_table; pub mod m20251223_000005_create_upstream_target_table; -pub mod m20251223_000006_create_proxy_host_table; -pub mod m20251223_000007_create_location_table; -pub mod m20251223_000008_create_stream_service_table; -pub mod m20251223_000009_create_access_list_table; -pub mod m20251223_000010_create_access_list_entry_table; -pub mod m20251223_000011_create_proxy_host_access_list_table; -pub mod m20251223_000012_create_stream_service_access_list_table; +pub mod m20251230_000006_create_editing_session_table; diff --git a/public/migration/src/migrations/m20251223_000006_create_proxy_host_table.rs b/public/migration/src/migrations/m20251223_000006_create_proxy_host_table.rs deleted file mode 100644 index bcdd3fa..0000000 --- a/public/migration/src/migrations/m20251223_000006_create_proxy_host_table.rs +++ /dev/null @@ -1,124 +0,0 @@ -use sea_orm_migration::{prelude::*, schema::*}; - -#[derive(DeriveMigrationName)] -pub struct Migration; - -#[forbid(dead_code)] -#[derive(DeriveIden)] -pub enum ProxyHost { - Table, - Id, - Name, - Domain, - Scheme, - ListenPort, - ForwardScheme, - ForwardHost, - ForwardPort, - PreserveHostHeader, - EnableWebsocket, - Enabled, - Meta, - DefaultUpstreamId, - CreatedBy, - CreatedAt, - UpdatedAt, -} - -#[async_trait::async_trait] -impl MigrationTrait for Migration { - async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { - manager - .create_table( - Table::create() - .table(ProxyHost::Table) - .if_not_exists() - .col(pk_uuid(ProxyHost::Id)) - .col(ColumnDef::new(ProxyHost::Name).string().null()) - .col(ColumnDef::new(ProxyHost::Domain).string().not_null()) - .col( - ColumnDef::new(ProxyHost::Scheme) - .string() - .default("http") - .not_null(), - ) - .col( - ColumnDef::new(ProxyHost::ListenPort) - .integer() - .default(80) - .not_null(), - ) - .col( - ColumnDef::new(ProxyHost::ForwardScheme) - .string() - .default("http") - .not_null(), - ) - .col(ColumnDef::new(ProxyHost::ForwardHost).string().null()) - .col(ColumnDef::new(ProxyHost::ForwardPort).integer().null()) - .col( - ColumnDef::new(ProxyHost::PreserveHostHeader) - .boolean() - .default(false) - .not_null(), - ) - .col( - ColumnDef::new(ProxyHost::EnableWebsocket) - .boolean() - .default(false) - .not_null(), - ) - .col( - ColumnDef::new(ProxyHost::Enabled) - .boolean() - .default(true) - .not_null(), - ) - .col(ColumnDef::new(ProxyHost::Meta).json_binary().null()) - .col(ColumnDef::new(ProxyHost::DefaultUpstreamId).uuid().null()) - .foreign_key( - ForeignKey::create() - .name("fk-proxy-host-default-upstream-id") - .from(ProxyHost::Table, ProxyHost::DefaultUpstreamId) - .to( - super::m20251223_000004_create_upstream_table::Upstream::Table, - super::m20251223_000004_create_upstream_table::Upstream::Id, - ) - .on_delete(ForeignKeyAction::SetNull) - .on_update(ForeignKeyAction::Cascade), - ) - .col(ColumnDef::new(ProxyHost::CreatedBy).uuid().null()) - .foreign_key( - ForeignKey::create() - .name("fk-proxy-host-created-by") - .from(ProxyHost::Table, ProxyHost::CreatedBy) - .to( - super::m20251011_000002_create_user_table::User::Table, - super::m20251011_000002_create_user_table::User::Id, - ) - .on_delete(ForeignKeyAction::SetNull) - .on_update(ForeignKeyAction::Cascade), - ) - .col( - ColumnDef::new(ProxyHost::CreatedAt) - .timestamp() - .default(SimpleExpr::Keyword(Keyword::CurrentTimestamp)) - .not_null(), - ) - .col( - ColumnDef::new(ProxyHost::UpdatedAt) - .timestamp() - .default(SimpleExpr::Keyword(Keyword::CurrentTimestamp)) - .not_null(), - ) - .to_owned(), - ) - .await - } - - async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { - manager - .drop_table(Table::drop().table(ProxyHost::Table).to_owned()) - .await - } -} diff --git a/public/migration/src/migrations/m20251223_000007_create_location_table.rs b/public/migration/src/migrations/m20251223_000007_create_location_table.rs deleted file mode 100644 index 5da97d4..0000000 --- a/public/migration/src/migrations/m20251223_000007_create_location_table.rs +++ /dev/null @@ -1,100 +0,0 @@ -use sea_orm_migration::{prelude::*, schema::*}; - -#[derive(DeriveMigrationName)] -pub struct Migration; - -#[forbid(dead_code)] -#[derive(DeriveIden)] -pub enum Location { - Table, - Id, - HostId, - Path, - MatchType, - Order, - UpstreamId, - ProxyPassHost, - ProxyPassPort, - PreserveHostHeader, - AllowedMethods, - CustomConfig, - Enabled, - CreatedAt, - UpdatedAt, -} - -#[async_trait::async_trait] -impl MigrationTrait for Migration { - async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { - manager - .create_table( - Table::create() - .table(Location::Table) - .if_not_exists() - .col(pk_uuid(Location::Id)) - .col(ColumnDef::new(Location::HostId).uuid().not_null()) - .foreign_key( - ForeignKey::create() - .name("fk-location-host-id") - .from(Location::Table, Location::HostId) - .to( - super::m20251223_000006_create_proxy_host_table::ProxyHost::Table, - super::m20251223_000006_create_proxy_host_table::ProxyHost::Id, - ) - .on_delete(ForeignKeyAction::Cascade) - .on_update(ForeignKeyAction::Cascade), - ) - .col(ColumnDef::new(Location::Path).string().not_null()) - .col( - ColumnDef::new(Location::MatchType) - .string() - .default("prefix") - .not_null(), - ) - .col(ColumnDef::new(Location::Order).integer().default(0).not_null()) - .col(ColumnDef::new(Location::UpstreamId).uuid().null()) - .foreign_key( - ForeignKey::create() - .name("fk-location-upstream-id") - .from(Location::Table, Location::UpstreamId) - .to( - super::m20251223_000004_create_upstream_table::Upstream::Table, - super::m20251223_000004_create_upstream_table::Upstream::Id, - ) - .on_delete(ForeignKeyAction::SetNull) - .on_update(ForeignKeyAction::Cascade), - ) - .col(ColumnDef::new(Location::ProxyPassHost).string().null()) - .col(ColumnDef::new(Location::ProxyPassPort).integer().null()) - .col(ColumnDef::new(Location::PreserveHostHeader).boolean().null()) - .col(ColumnDef::new(Location::AllowedMethods).json_binary().null()) - .col(ColumnDef::new(Location::CustomConfig).text().null()) - .col( - ColumnDef::new(Location::Enabled) - .boolean() - .default(true) - .not_null(), - ) - .col( - ColumnDef::new(Location::CreatedAt) - .timestamp() - .default(SimpleExpr::Keyword(Keyword::CurrentTimestamp)) - .not_null(), - ) - .col( - ColumnDef::new(Location::UpdatedAt) - .timestamp() - .default(SimpleExpr::Keyword(Keyword::CurrentTimestamp)) - .not_null(), - ) - .to_owned(), - ) - .await - } - - async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { - manager - .drop_table(Table::drop().table(Location::Table).to_owned()) - .await - } -} diff --git a/public/migration/src/migrations/m20251223_000008_create_stream_service_table.rs b/public/migration/src/migrations/m20251223_000008_create_stream_service_table.rs deleted file mode 100644 index e99ae60..0000000 --- a/public/migration/src/migrations/m20251223_000008_create_stream_service_table.rs +++ /dev/null @@ -1,112 +0,0 @@ -use sea_orm_migration::{prelude::*, schema::*}; - -#[derive(DeriveMigrationName)] -pub struct Migration; - -#[forbid(dead_code)] -#[derive(DeriveIden)] -pub enum StreamService { - Table, - Id, - Name, - ListenHost, - ListenPort, - Protocol, - Mode, - ForwardHost, - ForwardPort, - UpstreamId, - PreservedClientIp, - Enabled, - Meta, - CreatedBy, - CreatedAt, - UpdatedAt, -} - -#[async_trait::async_trait] -impl MigrationTrait for Migration { - async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { - manager - .create_table( - Table::create() - .table(StreamService::Table) - .if_not_exists() - .col(pk_uuid(StreamService::Id)) - .col(ColumnDef::new(StreamService::Name).string().null()) - .col( - ColumnDef::new(StreamService::ListenHost) - .string() - .default("0.0.0.0") - .not_null(), - ) - .col(ColumnDef::new(StreamService::ListenPort).integer().not_null()) - .col(ColumnDef::new(StreamService::Protocol).string().not_null()) - .col( - ColumnDef::new(StreamService::Mode) - .string() - .default("direct") - .not_null(), - ) - .col(ColumnDef::new(StreamService::ForwardHost).string().null()) - .col(ColumnDef::new(StreamService::ForwardPort).integer().null()) - .col(ColumnDef::new(StreamService::UpstreamId).uuid().null()) - .foreign_key( - ForeignKey::create() - .name("fk-stream-service-upstream-id") - .from(StreamService::Table, StreamService::UpstreamId) - .to( - super::m20251223_000004_create_upstream_table::Upstream::Table, - super::m20251223_000004_create_upstream_table::Upstream::Id, - ) - .on_delete(ForeignKeyAction::SetNull) - .on_update(ForeignKeyAction::Cascade), - ) - .col( - ColumnDef::new(StreamService::PreservedClientIp) - .boolean() - .default(false) - .not_null(), - ) - .col( - ColumnDef::new(StreamService::Enabled) - .boolean() - .default(true) - .not_null(), - ) - .col(ColumnDef::new(StreamService::Meta).json_binary().null()) - .col(ColumnDef::new(StreamService::CreatedBy).uuid().null()) - .foreign_key( - ForeignKey::create() - .name("fk-stream-service-created-by") - .from(StreamService::Table, StreamService::CreatedBy) - .to( - super::m20251011_000002_create_user_table::User::Table, - super::m20251011_000002_create_user_table::User::Id, - ) - .on_delete(ForeignKeyAction::SetNull) - .on_update(ForeignKeyAction::Cascade), - ) - .col( - ColumnDef::new(StreamService::CreatedAt) - .timestamp() - .default(SimpleExpr::Keyword(Keyword::CurrentTimestamp)) - .not_null(), - ) - .col( - ColumnDef::new(StreamService::UpdatedAt) - .timestamp() - .default(SimpleExpr::Keyword(Keyword::CurrentTimestamp)) - .not_null(), - ) - .to_owned(), - ) - .await - } - - async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { - manager - .drop_table(Table::drop().table(StreamService::Table).to_owned()) - .await - } -} diff --git a/public/migration/src/migrations/m20251223_000009_create_access_list_table.rs b/public/migration/src/migrations/m20251223_000009_create_access_list_table.rs deleted file mode 100644 index 7d947b7..0000000 --- a/public/migration/src/migrations/m20251223_000009_create_access_list_table.rs +++ /dev/null @@ -1,63 +0,0 @@ -use sea_orm_migration::{prelude::*, schema::*}; - -#[derive(DeriveMigrationName)] -pub struct Migration; - -#[forbid(dead_code)] -#[derive(DeriveIden)] -pub enum AccessList { - Table, - Id, - Name, - Description, - CreatedBy, - CreatedAt, - UpdatedAt, -} - -#[async_trait::async_trait] -impl MigrationTrait for Migration { - async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { - manager - .create_table( - Table::create() - .table(AccessList::Table) - .if_not_exists() - .col(pk_uuid(AccessList::Id)) - .col(ColumnDef::new(AccessList::Name).string().not_null()) - .col(ColumnDef::new(AccessList::Description).text().null()) - .col(ColumnDef::new(AccessList::CreatedBy).uuid().null()) - .foreign_key( - ForeignKey::create() - .name("fk-access-list-created-by") - .from(AccessList::Table, AccessList::CreatedBy) - .to( - super::m20251011_000002_create_user_table::User::Table, - super::m20251011_000002_create_user_table::User::Id, - ) - .on_delete(ForeignKeyAction::SetNull) - .on_update(ForeignKeyAction::Cascade), - ) - .col( - ColumnDef::new(AccessList::CreatedAt) - .timestamp() - .default(SimpleExpr::Keyword(Keyword::CurrentTimestamp)) - .not_null(), - ) - .col( - ColumnDef::new(AccessList::UpdatedAt) - .timestamp() - .default(SimpleExpr::Keyword(Keyword::CurrentTimestamp)) - .not_null(), - ) - .to_owned(), - ) - .await - } - - async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { - manager - .drop_table(Table::drop().table(AccessList::Table).to_owned()) - .await - } -} diff --git a/public/migration/src/migrations/m20251223_000010_create_access_list_entry_table.rs b/public/migration/src/migrations/m20251223_000010_create_access_list_entry_table.rs deleted file mode 100644 index 2dea34e..0000000 --- a/public/migration/src/migrations/m20251223_000010_create_access_list_entry_table.rs +++ /dev/null @@ -1,58 +0,0 @@ -use sea_orm_migration::{prelude::*, schema::*}; - -#[derive(DeriveMigrationName)] -pub struct Migration; - -#[forbid(dead_code)] -#[derive(DeriveIden)] -pub enum AccessListEntry { - Table, - Id, - AccessListId, - EntryType, - Value, - Comment, - CreatedAt, -} - -#[async_trait::async_trait] -impl MigrationTrait for Migration { - async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { - manager - .create_table( - Table::create() - .table(AccessListEntry::Table) - .if_not_exists() - .col(pk_uuid(AccessListEntry::Id)) - .col(ColumnDef::new(AccessListEntry::AccessListId).uuid().not_null()) - .foreign_key( - ForeignKey::create() - .name("fk-access-list-entry-access-list-id") - .from(AccessListEntry::Table, AccessListEntry::AccessListId) - .to( - super::m20251223_000009_create_access_list_table::AccessList::Table, - super::m20251223_000009_create_access_list_table::AccessList::Id, - ) - .on_delete(ForeignKeyAction::Cascade) - .on_update(ForeignKeyAction::Cascade), - ) - .col(ColumnDef::new(AccessListEntry::EntryType).string().not_null()) - .col(ColumnDef::new(AccessListEntry::Value).string().not_null()) - .col(ColumnDef::new(AccessListEntry::Comment).text().null()) - .col( - ColumnDef::new(AccessListEntry::CreatedAt) - .timestamp() - .default(SimpleExpr::Keyword(Keyword::CurrentTimestamp)) - .not_null(), - ) - .to_owned(), - ) - .await - } - - async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { - manager - .drop_table(Table::drop().table(AccessListEntry::Table).to_owned()) - .await - } -} diff --git a/public/migration/src/migrations/m20251223_000011_create_proxy_host_access_list_table.rs b/public/migration/src/migrations/m20251223_000011_create_proxy_host_access_list_table.rs deleted file mode 100644 index 27f085e..0000000 --- a/public/migration/src/migrations/m20251223_000011_create_proxy_host_access_list_table.rs +++ /dev/null @@ -1,65 +0,0 @@ -use sea_orm_migration::{prelude::*, schema::*}; - -#[derive(DeriveMigrationName)] -pub struct Migration; - -#[forbid(dead_code)] -#[derive(DeriveIden)] -pub enum ProxyHostAccessList { - Table, - Id, - ProxyHostId, - AccessListId, - CreatedAt, -} - -#[async_trait::async_trait] -impl MigrationTrait for Migration { - async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { - manager - .create_table( - Table::create() - .table(ProxyHostAccessList::Table) - .if_not_exists() - .col(pk_uuid(ProxyHostAccessList::Id)) - .col(ColumnDef::new(ProxyHostAccessList::ProxyHostId).uuid().not_null()) - .foreign_key( - ForeignKey::create() - .name("fk-proxy-host-access-list-host-id") - .from(ProxyHostAccessList::Table, ProxyHostAccessList::ProxyHostId) - .to( - super::m20251223_000006_create_proxy_host_table::ProxyHost::Table, - super::m20251223_000006_create_proxy_host_table::ProxyHost::Id, - ) - .on_delete(ForeignKeyAction::Cascade) - .on_update(ForeignKeyAction::Cascade), - ) - .col(ColumnDef::new(ProxyHostAccessList::AccessListId).uuid().not_null()) - .foreign_key( - ForeignKey::create() - .name("fk-proxy-host-access-list-access-list-id") - .from(ProxyHostAccessList::Table, ProxyHostAccessList::AccessListId) - .to( - super::m20251223_000009_create_access_list_table::AccessList::Table, - super::m20251223_000009_create_access_list_table::AccessList::Id, - ) - .on_delete(ForeignKeyAction::Cascade) - .on_update(ForeignKeyAction::Cascade), - ) - .col( - ColumnDef::new(ProxyHostAccessList::CreatedAt) - .timestamp() - .default(SimpleExpr::Keyword(Keyword::CurrentTimestamp)) - .not_null(), - ) - .to_owned(), - ) - .await - } - - async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { - manager - .drop_table(Table::drop().table(ProxyHostAccessList::Table).to_owned()) - .await - } -} diff --git a/public/migration/src/migrations/m20251223_000012_create_stream_service_access_list_table.rs b/public/migration/src/migrations/m20251223_000012_create_stream_service_access_list_table.rs deleted file mode 100644 index 84d8b41..0000000 --- a/public/migration/src/migrations/m20251223_000012_create_stream_service_access_list_table.rs +++ /dev/null @@ -1,65 +0,0 @@ -use sea_orm_migration::{prelude::*, schema::*}; - -#[derive(DeriveMigrationName)] -pub struct Migration; - -#[forbid(dead_code)] -#[derive(DeriveIden)] -pub enum StreamServiceAccessList { - Table, - Id, - StreamServiceId, - AccessListId, - CreatedAt, -} - -#[async_trait::async_trait] -impl MigrationTrait for Migration { - async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { - manager - .create_table( - Table::create() - .table(StreamServiceAccessList::Table) - .if_not_exists() - .col(pk_uuid(StreamServiceAccessList::Id)) - .col(ColumnDef::new(StreamServiceAccessList::StreamServiceId).uuid().not_null()) - .foreign_key( - ForeignKey::create() - .name("fk-stream-service-access-list-service-id") - .from(StreamServiceAccessList::Table, StreamServiceAccessList::StreamServiceId) - .to( - super::m20251223_000008_create_stream_service_table::StreamService::Table, - super::m20251223_000008_create_stream_service_table::StreamService::Id, - ) - .on_delete(ForeignKeyAction::Cascade) - .on_update(ForeignKeyAction::Cascade), - ) - .col(ColumnDef::new(StreamServiceAccessList::AccessListId).uuid().not_null()) - .foreign_key( - ForeignKey::create() - .name("fk-stream-service-access-list-access-list-id") - .from(StreamServiceAccessList::Table, StreamServiceAccessList::AccessListId) - .to( - super::m20251223_000009_create_access_list_table::AccessList::Table, - super::m20251223_000009_create_access_list_table::AccessList::Id, - ) - .on_delete(ForeignKeyAction::Cascade) - .on_update(ForeignKeyAction::Cascade), - ) - .col( - ColumnDef::new(StreamServiceAccessList::CreatedAt) - .timestamp() - .default(SimpleExpr::Keyword(Keyword::CurrentTimestamp)) - .not_null(), - ) - .to_owned(), - ) - .await - } - - async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { - manager - .drop_table(Table::drop().table(StreamServiceAccessList::Table).to_owned()) - .await - } -} diff --git a/public/migration/src/migrations/m20251230_000006_create_editing_session_table.rs b/public/migration/src/migrations/m20251230_000006_create_editing_session_table.rs new file mode 100644 index 0000000..2765fa3 --- /dev/null +++ b/public/migration/src/migrations/m20251230_000006_create_editing_session_table.rs @@ -0,0 +1,117 @@ +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[forbid(dead_code)] +#[derive(DeriveIden)] +pub enum EditingSession { + Table, + Id, + Name, + CreatedBy, + Status, + CreatedAt, + AppliedAt, + AppliedBy, + ExpiresAt, +} + +#[forbid(dead_code)] +#[derive(DeriveIden)] +pub enum EditOperation { + Table, + Id, + SessionId, + ResourceType, + ResourceId, + OperationType, + Payload, + CreatedAt, + AppliedAt, +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(EditingSession::Table) + .if_not_exists() + .col(pk_uuid(EditingSession::Id)) + .col(ColumnDef::new(EditingSession::Name).string().null()) + .col(ColumnDef::new(EditingSession::CreatedBy).uuid().null()) + .col( + ColumnDef::new(EditingSession::Status) + .string() + .default("pending") + .not_null(), + ) + .col( + ColumnDef::new(EditingSession::CreatedAt) + .timestamp() + .default(SimpleExpr::Keyword(Keyword::CurrentTimestamp)) + .not_null(), + ) + .col(ColumnDef::new(EditingSession::AppliedAt).timestamp().null()) + .col(ColumnDef::new(EditingSession::AppliedBy).uuid().null()) + .col(ColumnDef::new(EditingSession::ExpiresAt).timestamp().null()) + .to_owned(), + ) + .await?; + + manager + .create_table( + Table::create() + .table(EditOperation::Table) + .if_not_exists() + .col(pk_uuid(EditOperation::Id)) + .col(ColumnDef::new(EditOperation::SessionId).uuid().not_null()) + .col( + ColumnDef::new(EditOperation::ResourceType) + .string() + .not_null(), + ) // e.g. "upstream", "location" + .col(ColumnDef::new(EditOperation::ResourceId).uuid().null()) // null for create + .col( + ColumnDef::new(EditOperation::OperationType) + .string() + .not_null(), + ) // "create"|"update"|"delete" + .col( + ColumnDef::new(EditOperation::Payload) + .json_binary() + .not_null(), + ) // patch or full object + .col( + ColumnDef::new(EditOperation::CreatedAt) + .timestamp() + .default(SimpleExpr::Keyword(Keyword::CurrentTimestamp)) + .not_null(), + ) + .col(ColumnDef::new(EditOperation::AppliedAt).timestamp().null()) + .foreign_key( + ForeignKey::create() + .name("fk-edit-op-session") + .from(EditOperation::Table, EditOperation::SessionId) + .to(EditingSession::Table, EditingSession::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(EditOperation::Table).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(EditingSession::Table).to_owned()) + .await + } +} -- 2.49.1 From f4db47daf28a6ff38c6703c32947f20a6c7d7031 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Tue, 30 Dec 2025 15:09:49 +0800 Subject: [PATCH 23/43] feat: implement update handlers for upstream and upstream target management --- .../routes/api/restricted/nginx/upstream.rs | 10 +- .../nginx/upstream/info/response.rs | 68 ++++++ .../nginx/upstream/update_upstream.rs | 203 +++++++++++++++++ .../nginx/upstream/update_upstream_target.rs | 206 ++++++++++++++++++ apps/api/src/services/nginx/info/upstream.rs | 30 ++- .../services/nginx/info/upstream_target.rs | 17 +- apps/api/src/services/nginx/upstream.rs | 56 +++-- 7 files changed, 555 insertions(+), 35 deletions(-) create mode 100644 apps/api/src/routes/api/restricted/nginx/upstream/update_upstream.rs create mode 100644 apps/api/src/routes/api/restricted/nginx/upstream/update_upstream_target.rs diff --git a/apps/api/src/routes/api/restricted/nginx/upstream.rs b/apps/api/src/routes/api/restricted/nginx/upstream.rs index 717a043..fc5fe99 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream.rs @@ -3,6 +3,8 @@ pub mod create_upstream_target; pub mod get_upstream; pub mod get_upstream_target; pub mod info; +pub mod update_upstream; +pub mod update_upstream_target; use std::sync::Arc; @@ -19,14 +21,18 @@ pub fn get_upstream_router(state: Arc) -> Router { "/upstreams", get(get_upstream::get_upstream_list).post(create_upstream::create_upstream), ) - .route("/upstreams/{upstream_id}", get(get_upstream::get_upstream)) + .route( + "/upstreams/{upstream_id}", + get(get_upstream::get_upstream).patch(update_upstream::update_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), + get(get_upstream_target::get_upstream_target) + .patch(update_upstream_target::update_upstream_target), ) .with_state(state) } diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/info/response.rs b/apps/api/src/routes/api/restricted/nginx/upstream/info/response.rs index efdc428..0a8d717 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream/info/response.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream/info/response.rs @@ -162,3 +162,71 @@ impl From } } } + +#[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, + pub created_at: DateTime, + pub updated_at: DateTime, + // + pub upstream_targets: Vec, +} + +impl From 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, + pub updated_at: DateTime, + // + pub upstream_id: Uuid, +} + +impl From + 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, + } + } +} diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/update_upstream.rs b/apps/api/src/routes/api/restricted/nginx/upstream/update_upstream.rs new file mode 100644 index 0000000..a287671 --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/upstream/update_upstream.rs @@ -0,0 +1,203 @@ +use std::sync::Arc; + +use axum::{ + Json, + extract::{Path, State}, + response::Result as AxumResult, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + errors::api_error::ApiError, + middlewares::request_info::AuthenticatedRequestInfo, + routes::{ + AppState, 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, + pub protocol: Option, + pub algorithm: Option, + pub sticky_session: Option, + // only updates upstream targets' enabled status for now + pub upstream_targets: Option>, +} + +impl From 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, + } + } +} + +pub async fn update_upstream( + _request_info: AuthenticatedRequestInfo, + Path(upstream_id): Path, + State(state): State>, + Json(payload): Json, +) -> AxumResult, ApiError> { + let upstream_service = &state.service.nginx.get_upstream_service(); + let update_info: UpdateUpstreamInfo = payload.into(); + + let r = upstream_service + .update_upstream(upstream_id, update_info, None) + .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}; + + use database::generated::entities::upstream; + + use crate::{ + configs::{FromConfig, ProgramSettings}, + middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER, + routes::api::restricted::nginx::upstream::get_upstream_router, + services::get_app_service, + }; + use super::UpdateUpstreamRequestBody; + + fn get_router_with_state(db: DatabaseConnection) -> axum::Router { + let program_settings = ProgramSettings::mock(); + let app_service = get_app_service(&Arc::new(db.clone()), &program_settings); + let state = Arc::new(crate::routes::AppState { + database_connection: Arc::new(db), + service: Arc::new(app_service), + config: Arc::new(program_settings), + }); + get_upstream_router(state).layer(axum::middleware::from_fn( + crate::middlewares::require_auth::mock::mock_require_auth, + )) + } + + #[tokio::test] + async fn handler_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 first: Vec> = vec![vec![current_model.clone()]]; + let second: Vec> = vec![vec![updated_model.clone()]]; + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(first) + .append_query_results(second) + .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_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![Vec::::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); + } +} diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/update_upstream_target.rs b/apps/api/src/routes/api/restricted/nginx/upstream/update_upstream_target.rs new file mode 100644 index 0000000..814ce69 --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/upstream/update_upstream_target.rs @@ -0,0 +1,206 @@ +use std::sync::Arc; + +use axum::{ + Json, + extract::{Path, State}, + response::Result as AxumResult, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + errors::api_error::ApiError, + middlewares::request_info::AuthenticatedRequestInfo, + routes::{ + AppState, + api::restricted::nginx::upstream::info::response::UpdateUpstreamTargetInfoResponse, + }, + services::nginx::info::upstream_target::UpdateUpstreamTargetInfo, +}; + +#[derive(Deserialize, utoipa::ToSchema, Serialize)] +pub struct UpdateUpstreamTargetRequestBody { + pub host: Option, + pub port: Option, + pub enabled: Option, + pub is_backup: Option, + pub weight: Option, +} + +impl From 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), + } + } +} + +pub async fn update_upstream_target( + _request_info: AuthenticatedRequestInfo, + Path(upstream_target_id): Path, + State(state): State>, + Json(payload): Json, +) -> AxumResult, ApiError> { + let upstream_service = &state.service.nginx.get_upstream_service(); + let update_info: UpdateUpstreamTargetInfo = payload.into(); + + let r = upstream_service + .update_upstream_target(upstream_target_id, update_info, None) + .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_target; + + use super::UpdateUpstreamTargetRequestBody; + use crate::{ + configs::{FromConfig, ProgramSettings}, + middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER, + 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), + }); + + 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 first: Vec> = vec![vec![current_model.clone()]]; + let second: Vec> = vec![vec![updated_model.clone()]]; + + let db = MockDatabase::new(DatabaseBackend::Sqlite) + .append_query_results(first) + .append_query_results(second) + .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![Vec::::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); + } +} diff --git a/apps/api/src/services/nginx/info/upstream.rs b/apps/api/src/services/nginx/info/upstream.rs index 8389109..0353d0f 100644 --- a/apps/api/src/services/nginx/info/upstream.rs +++ b/apps/api/src/services/nginx/info/upstream.rs @@ -2,6 +2,7 @@ use chrono::{DateTime, Utc}; use optfield::optfield; use database::generated::entities::{upstream, upstream_target}; +use sea_orm::ActiveValue::{Set, Unchanged}; use uuid::Uuid; use crate::{ @@ -13,15 +14,14 @@ use crate::{ set_if_some, }; -#[optfield(pub UpdateUpstreamInfo)] #[derive(Clone)] pub struct UpstreamInfo { - pub id: uuid::Uuid, + pub id: Uuid, pub name: String, pub protocol: String, pub algorithm: String, pub sticky_session: bool, - pub created_by: Option, + pub created_by: Option, pub created_at: DateTime, pub updated_at: DateTime, // @@ -33,11 +33,21 @@ pub struct UpstreamCreateInfo { pub protocol: String, pub algorithm: String, pub sticky_session: bool, - pub created_by: Option, + pub created_by: Option, // pub upstream_targets: Vec, } +#[derive(Clone)] +pub struct UpdateUpstreamInfo { + pub name: Option, + pub protocol: Option, + pub algorithm: Option, + pub sticky_session: Option, + // + pub upstream_targets: Option>, +} + impl NginxConfigProvider for UpstreamInfo { fn to_nginx_config(&self, indent: Option) -> String { let targets_config: Vec = self @@ -142,18 +152,14 @@ impl From for (upstream::ActiveModel, Vec upstream::ActiveModel { upstream::ActiveModel { - id: sea_orm::ActiveValue::Unchanged(current_model.id), + 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: set_if_some!(if self.created_by.is_some() { - Some(self.created_by) - } else { - None - }), - created_at: set_if_some!(self.created_at), - updated_at: set_if_some!(self.updated_at), + created_by: Unchanged(current_model.created_by), + created_at: Unchanged(current_model.created_at), + updated_at: Set(chrono::Utc::now()), } } } diff --git a/apps/api/src/services/nginx/info/upstream_target.rs b/apps/api/src/services/nginx/info/upstream_target.rs index 3331098..129bae3 100644 --- a/apps/api/src/services/nginx/info/upstream_target.rs +++ b/apps/api/src/services/nginx/info/upstream_target.rs @@ -1,5 +1,4 @@ use chrono::{DateTime, Utc}; -use optfield::optfield; use sea_orm::ActiveValue::{Set, Unchanged}; use uuid::Uuid; @@ -11,7 +10,6 @@ use crate::{ set_if_some, }; -#[optfield(pub UpdateUpstreamTargetInfo)] #[derive(Clone)] pub struct UpstreamTargetInfo { pub id: uuid::Uuid, @@ -27,6 +25,15 @@ pub struct UpstreamTargetInfo { pub upstream: Option, } +#[derive(Clone)] +pub struct UpdateUpstreamTargetInfo { + pub target_host: Option, + pub target_port: Option, + pub weight: Option, + pub is_backup: Option, + pub enabled: Option, +} + #[derive(Clone)] pub struct UpstreamBasicInfo { pub id: uuid::Uuid, @@ -146,9 +153,9 @@ impl UpdateUpstreamTargetInfo { weight: set_if_some!(self.weight), is_backup: set_if_some!(self.is_backup), enabled: set_if_some!(self.enabled), - created_at: set_if_some!(self.created_at), - updated_at: set_if_some!(self.updated_at), - upstream_id: set_if_some!(self.upstream_id), + created_at: Unchanged(current_model.created_at), + updated_at: Set(chrono::Utc::now()), + upstream_id: Unchanged(current_model.upstream_id), } } } diff --git a/apps/api/src/services/nginx/upstream.rs b/apps/api/src/services/nginx/upstream.rs index f9fd054..4022128 100644 --- a/apps/api/src/services/nginx/upstream.rs +++ b/apps/api/src/services/nginx/upstream.rs @@ -142,7 +142,16 @@ impl UpstreamService { upstream: UpdateUpstreamInfo, tx: Option<&mut DatabaseTransaction>, ) -> Result { - let current_model = with_conn!(&*self.connection, tx, conn, { + // If a transaction was provided use it, otherwise create and own one here. + let mut maybe_owned_tx: Option = 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? @@ -151,9 +160,36 @@ impl UpstreamService { id )))? }); - let active_model = upstream.apply_to_model(current_model); + let upstream_active_model = upstream.clone().apply_to_model(current_model); - let r = active_model.update(&*self.connection).await?; + 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()) } @@ -494,14 +530,10 @@ mod tests { let svc = UpstreamService::new(Arc::new(db)); let update_info = crate::services::nginx::info::upstream::UpdateUpstreamInfo { - id: None, name: None, protocol: None, algorithm: None, sticky_session: None, - created_by: None, - created_at: None, - updated_at: None, upstream_targets: None, }; let res = svc.update_upstream(id, update_info, None).await; @@ -522,14 +554,11 @@ mod tests { .update_upstream( uuid::Uuid::new_v4(), crate::services::nginx::info::upstream::UpdateUpstreamInfo { - id: None, name: None, protocol: None, algorithm: None, sticky_session: None, - created_by: None, - created_at: None, - updated_at: None, + upstream_targets: None, }, None, @@ -650,16 +679,11 @@ mod tests { let svc = UpstreamService::new(Arc::new(db)); let update_info = crate::services::nginx::info::upstream_target::UpdateUpstreamTargetInfo { - id: None, target_host: None, target_port: None, weight: None, is_backup: None, enabled: None, - created_at: None, - updated_at: None, - upstream_id: None, - upstream: None, }; let res = svc.update_upstream_target(id, update_info, None).await; assert!(res.is_ok()); -- 2.49.1 From f05544267c99a1935d4f3309d69172be9254b0a2 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Tue, 30 Dec 2025 18:02:46 +0800 Subject: [PATCH 24/43] feat: add remove upstream and remove upstream target handlers --- .../routes/api/restricted/nginx/upstream.rs | 9 +- .../nginx/upstream/remove_upstream.rs | 123 ++++++++++++++++++ .../nginx/upstream/remove_upstream_target.rs | 123 ++++++++++++++++++ apps/api/src/services/nginx/upstream.rs | 5 + 4 files changed, 258 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream.rs create mode 100644 apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream_target.rs diff --git a/apps/api/src/routes/api/restricted/nginx/upstream.rs b/apps/api/src/routes/api/restricted/nginx/upstream.rs index fc5fe99..00fd867 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream.rs @@ -3,6 +3,8 @@ 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; @@ -23,7 +25,9 @@ pub fn get_upstream_router(state: Arc) -> Router { ) .route( "/upstreams/{upstream_id}", - get(get_upstream::get_upstream).patch(update_upstream::update_upstream), + get(get_upstream::get_upstream) + .patch(update_upstream::update_upstream) + .delete(remove_upstream::remove_upstream), ) .route( "/upstreams/{upstream_id}/targets", @@ -32,7 +36,8 @@ pub fn get_upstream_router(state: Arc) -> Router { .route( "/upstream_targets/{upstream_target_id}", get(get_upstream_target::get_upstream_target) - .patch(update_upstream_target::update_upstream_target), + .patch(update_upstream_target::update_upstream_target) + .delete(remove_upstream_target::remove_upstream_target), ) .with_state(state) } diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream.rs b/apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream.rs new file mode 100644 index 0000000..419d8f5 --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream.rs @@ -0,0 +1,123 @@ +use std::sync::Arc; + +use axum::{ + Json, + extract::{Path, State}, + response::Result as AxumResult, +}; +use uuid::Uuid; + +use crate::{ + errors::api_error::ApiError, middlewares::request_info::AuthenticatedRequestInfo, + routes::AppState, +}; + +pub async fn remove_upstream( + _request_info: AuthenticatedRequestInfo, + Path(upstream_id): Path, + State(state): State>, +) -> AxumResult, ApiError> { + let upstream_service = &state.service.nginx.get_upstream_service(); + + upstream_service.delete_upstream(upstream_id, None).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}; + + use database::generated::entities::upstream; + + use crate::{ + configs::{FromConfig, ProgramSettings}, + middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER, + routes::api::restricted::nginx::upstream::get_upstream_router, + services::get_app_service, + }; + + fn get_router_with_state(db: DatabaseConnection) -> axum::Router { + let program_settings = ProgramSettings::mock(); + let app_service = get_app_service(&Arc::new(db.clone()), &program_settings); + let state = Arc::new(crate::routes::AppState { + database_connection: Arc::new(db), + service: Arc::new(app_service), + config: Arc::new(program_settings), + }); + get_upstream_router(state).layer(axum::middleware::from_fn( + crate::middlewares::require_auth::mock::mock_require_auth, + )) + } + + #[tokio::test] + async fn handler_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 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 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_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![Vec::::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); + } +} diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream_target.rs b/apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream_target.rs new file mode 100644 index 0000000..3784352 --- /dev/null +++ b/apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream_target.rs @@ -0,0 +1,123 @@ +use std::sync::Arc; + +use axum::{ + Json, + extract::{Path, State}, + response::Result as AxumResult, +}; +use uuid::Uuid; + +use crate::{ + errors::api_error::ApiError, middlewares::request_info::AuthenticatedRequestInfo, + routes::AppState, +}; + +pub async fn remove_upstream_target( + _request_info: AuthenticatedRequestInfo, + Path(upstream_target_id): Path, + State(state): State>, +) -> AxumResult, ApiError> { + let upstream_service = &state.service.nginx.get_upstream_service(); + + upstream_service + .delete_upstream_target(upstream_target_id, None) + .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}; + + use database::generated::entities::upstream_target; + + use crate::{ + configs::{FromConfig, ProgramSettings}, + middlewares::require_auth::mock::REQUEST_AUTH_USER_INVALID_HEADER, + routes::api::restricted::nginx::upstream::get_upstream_router, + services::get_app_service, + }; + + fn get_router_with_state(db: DatabaseConnection) -> axum::Router { + let program_settings = ProgramSettings::mock(); + let app_service = get_app_service(&Arc::new(db.clone()), &program_settings); + let state = Arc::new(crate::routes::AppState { + database_connection: Arc::new(db), + service: Arc::new(app_service), + config: Arc::new(program_settings), + }); + get_upstream_router(state).layer(axum::middleware::from_fn( + crate::middlewares::require_auth::mock::mock_require_auth, + )) + } + + #[tokio::test] + async fn handler_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 first: Vec> = 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, + }]) + .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_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![Vec::::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); + } +} diff --git a/apps/api/src/services/nginx/upstream.rs b/apps/api/src/services/nginx/upstream.rs index 4022128..8833be0 100644 --- a/apps/api/src/services/nginx/upstream.rs +++ b/apps/api/src/services/nginx/upstream.rs @@ -208,6 +208,11 @@ impl UpstreamService { )))? }); 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(()) }) -- 2.49.1 From b2a322ed796b43ffaa39de5d870993eb9b9721ab Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Tue, 30 Dec 2025 18:22:18 +0800 Subject: [PATCH 25/43] chore: added trait for upstream service --- apps/api/src/services/nginx.rs | 8 +- apps/api/src/services/nginx/upstream.rs | 112 ++++++++++++++++++------ 2 files changed, 90 insertions(+), 30 deletions(-) diff --git a/apps/api/src/services/nginx.rs b/apps/api/src/services/nginx.rs index 383c4cc..bb16164 100644 --- a/apps/api/src/services/nginx.rs +++ b/apps/api/src/services/nginx.rs @@ -8,12 +8,12 @@ use std::sync::Arc; use sea_orm::DatabaseConnection; -use upstream::UpstreamService; +use crate::services::nginx::upstream::{UpstreamService, UpstreamServiceImpl}; pub struct NginxService { connection: Arc, // - upstream_service: Arc, + upstream_service: Arc, } impl NginxService { @@ -21,11 +21,11 @@ impl NginxService { Self { connection: connection.clone(), // - upstream_service: Arc::new(UpstreamService::new(connection.clone())), + upstream_service: Arc::new(UpstreamServiceImpl::new(connection.clone())), } } - pub fn get_upstream_service(&self) -> Arc { + pub fn get_upstream_service(&self) -> Arc { self.upstream_service.clone() } } diff --git a/apps/api/src/services/nginx/upstream.rs b/apps/api/src/services/nginx/upstream.rs index 8833be0..df12161 100644 --- a/apps/api/src/services/nginx/upstream.rs +++ b/apps/api/src/services/nginx/upstream.rs @@ -17,7 +17,65 @@ use crate::{ with_conn, }; -pub struct UpstreamService { +#[async_trait::async_trait] +pub trait UpstreamService: Send + Sync { + async fn create_upstream( + &self, + create_info: UpstreamCreateInfo, + tx: Option<&mut DatabaseTransaction>, + ) -> Result; + async fn get_upstream( + &self, + upstream_id: uuid::Uuid, + options: Option, + tx: Option<&mut DatabaseTransaction>, + ) -> Result; + async fn get_upstreams( + &self, + pagination: Option, + tx: Option<&mut DatabaseTransaction>, + ) -> Result, ServiceError>; + async fn update_upstream( + &self, + id: uuid::Uuid, + upstream: UpdateUpstreamInfo, + tx: Option<&mut DatabaseTransaction>, + ) -> Result; + 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; + async fn get_upstream_target( + &self, + target_id: uuid::Uuid, + options: Option, + tx: Option<&mut DatabaseTransaction>, + ) -> Result; + async fn get_upstream_targets_by_upstream( + &self, + upstream_id: uuid::Uuid, + tx: Option<&mut DatabaseTransaction>, + ) -> Result, ServiceError>; + async fn update_upstream_target( + &self, + id: uuid::Uuid, + target: UpdateUpstreamTargetInfo, + tx: Option<&mut DatabaseTransaction>, + ) -> Result; + async fn delete_upstream_target( + &self, + target_id: uuid::Uuid, + tx: Option<&mut DatabaseTransaction>, + ) -> Result<(), ServiceError>; +} + +pub struct UpstreamServiceImpl { connection: Arc, } @@ -31,13 +89,15 @@ pub struct GetUpstreamTargetOptions { pub include_upstream: bool, } -impl UpstreamService { +impl UpstreamServiceImpl { pub fn new(connection: Arc) -> Self { Self { connection } } - // - // - pub async fn create_upstream( +} + +#[async_trait::async_trait] +impl UpstreamService for UpstreamServiceImpl { + async fn create_upstream( &self, create_info: UpstreamCreateInfo, tx: Option<&mut DatabaseTransaction>, @@ -79,7 +139,7 @@ impl UpstreamService { Ok(r.into()) } - pub async fn get_upstream( + async fn get_upstream( &self, upstream_id: uuid::Uuid, options: Option, @@ -117,7 +177,7 @@ impl UpstreamService { Ok(info) } - pub async fn get_upstreams( + async fn get_upstreams( &self, pagination: Option, tx: Option<&mut DatabaseTransaction>, @@ -136,7 +196,7 @@ impl UpstreamService { Ok(r.into_iter().map(|m| m.into()).collect()) } - pub async fn update_upstream( + async fn update_upstream( &self, id: uuid::Uuid, upstream: UpdateUpstreamInfo, @@ -193,7 +253,7 @@ impl UpstreamService { Ok(r.into()) } - pub async fn delete_upstream( + async fn delete_upstream( &self, upstream_id: uuid::Uuid, tx: Option<&mut DatabaseTransaction>, @@ -220,7 +280,7 @@ impl UpstreamService { // // - pub async fn create_upstream_target( + async fn create_upstream_target( &self, create_info: UpstreamTargetCreateInfo, tx: Option<&mut DatabaseTransaction>, @@ -230,7 +290,7 @@ impl UpstreamService { Ok(r.into()) } - pub async fn get_upstream_target( + async fn get_upstream_target( &self, target_id: uuid::Uuid, options: Option, @@ -273,7 +333,7 @@ impl UpstreamService { Ok(info) } - pub async fn get_upstream_targets_by_upstream( + async fn get_upstream_targets_by_upstream( &self, upstream_id: uuid::Uuid, tx: Option<&mut DatabaseTransaction>, @@ -287,7 +347,7 @@ impl UpstreamService { Ok(r.into_iter().map(|m| m.into()).collect()) } - pub async fn update_upstream_target( + async fn update_upstream_target( &self, id: uuid::Uuid, target: UpdateUpstreamTargetInfo, @@ -308,7 +368,7 @@ impl UpstreamService { Ok(r.into()) } - pub async fn delete_upstream_target( + async fn delete_upstream_target( &self, target_id: uuid::Uuid, tx: Option<&mut DatabaseTransaction>, @@ -356,7 +416,7 @@ mod tests { .append_query_results(vec![vec![up_model.clone()]]) .into_connection(); - let svc = UpstreamService::new(Arc::new(db)); + let svc = UpstreamServiceImpl::new(Arc::new(db)); let create_info = crate::services::nginx::info::upstream::UpstreamCreateInfo { name: "test_upstream".to_string(), @@ -407,7 +467,7 @@ mod tests { .append_query_results(vec![vec![target_model.clone()]]) .into_connection(); - let svc = UpstreamService::new(Arc::new(db)); + let svc = UpstreamServiceImpl::new(Arc::new(db)); let res = svc .get_upstream( @@ -432,7 +492,7 @@ mod tests { .append_query_results(vec![Vec::::new()]) .into_connection(); - let svc = UpstreamService::new(Arc::new(db)); + let svc = UpstreamServiceImpl::new(Arc::new(db)); let res = svc.get_upstream(uuid::Uuid::new_v4(), None, None).await; @@ -466,7 +526,7 @@ mod tests { .append_query_results(vec![vec![u1.clone(), u2.clone()]]) .into_connection(); - let svc = UpstreamService::new(Arc::new(db)); + let svc = UpstreamServiceImpl::new(Arc::new(db)); let res = svc.get_upstreams(None, None).await; assert!(res.is_ok()); @@ -494,7 +554,7 @@ mod tests { .append_query_results(vec![vec![t.clone()]]) .into_connection(); - let svc = UpstreamService::new(Arc::new(db)); + let svc = UpstreamServiceImpl::new(Arc::new(db)); let res = svc.get_upstream_targets_by_upstream(up_id, None).await; assert!(res.is_ok()); @@ -532,7 +592,7 @@ mod tests { .append_query_results(vec![vec![updated.clone()]]) // update result .into_connection(); - let svc = UpstreamService::new(Arc::new(db)); + let svc = UpstreamServiceImpl::new(Arc::new(db)); let update_info = crate::services::nginx::info::upstream::UpdateUpstreamInfo { name: None, @@ -553,7 +613,7 @@ mod tests { .append_query_results(vec![Vec::::new()]) .into_connection(); - let svc = UpstreamService::new(Arc::new(db)); + let svc = UpstreamServiceImpl::new(Arc::new(db)); let res = svc .update_upstream( @@ -595,7 +655,7 @@ mod tests { }]) .into_connection(); - let svc = UpstreamService::new(Arc::new(db)); + let svc = UpstreamServiceImpl::new(Arc::new(db)); let res = svc.delete_upstream(id, None).await; assert!(res.is_ok()); @@ -607,7 +667,7 @@ mod tests { .append_query_results(vec![Vec::::new()]) .into_connection(); - let svc = UpstreamService::new(Arc::new(db)); + let svc = UpstreamServiceImpl::new(Arc::new(db)); let res = svc.delete_upstream(uuid::Uuid::new_v4(), None).await; assert!(matches!(res, Err(ServiceError::NotFound(_)))); @@ -633,7 +693,7 @@ mod tests { .append_query_results(vec![vec![created.clone()]]) .into_connection(); - let svc = UpstreamService::new(Arc::new(db)); + 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(), @@ -681,7 +741,7 @@ mod tests { .append_query_results(vec![vec![updated.clone()]]) .into_connection(); - let svc = UpstreamService::new(Arc::new(db)); + let svc = UpstreamServiceImpl::new(Arc::new(db)); let update_info = crate::services::nginx::info::upstream_target::UpdateUpstreamTargetInfo { target_host: None, @@ -719,7 +779,7 @@ mod tests { }]) .into_connection(); - let svc = UpstreamService::new(Arc::new(db)); + let svc = UpstreamServiceImpl::new(Arc::new(db)); let res = svc.delete_upstream_target(id, None).await; assert!(res.is_ok()); } -- 2.49.1 From dff560019fbb5ea8db7ad0dc4d6c3e03419d32af Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Wed, 31 Dec 2025 12:01:08 +0800 Subject: [PATCH 26/43] revert editing session --- .../src/generated/entities/edit_operation.rs | 30 ----- .../src/generated/entities/editing_session.rs | 23 ---- public/database/src/generated/entities/mod.rs | 2 - .../src/generated/entities/prelude.rs | 2 - public/migration/src/lib.rs | 1 - public/migration/src/migrations.rs | 1 - ...230_000006_create_editing_session_table.rs | 117 ------------------ 7 files changed, 176 deletions(-) delete mode 100644 public/database/src/generated/entities/edit_operation.rs delete mode 100644 public/database/src/generated/entities/editing_session.rs delete mode 100644 public/migration/src/migrations/m20251230_000006_create_editing_session_table.rs diff --git a/public/database/src/generated/entities/edit_operation.rs b/public/database/src/generated/entities/edit_operation.rs deleted file mode 100644 index 98b4275..0000000 --- a/public/database/src/generated/entities/edit_operation.rs +++ /dev/null @@ -1,30 +0,0 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0.0-rc.18 - -use sea_orm::entity::prelude::*; -use serde::{Deserialize, Serialize}; - -#[sea_orm::model] -#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] -#[sea_orm(table_name = "edit_operation")] -pub struct Model { - #[sea_orm(primary_key, auto_increment = false)] - pub id: Uuid, - pub session_id: Uuid, - pub resource_type: String, - pub resource_id: Option, - pub operation_type: String, - #[sea_orm(column_type = "JsonBinary")] - pub payload: Json, - pub created_at: DateTimeUtc, - pub applied_at: Option, - #[sea_orm( - belongs_to, - from = "session_id", - to = "id", - on_update = "Cascade", - on_delete = "Cascade" - )] - pub editing_session: HasOne, -} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/public/database/src/generated/entities/editing_session.rs b/public/database/src/generated/entities/editing_session.rs deleted file mode 100644 index 595305d..0000000 --- a/public/database/src/generated/entities/editing_session.rs +++ /dev/null @@ -1,23 +0,0 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0.0-rc.18 - -use sea_orm::entity::prelude::*; -use serde::{Deserialize, Serialize}; - -#[sea_orm::model] -#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] -#[sea_orm(table_name = "editing_session")] -pub struct Model { - #[sea_orm(primary_key, auto_increment = false)] - pub id: Uuid, - pub name: Option, - pub created_by: Option, - pub status: String, - pub created_at: DateTimeUtc, - pub applied_at: Option, - pub applied_by: Option, - pub expires_at: Option, - #[sea_orm(has_many)] - pub edit_operations: HasMany, -} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/public/database/src/generated/entities/mod.rs b/public/database/src/generated/entities/mod.rs index 1f4ed58..8adf245 100644 --- a/public/database/src/generated/entities/mod.rs +++ b/public/database/src/generated/entities/mod.rs @@ -3,8 +3,6 @@ pub mod prelude; pub mod config; -pub mod edit_operation; -pub mod editing_session; pub mod upstream; pub mod upstream_target; pub mod user; diff --git a/public/database/src/generated/entities/prelude.rs b/public/database/src/generated/entities/prelude.rs index a66a6d5..537ed66 100644 --- a/public/database/src/generated/entities/prelude.rs +++ b/public/database/src/generated/entities/prelude.rs @@ -1,8 +1,6 @@ //! `SeaORM` Entity, @generated by sea-orm-codegen 2.0.0-rc.18 pub use super::config::Entity as Config; -pub use super::edit_operation::Entity as EditOperation; -pub use super::editing_session::Entity as EditingSession; pub use super::upstream::Entity as Upstream; pub use super::upstream_target::Entity as UpstreamTarget; pub use super::user::Entity as User; diff --git a/public/migration/src/lib.rs b/public/migration/src/lib.rs index 71c86cc..f31841c 100644 --- a/public/migration/src/lib.rs +++ b/public/migration/src/lib.rs @@ -15,7 +15,6 @@ impl MigratorTrait for Migrator { Box::new(m20251011_000003_create_user_identity_table::Migration), Box::new(m20251223_000004_create_upstream_table::Migration), Box::new(m20251223_000005_create_upstream_target_table::Migration), - Box::new(m20251230_000006_create_editing_session_table::Migration), ] } } diff --git a/public/migration/src/migrations.rs b/public/migration/src/migrations.rs index 55f0835..aef516b 100644 --- a/public/migration/src/migrations.rs +++ b/public/migration/src/migrations.rs @@ -3,4 +3,3 @@ pub mod m20251011_000002_create_user_table; pub mod m20251011_000003_create_user_identity_table; pub mod m20251223_000004_create_upstream_table; pub mod m20251223_000005_create_upstream_target_table; -pub mod m20251230_000006_create_editing_session_table; diff --git a/public/migration/src/migrations/m20251230_000006_create_editing_session_table.rs b/public/migration/src/migrations/m20251230_000006_create_editing_session_table.rs deleted file mode 100644 index 2765fa3..0000000 --- a/public/migration/src/migrations/m20251230_000006_create_editing_session_table.rs +++ /dev/null @@ -1,117 +0,0 @@ -use sea_orm_migration::{prelude::*, schema::*}; - -#[derive(DeriveMigrationName)] -pub struct Migration; - -#[forbid(dead_code)] -#[derive(DeriveIden)] -pub enum EditingSession { - Table, - Id, - Name, - CreatedBy, - Status, - CreatedAt, - AppliedAt, - AppliedBy, - ExpiresAt, -} - -#[forbid(dead_code)] -#[derive(DeriveIden)] -pub enum EditOperation { - Table, - Id, - SessionId, - ResourceType, - ResourceId, - OperationType, - Payload, - CreatedAt, - AppliedAt, -} - -#[async_trait::async_trait] -impl MigrationTrait for Migration { - async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { - manager - .create_table( - Table::create() - .table(EditingSession::Table) - .if_not_exists() - .col(pk_uuid(EditingSession::Id)) - .col(ColumnDef::new(EditingSession::Name).string().null()) - .col(ColumnDef::new(EditingSession::CreatedBy).uuid().null()) - .col( - ColumnDef::new(EditingSession::Status) - .string() - .default("pending") - .not_null(), - ) - .col( - ColumnDef::new(EditingSession::CreatedAt) - .timestamp() - .default(SimpleExpr::Keyword(Keyword::CurrentTimestamp)) - .not_null(), - ) - .col(ColumnDef::new(EditingSession::AppliedAt).timestamp().null()) - .col(ColumnDef::new(EditingSession::AppliedBy).uuid().null()) - .col(ColumnDef::new(EditingSession::ExpiresAt).timestamp().null()) - .to_owned(), - ) - .await?; - - manager - .create_table( - Table::create() - .table(EditOperation::Table) - .if_not_exists() - .col(pk_uuid(EditOperation::Id)) - .col(ColumnDef::new(EditOperation::SessionId).uuid().not_null()) - .col( - ColumnDef::new(EditOperation::ResourceType) - .string() - .not_null(), - ) // e.g. "upstream", "location" - .col(ColumnDef::new(EditOperation::ResourceId).uuid().null()) // null for create - .col( - ColumnDef::new(EditOperation::OperationType) - .string() - .not_null(), - ) // "create"|"update"|"delete" - .col( - ColumnDef::new(EditOperation::Payload) - .json_binary() - .not_null(), - ) // patch or full object - .col( - ColumnDef::new(EditOperation::CreatedAt) - .timestamp() - .default(SimpleExpr::Keyword(Keyword::CurrentTimestamp)) - .not_null(), - ) - .col(ColumnDef::new(EditOperation::AppliedAt).timestamp().null()) - .foreign_key( - ForeignKey::create() - .name("fk-edit-op-session") - .from(EditOperation::Table, EditOperation::SessionId) - .to(EditingSession::Table, EditingSession::Id) - .on_delete(ForeignKeyAction::Cascade) - .on_update(ForeignKeyAction::Cascade), - ) - .to_owned(), - ) - .await?; - - Ok(()) - } - - async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { - manager - .drop_table(Table::drop().table(EditOperation::Table).to_owned()) - .await?; - manager - .drop_table(Table::drop().table(EditingSession::Table).to_owned()) - .await - } -} -- 2.49.1 From d81e5fe48d1d1c7517b20332046d15e85a09c0eb Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Wed, 31 Dec 2025 14:48:51 +0800 Subject: [PATCH 27/43] refactor: clean up test module imports in health info endpoint --- apps/api/src/routes/api/health/info.rs | 23 +++++------------------ 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/apps/api/src/routes/api/health/info.rs b/apps/api/src/routes/api/health/info.rs index 403ca5e..c6d7aaa 100644 --- a/apps/api/src/routes/api/health/info.rs +++ b/apps/api/src/routes/api/health/info.rs @@ -78,25 +78,12 @@ pub async fn get_health_info( #[cfg(test)] mod test { - use crate::configs::FromConfig; - use crate::services::agent_client::AgentService; - use crate::services::get_app_service; - use crate::{ - routes::{AppState, api::health::state::HealthState}, - services::{ - auth::{ - authentication::{ - AuthenticationServiceImpl, strategies::password::PasswordStrategy, - }, - user::UserServiceImpl, - }, - server_state::ServerStateService, - settings::SettingsService, - }, - }; - use super::*; - use agent_client::apis::configuration::Configuration; + + use crate::configs::FromConfig; + use crate::routes::{AppState, api::health::state::HealthState}; + use crate::services::get_app_service; + use axum::body::to_bytes; use axum::{ Router, -- 2.49.1 From 4f85d8838069e4854f324f36c8117f37020ae70e Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Wed, 31 Dec 2025 14:51:22 +0800 Subject: [PATCH 28/43] feat: implement conversion from DbErr to ApiError --- apps/api/src/errors/api_error.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/api/src/errors/api_error.rs b/apps/api/src/errors/api_error.rs index 8f16996..e972587 100644 --- a/apps/api/src/errors/api_error.rs +++ b/apps/api/src/errors/api_error.rs @@ -1,4 +1,5 @@ use axum::response::IntoResponse; +use sea_orm::DbErr; use crate::errors::service_error::ServiceError; @@ -12,6 +13,12 @@ impl From for ApiError { } } +impl From for ApiError { + fn from(err: DbErr) -> Self { + ServiceError::from(err).into() + } +} + impl IntoResponse for ApiError { fn into_response(self) -> axum::response::Response { match self { -- 2.49.1 From 331b4e1e9668097913c9f83a1f40ac653d9fe9ab Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Wed, 31 Dec 2025 15:57:29 +0800 Subject: [PATCH 29/43] 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. --- Cargo.lock | 43 +++-- apps/api/Cargo.toml | 3 +- .../nginx/upstream/create_upstream.rs | 103 ++++++++++- .../nginx/upstream/create_upstream_target.rs | 113 +++++++++++- .../nginx/upstream/remove_upstream.rs | 111 ++++++++++- .../nginx/upstream/remove_upstream_target.rs | 103 ++++++++++- .../nginx/upstream/update_upstream.rs | 113 +++++++++++- .../nginx/upstream/update_upstream_target.rs | 137 +++++++++++++- apps/api/src/services.rs | 30 ++- apps/api/src/services/agent_client.rs | 172 ++++++++++++++++-- apps/api/src/services/nginx.rs | 59 +++++- apps/api/src/services/nginx/builder.rs | 7 +- apps/api/src/services/nginx/info/upstream.rs | 1 - apps/api/src/services/nginx/upstream.rs | 51 +++++- 14 files changed, 975 insertions(+), 71 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 86a92e0..f583c2a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7,7 +7,7 @@ name = "agent_client" version = "0.1.0" dependencies = [ "async-trait", - "mockall", + "mockall 0.13.1", "reqwest", "serde", "serde_json", @@ -2247,7 +2247,21 @@ dependencies = [ "cfg-if", "downcast", "fragile", - "mockall_derive", + "mockall_derive 0.13.1", + "predicates", + "predicates-tree", +] + +[[package]] +name = "mockall" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58d964098a5f9c6b63d0798e5372fd04708193510a7af313c22e9f29b7b620b" +dependencies = [ + "cfg-if", + "downcast", + "fragile", + "mockall_derive 0.14.0", "predicates", "predicates-tree", ] @@ -2264,6 +2278,18 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "mockall_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca41ce716dda6a9be188b385aa78ee5260fc25cd3802cb2a8afdc6afbe6b6dbf" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "native-tls" version = "0.2.14" @@ -2465,17 +2491,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "optfield" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "969ccca8ffc4fb105bd131a228107d5c9dd89d9d627edf3295cbe979156f9712" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - [[package]] name = "ordered-float" version = "4.6.0" @@ -5599,8 +5614,8 @@ dependencies = [ "jsonwebtoken", "migration", "mime_guess", + "mockall 0.14.0", "once_cell", - "optfield", "reqwest", "sea-orm", "serde", diff --git a/apps/api/Cargo.toml b/apps/api/Cargo.toml index b71f3a1..2563501 100644 --- a/apps/api/Cargo.toml +++ b/apps/api/Cargo.toml @@ -31,11 +31,12 @@ uuid = { version = "1.19.0", features = ["v4", "serde", "fast-rng"] } tower-http = { version = "0.6.8", features = ["cors"] } reqwest = { version = "^0.12", features = ["json", "multipart", "stream"] } serde_urlencoded = { version = "0.7.1" } -optfield = { version = "0.4.0" } [dev-dependencies] tempfile = "3" axum-test = "18.4.1" +agent_client = { path = "../../public/agent-client", features = ["mockall"] } +mockall = { version = "0.14.0", features = [] } [lints.clippy] unwrap_used = "deny" diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream.rs b/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream.rs index 45b79ca..7ce3d9e 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use axum::{Json, extract::State, response::Result as AxumResult}; +use sea_orm::TransactionTrait; use crate::{ errors::api_error::ApiError, @@ -104,7 +105,18 @@ pub async fn create_upstream( .collect(), }; - let upstream_info = upstream_service.create_upstream(create_info, None).await?; + 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())) } @@ -126,12 +138,17 @@ mod tests { create_upstream::{CreateUpstreamRequestBody, UpstreamTargetInfo as ReqTarget}, get_upstream_router, }, - services::get_app_service, + services::{agent_client::MockAgentService, get_app_service, get_mock_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 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), @@ -174,6 +191,10 @@ mod tests { 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)` which + // the mock DB expects as `(Model, Option)` per row. + .append_query_results(vec![vec![(up_model.clone(), Some(target_model.clone()))]]) .into_connection(); let router = get_router_with_state(db.clone()); @@ -218,6 +239,82 @@ mod tests { 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(); diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream_target.rs b/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream_target.rs index 86cfd6b..c0b31df 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream_target.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream_target.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use axum::{Json, extract::State, response::Result as AxumResult}; +use sea_orm::TransactionTrait; use crate::{ errors::api_error::ApiError, @@ -61,10 +62,19 @@ pub async fn add_upstream_target( upstream_id: concrete_payload.upstream_id, }; + let mut tx = state.database_connection.begin().await?; let upstream_info = upstream_service - .create_upstream_target(create_info, None) + .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())) } @@ -76,7 +86,7 @@ mod tests { use axum_test::TestServer; use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase}; - use database::generated::entities::upstream_target; + use database::generated::entities::{upstream, upstream_target}; use crate::{ configs::{FromConfig, ProgramSettings}, @@ -84,12 +94,17 @@ mod tests { routes::api::restricted::nginx::upstream::{ create_upstream_target::CreateUpstreamTargetInfo, get_upstream_router, }, - services::get_app_service, + services::{agent_client::MockAgentService, get_mock_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 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), @@ -100,6 +115,83 @@ mod tests { )) } + #[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(), + }; + + // 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()))]]) + .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(); @@ -117,8 +209,21 @@ mod tests { 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 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()))]]) .into_connection(); let router = get_router_with_state(db.clone()); diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream.rs b/apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream.rs index 419d8f5..25de995 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream.rs @@ -5,6 +5,7 @@ use axum::{ extract::{Path, State}, response::Result as AxumResult, }; +use sea_orm::TransactionTrait; use uuid::Uuid; use crate::{ @@ -19,7 +20,18 @@ pub async fn remove_upstream( ) -> AxumResult, ApiError> { let upstream_service = &state.service.nginx.get_upstream_service(); - upstream_service.delete_upstream(upstream_id, None).await?; + 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(())) } @@ -32,18 +44,23 @@ mod tests { use axum_test::TestServer; use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, MockExecResult}; - use database::generated::entities::upstream; + 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::get_app_service, + services::{agent_client::MockAgentService, get_mock_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 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), @@ -69,6 +86,18 @@ mod tests { 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) .append_query_results(vec![vec![existing.clone()]]) .append_exec_results(vec![ @@ -81,6 +110,8 @@ mod tests { 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()))]]) .into_connection(); let router = get_router_with_state(db.clone()); @@ -91,6 +122,78 @@ mod tests { 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(); diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream_target.rs b/apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream_target.rs index 3784352..f6bf323 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream_target.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream_target.rs @@ -5,6 +5,7 @@ use axum::{ extract::{Path, State}, response::Result as AxumResult, }; +use sea_orm::TransactionTrait; use uuid::Uuid; use crate::{ @@ -19,10 +20,19 @@ pub async fn remove_upstream_target( ) -> AxumResult, 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, None) + .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(())) } @@ -34,18 +44,23 @@ mod tests { use axum_test::TestServer; use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, MockExecResult}; - use database::generated::entities::upstream_target; + 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::get_app_service, + services::{agent_client::MockAgentService, get_mock_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 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), @@ -73,6 +88,17 @@ mod tests { }; // 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 first: Vec> = vec![vec![current_model.clone()]]; let db = MockDatabase::new(DatabaseBackend::Sqlite) .append_query_results(first) @@ -80,6 +106,8 @@ mod tests { 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()))]]) .into_connection(); let router = get_router_with_state(db.clone()); @@ -90,6 +118,73 @@ mod tests { 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![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(); diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/update_upstream.rs b/apps/api/src/routes/api/restricted/nginx/upstream/update_upstream.rs index a287671..0f496cb 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream/update_upstream.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream/update_upstream.rs @@ -5,6 +5,7 @@ use axum::{ extract::{Path, State}, response::Result as AxumResult, }; +use sea_orm::TransactionTrait; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -55,14 +56,22 @@ pub async fn update_upstream( 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, None) + .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; @@ -71,19 +80,24 @@ mod tests { use axum_test::TestServer; use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase}; - use database::generated::entities::upstream; + 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::get_app_service, + services::{agent_client::MockAgentService, get_mock_app_service}, }; - use super::UpdateUpstreamRequestBody; 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 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), @@ -121,11 +135,17 @@ mod tests { }; // first find_by_id, then update returns updated model + let up_model = current_model.clone(); let first: Vec> = vec![vec![current_model.clone()]]; let second: Vec> = vec![vec![updated_model.clone()]]; 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::::None, + )]]) .into_connection(); let router = get_router_with_state(db.clone()); @@ -153,6 +173,87 @@ mod tests { 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![vec![current_model.clone()]]; + let second: Vec> = 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::::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(); diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/update_upstream_target.rs b/apps/api/src/routes/api/restricted/nginx/upstream/update_upstream_target.rs index 814ce69..0750475 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream/update_upstream_target.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream/update_upstream_target.rs @@ -5,6 +5,7 @@ use axum::{ extract::{Path, State}, response::Result as AxumResult, }; +use sea_orm::TransactionTrait; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -48,10 +49,19 @@ pub async fn update_upstream_target( 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, None) + .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())) } @@ -64,18 +74,23 @@ mod tests { use axum_test::TestServer; use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase}; - use database::generated::entities::upstream_target; + 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::get_app_service, + services::{agent_client::MockAgentService, get_mock_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 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), @@ -121,12 +136,27 @@ mod tests { 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![vec![current_model.clone()]]; let second: Vec> = vec![vec![updated_model.clone()]]; + // additional query result for regenerate_and_apply_config -> generate_config + let third: Vec)>> = + 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 router = get_router_with_state(db.clone()); @@ -203,4 +233,103 @@ mod tests { 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![vec![current_model.clone()]]; + let second: Vec> = vec![vec![updated_model.clone()]]; + let third: Vec)>> = + 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); + } } diff --git a/apps/api/src/services.rs b/apps/api/src/services.rs index aa61504..66fb373 100644 --- a/apps/api/src/services.rs +++ b/apps/api/src/services.rs @@ -8,10 +8,13 @@ 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}, @@ -32,7 +35,7 @@ pub struct AppService { #[allow(dead_code)] pub nginx: ServiceState, #[allow(dead_code)] - pub agent_client: ServiceState, + pub agent_client: ServiceState, } pub fn get_app_service( @@ -52,8 +55,31 @@ pub fn get_app_service( }, user: Arc::new(UserServiceImpl::new(db_connection.clone())), nginx: Arc::new(NginxService::new(db_connection.clone())), - agent_client: Arc::new(agent_client::AgentService::new(Configuration::from( + agent_client: Arc::new(AgentServiceImpl::new(Configuration::from( settings.agent.clone(), ))), } } + +#[cfg(test)] +pub fn get_mock_app_service( + db_connection: &Arc, + settings: &ProgramSettings, + mock_agent: Arc, +) -> 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, + } +} diff --git a/apps/api/src/services/agent_client.rs b/apps/api/src/services/agent_client.rs index 8aa7d95..784d6ca 100644 --- a/apps/api/src/services/agent_client.rs +++ b/apps/api/src/services/agent_client.rs @@ -1,14 +1,104 @@ use std::sync::Arc; -use agent_client::apis::{ApiClient, configuration::Configuration}; +use agent_client::{ + apis::{ + Api, ApiClient, Error as ApiError, ResponseContent, + configuration::Configuration, + nginx_agent_api::{ValidateAndReloadParams, ValidateParams, WriteConfigParams}, + }, + models::{ValidateAndReloadBody, ValidateBody, WriteConfigBody}, +}; use tracing::warn; -use crate::configs::agent::AgentSettings; +use crate::{configs::agent::AgentSettings, errors::service_error::ServiceError}; -pub struct AgentService { +#[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 for ServiceError { + fn from(err: AgentError) -> Self { + match err { + AgentError::ValidationFailed(_internal, user) => ServiceError::InternalError(user), + AgentError::ApplicationFailed(_internal, user) => ServiceError::InternalError(user), + } + } +} + +impl From> for AgentError { + fn from(err: ResponseContent) -> Self { + let ResponseContent { + status, + content, + entity, + } = err; + { + let entity_str = entity + .map(|e| format!("{:?}", e)) + .unwrap_or_else(|| "".to_string()); + AgentError::ApplicationFailed( + format!( + "Agent responded with error status {}: {}, entity: {}", + status, content, entity_str + ), + "Agent reported an error during operation.".to_string(), + ) + } + } +} + +impl From> for AgentError { + fn from(err: ApiError) -> 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; + + // 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, } +impl AgentServiceImpl { + pub fn new(config: impl Into>) -> Self { + let client = ApiClient::new(config.into()); + AgentServiceImpl { + client: Arc::new(client), + } + } +} + impl From for Configuration { fn from(settings: AgentSettings) -> Self { let mut config = Configuration::default(); @@ -27,17 +117,73 @@ impl From for Configuration { } } -impl AgentService { - pub fn new(config: impl Into>) -> Self { - let client = ApiClient::new(config.into()); - AgentService { - client: Arc::new(client), - } +#[async_trait::async_trait] +impl AgentService for AgentServiceImpl { + fn get_client(&self) -> Arc { + Arc::clone(&self.client) } - #[allow(dead_code)] - pub fn get_client(&self) -> Arc { - 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(()) } } @@ -56,7 +202,7 @@ mod tests { #[test] fn test_agent_service_creation() { let config = Configuration::default(); - let service = AgentService::new(config); + let service = AgentServiceImpl::new(config); let client = service.get_client(); assert!(Arc::ptr_eq(&client, &service.client)); } diff --git a/apps/api/src/services/nginx.rs b/apps/api/src/services/nginx.rs index bb16164..fd56b07 100644 --- a/apps/api/src/services/nginx.rs +++ b/apps/api/src/services/nginx.rs @@ -6,11 +6,21 @@ pub mod upstream; use std::sync::Arc; -use sea_orm::DatabaseConnection; +use sea_orm::{DatabaseConnection, DatabaseTransaction}; -use crate::services::nginx::upstream::{UpstreamService, UpstreamServiceImpl}; +use crate::{ + errors::service_error::ServiceError, + services::{ + agent_client::AgentService, + nginx::{ + builder::{NginxConfigBuilder, NginxConfigProvider}, + upstream::{UpstreamService, UpstreamServiceImpl}, + }, + }, +}; pub struct NginxService { + #[allow(dead_code)] connection: Arc, // upstream_service: Arc, @@ -28,4 +38,49 @@ impl NginxService { pub fn get_upstream_service(&self) -> Arc { self.upstream_service.clone() } + + #[allow(dead_code)] + pub async fn validate_config( + &self, + agent: Arc, + config: &str, + ) -> Result<(), ServiceError> { + agent.validate(config).await?; + + Ok(()) + } + + pub async fn apply_changes( + &self, + agent: Arc, + config: &str, + ) -> Result<(), ServiceError> { + agent.apply(config).await?; + + Ok(()) + } + + pub async fn generate_config( + &self, + tx: Option<&mut DatabaseTransaction>, + ) -> Result { + let mut builder = NginxConfigBuilder::default(); + self.upstream_service + .generate_config(&mut builder, tx) + .await?; + + Ok(builder.to_nginx_config(None)) + } + + pub async fn regenerate_and_apply_config( + &self, + agent: Arc, + tx: Option<&mut DatabaseTransaction>, + ) -> Result<(), ServiceError> { + let config = self.generate_config(tx).await?; + + self.apply_changes(agent, &config).await?; + + Ok(()) + } } diff --git a/apps/api/src/services/nginx/builder.rs b/apps/api/src/services/nginx/builder.rs index 972d0b7..c042d05 100644 --- a/apps/api/src/services/nginx/builder.rs +++ b/apps/api/src/services/nginx/builder.rs @@ -6,17 +6,12 @@ pub trait NginxConfigProvider { fn to_nginx_config(&self, indent: Option) -> String; } +#[derive(Default)] pub struct NginxConfigBuilder { upstreams: Vec, } impl NginxConfigBuilder { - pub fn new() -> Self { - Self { - upstreams: Vec::new(), - } - } - pub fn add_upstream(&mut self, upstream: UpstreamInfo) { self.upstreams.push(upstream); } diff --git a/apps/api/src/services/nginx/info/upstream.rs b/apps/api/src/services/nginx/info/upstream.rs index 0353d0f..cd0f078 100644 --- a/apps/api/src/services/nginx/info/upstream.rs +++ b/apps/api/src/services/nginx/info/upstream.rs @@ -1,5 +1,4 @@ use chrono::{DateTime, Utc}; -use optfield::optfield; use database::generated::entities::{upstream, upstream_target}; use sea_orm::ActiveValue::{Set, Unchanged}; diff --git a/apps/api/src/services/nginx/upstream.rs b/apps/api/src/services/nginx/upstream.rs index df12161..088244f 100644 --- a/apps/api/src/services/nginx/upstream.rs +++ b/apps/api/src/services/nginx/upstream.rs @@ -10,9 +10,14 @@ use database::generated::entities::{upstream, upstream_target}; use crate::{ errors::service_error::ServiceError, helpers::database::PaginationFilter, - services::nginx::info::{ - upstream::{UpdateUpstreamInfo, UpstreamCreateInfo, UpstreamInfo}, - upstream_target::{UpdateUpstreamTargetInfo, UpstreamTargetCreateInfo, UpstreamTargetInfo}, + services::nginx::{ + builder::NginxConfigBuilder, + info::{ + upstream::{UpdateUpstreamInfo, UpstreamCreateInfo, UpstreamInfo}, + upstream_target::{ + UpdateUpstreamTargetInfo, UpstreamTargetCreateInfo, UpstreamTargetInfo, + }, + }, }, with_conn, }; @@ -57,6 +62,7 @@ pub trait UpstreamService: Send + Sync { options: Option, tx: Option<&mut DatabaseTransaction>, ) -> Result; + #[allow(dead_code)] async fn get_upstream_targets_by_upstream( &self, upstream_id: uuid::Uuid, @@ -73,6 +79,11 @@ pub trait UpstreamService: Send + Sync { 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 { @@ -387,6 +398,26 @@ impl UpstreamService for UpstreamServiceImpl { 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::>(); + builder.add_upstreams(upstreams_info); + Ok(()) + } } #[cfg(test)] @@ -649,10 +680,16 @@ mod tests { 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, - }]) + .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)); -- 2.49.1 From 9c3f775a67f8a9ea313045363a8070ac8245d0c1 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Wed, 31 Dec 2025 16:05:28 +0800 Subject: [PATCH 30/43] refactor: remove unused Filters struct and clean up imports in create_upstream tests --- apps/api/src/helpers/database.rs | 4 ---- .../restricted/nginx/upstream/create_upstream.rs | 15 +++++++++------ 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/apps/api/src/helpers/database.rs b/apps/api/src/helpers/database.rs index 757121c..b4b42ed 100644 --- a/apps/api/src/helpers/database.rs +++ b/apps/api/src/helpers/database.rs @@ -12,10 +12,6 @@ macro_rules! with_conn { }}; } -pub struct Filters { - pub pagination: Option, -} - pub struct PaginationFilter { pub page: u64, pub per_page: u64, diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream.rs b/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream.rs index 7ce3d9e..426cf57 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream.rs @@ -138,7 +138,7 @@ mod tests { create_upstream::{CreateUpstreamRequestBody, UpstreamTargetInfo as ReqTarget}, get_upstream_router, }, - services::{agent_client::MockAgentService, get_app_service, get_mock_app_service}, + services::{agent_client::MockAgentService, get_mock_app_service}, }; fn get_router_with_state(db: DatabaseConnection) -> axum::Router { @@ -271,10 +271,12 @@ mod tests { 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(), - )) + Err( + crate::services::agent_client::AgentError::ApplicationFailed( + "internal".to_string(), + "Failed to communicate with the agent.".to_string(), + ), + ) }); let mock_agent = Arc::new(mock); @@ -285,7 +287,8 @@ mod tests { .into_connection(); let program_settings = ProgramSettings::mock(); - let app_service = get_mock_app_service(&Arc::new(db.clone()), &program_settings, mock_agent); + 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), -- 2.49.1 From 6a30a03e590264765d5f05b40f6b5a41657be624 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Wed, 31 Dec 2025 16:11:03 +0800 Subject: [PATCH 31/43] feat: enhance socket path validation for reqwest client configuration --- apps/api/src/services/agent_client.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/api/src/services/agent_client.rs b/apps/api/src/services/agent_client.rs index 784d6ca..09c01aa 100644 --- a/apps/api/src/services/agent_client.rs +++ b/apps/api/src/services/agent_client.rs @@ -1,4 +1,4 @@ -use std::sync::Arc; +use std::{os::unix::fs::FileTypeExt, sync::Arc}; use agent_client::{ apis::{ @@ -105,7 +105,9 @@ impl From for Configuration { let mut builder = reqwest::Client::builder(); let url = settings.socket_path; - if url.starts_with("unix://") { + // 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 { -- 2.49.1 From d184261027d80f2a718cb8e963fa70e84b04d289 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Wed, 31 Dec 2025 16:44:18 +0800 Subject: [PATCH 32/43] feat: added openapi doc --- apps/api/src/routes/api/openapi.rs | 27 +- .../nginx/upstream/create_upstream.rs | 20 +- .../nginx/upstream/create_upstream_target.rs | 18 +- .../restricted/nginx/upstream/get_upstream.rs | 20 + .../nginx/upstream/get_upstream_target.rs | 18 +- .../nginx/upstream/remove_upstream.rs | 16 +- .../nginx/upstream/remove_upstream_target.rs | 16 +- .../nginx/upstream/update_upstream.rs | 16 +- .../nginx/upstream/update_upstream_target.rs | 15 +- apps/api/swagger.json | 931 ++++++++++++++++++ .../app/generated/api-client/api-client.ts | 273 +++++ .../generated/api-client/tanstack-client.ts | 62 ++ 12 files changed, 1422 insertions(+), 10 deletions(-) diff --git a/apps/api/src/routes/api/openapi.rs b/apps/api/src/routes/api/openapi.rs index f37db25..cc855e3 100644 --- a/apps/api/src/routes/api/openapi.rs +++ b/apps/api/src/routes/api/openapi.rs @@ -3,6 +3,7 @@ pub mod tag { pub const HEALTH_TAG: &str = "Health"; pub const AUTH_TAG: &str = "Authentication"; pub const USER_TAG: &str = "User"; + pub const NGINX_TAG: &str = "Nginx"; } #[derive(utoipa::OpenApi)] @@ -14,6 +15,16 @@ pub mod tag { crate::routes::api::auth::init_admin::init_admin, // User management paths crate::routes::api::restricted::user::me::get_user_info, + // Nginx upstream management + crate::routes::api::restricted::nginx::upstream::create_upstream::create_upstream, + crate::routes::api::restricted::nginx::upstream::create_upstream_target::add_upstream_target, + crate::routes::api::restricted::nginx::upstream::get_upstream::get_upstream_list, + crate::routes::api::restricted::nginx::upstream::get_upstream::get_upstream, + crate::routes::api::restricted::nginx::upstream::get_upstream_target::get_upstream_target, + crate::routes::api::restricted::nginx::upstream::update_upstream::update_upstream, + crate::routes::api::restricted::nginx::upstream::update_upstream_target::update_upstream_target, + crate::routes::api::restricted::nginx::upstream::remove_upstream::remove_upstream, + crate::routes::api::restricted::nginx::upstream::remove_upstream_target::remove_upstream_target, ), components( schemas(crate::routes::api::health::info::HealthInfo), @@ -22,11 +33,25 @@ pub mod tag { schemas(crate::routes::api::auth::init_admin::AdminInitRequest), // User management schemas schemas(crate::routes::api::restricted::user::me::UserInfo), + // Nginx upstream schemas + schemas(crate::routes::api::restricted::nginx::upstream::create_upstream::CreateUpstreamRequestBody), + schemas(crate::routes::api::restricted::nginx::upstream::create_upstream_target::CreateUpstreamTargetInfo), + schemas(crate::routes::api::restricted::nginx::upstream::get_upstream::GetUpstreamParams), + schemas(crate::routes::api::restricted::nginx::upstream::get_upstream_target::GetUpstreamTargetsParams), + schemas(crate::routes::api::restricted::nginx::upstream::info::response::UpstreamTargetInfo), + schemas(crate::routes::api::restricted::nginx::upstream::info::response::UpstreamInfoResponse), + schemas(crate::routes::api::restricted::nginx::upstream::info::response::UpstreamListResponse), + schemas(crate::routes::api::restricted::nginx::upstream::info::response::UpstreamTargetInfoResponse), + schemas(crate::routes::api::restricted::nginx::upstream::update_upstream::UpdateUpstreamRequestBody), + schemas(crate::routes::api::restricted::nginx::upstream::update_upstream_target::UpdateUpstreamTargetRequestBody), + schemas(crate::routes::api::restricted::nginx::upstream::info::response::UpdateUpstreamInfoResponse), + schemas(crate::routes::api::restricted::nginx::upstream::info::response::UpdateUpstreamTargetInfoResponse), ), tags( (name = tag::HEALTH_TAG, description = "Health information API"), (name = tag::AUTH_TAG, description = "Authentication API"), - (name = tag::USER_TAG, description = "User management API") + (name = tag::USER_TAG, description = "User management API"), + (name = tag::NGINX_TAG, description = "Nginx management API") ) )] pub struct ApiDoc; diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream.rs b/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream.rs index 426cf57..0319076 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream.rs @@ -6,7 +6,13 @@ use sea_orm::TransactionTrait; use crate::{ errors::api_error::ApiError, middlewares::request_info::AuthenticatedRequestInfo, - routes::{AppState, api::restricted::nginx::upstream::info::response::UpstreamInfoResponse}, + routes::{ + AppState, + api::{ + openapi::tag::NGINX_TAG, + restricted::nginx::upstream::info::response::UpstreamInfoResponse, + }, + }, services::nginx::info::upstream::UpstreamCreateInfo, }; @@ -75,6 +81,18 @@ impl From for ConcreteCreateUpstreamRequestBody { } #[axum::debug_handler] +#[utoipa::path( + post, + path = "/api/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>, diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream_target.rs b/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream_target.rs index c0b31df..9865058 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream_target.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream_target.rs @@ -7,7 +7,11 @@ use crate::{ errors::api_error::ApiError, middlewares::request_info::AuthenticatedRequestInfo, routes::{ - AppState, api::restricted::nginx::upstream::info::response::UpstreamTargetInfoResponse, + AppState, + api::{ + openapi::tag::NGINX_TAG, + restricted::nginx::upstream::info::response::UpstreamTargetInfoResponse, + }, }, services::nginx::info::upstream_target::UpstreamTargetCreateInfo, }; @@ -45,6 +49,18 @@ impl From for ConcreteCreateUpstreamTargetInfo { } #[axum::debug_handler] +#[utoipa::path( + post, + path = "/api/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>, diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream.rs b/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream.rs index e1cdd95..0d16351 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream.rs @@ -14,6 +14,7 @@ use crate::{ AppState, api::{ helper::pagination::{ExtractPagination, PaginationInfo}, + openapi::tag::NGINX_TAG, restricted::nginx::upstream::info::response::{ UpstreamInfoResponse, UpstreamListResponse, }, @@ -39,6 +40,15 @@ impl From for ConcreteGetUpstreamParams { } } +#[utoipa::path( + get, + path = "/api/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>, @@ -60,6 +70,16 @@ pub async fn get_upstream_list( })) } +#[utoipa::path( + get, + path = "/api/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, Query(params): Query, diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream_target.rs b/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream_target.rs index 07a4ac5..2e66d1b 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream_target.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream_target.rs @@ -10,7 +10,13 @@ use uuid::Uuid; use crate::{ errors::api_error::ApiError, - routes::{AppState, api::restricted::nginx::upstream::info::response::UpstreamTargetInfo}, + routes::{ + AppState, + api::{ + openapi::tag::NGINX_TAG, + restricted::nginx::upstream::info::response::UpstreamTargetInfo, + }, + }, }; #[derive(Serialize, Deserialize, utoipa::ToSchema)] @@ -30,6 +36,16 @@ impl From for ConcreteGetUpstreamTargetsParams { } } +#[utoipa::path( + get, + path = "/api/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, Query(params): Query, diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream.rs b/apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream.rs index 25de995..656a24e 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream.rs @@ -9,10 +9,22 @@ use sea_orm::TransactionTrait; use uuid::Uuid; use crate::{ - errors::api_error::ApiError, middlewares::request_info::AuthenticatedRequestInfo, - routes::AppState, + errors::api_error::ApiError, + middlewares::request_info::AuthenticatedRequestInfo, + routes::{AppState, api::openapi::tag::NGINX_TAG}, }; +#[utoipa::path( + delete, + path = "/api/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, diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream_target.rs b/apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream_target.rs index f6bf323..40b0266 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream_target.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream_target.rs @@ -9,10 +9,22 @@ use sea_orm::TransactionTrait; use uuid::Uuid; use crate::{ - errors::api_error::ApiError, middlewares::request_info::AuthenticatedRequestInfo, - routes::AppState, + errors::api_error::ApiError, + middlewares::request_info::AuthenticatedRequestInfo, + routes::{AppState, api::openapi::tag::NGINX_TAG}, }; +#[utoipa::path( + delete, + path = "/api/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, diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/update_upstream.rs b/apps/api/src/routes/api/restricted/nginx/upstream/update_upstream.rs index 0f496cb..94b23af 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream/update_upstream.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream/update_upstream.rs @@ -13,7 +13,8 @@ use crate::{ errors::api_error::ApiError, middlewares::request_info::AuthenticatedRequestInfo, routes::{ - AppState, api::restricted::nginx::upstream::info::response::UpdateUpstreamInfoResponse, + AppState, api::openapi::tag::NGINX_TAG, + api::restricted::nginx::upstream::info::response::UpdateUpstreamInfoResponse, }, services::nginx::info::upstream::UpdateUpstreamInfo, }; @@ -47,6 +48,19 @@ impl From for UpdateUpstreamInfo { } } +#[utoipa::path( + patch, + path = "/api/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, diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/update_upstream_target.rs b/apps/api/src/routes/api/restricted/nginx/upstream/update_upstream_target.rs index 0750475..30c1bb0 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream/update_upstream_target.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream/update_upstream_target.rs @@ -13,7 +13,7 @@ use crate::{ errors::api_error::ApiError, middlewares::request_info::AuthenticatedRequestInfo, routes::{ - AppState, + AppState, api::openapi::tag::NGINX_TAG, api::restricted::nginx::upstream::info::response::UpdateUpstreamTargetInfoResponse, }, services::nginx::info::upstream_target::UpdateUpstreamTargetInfo, @@ -40,6 +40,19 @@ impl From for UpdateUpstreamTargetInfo { } } +#[utoipa::path( + patch, + path = "/api/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, diff --git a/apps/api/swagger.json b/apps/api/swagger.json index cf7fddc..b43e131 100644 --- a/apps/api/swagger.json +++ b/apps/api/swagger.json @@ -106,6 +106,357 @@ } } }, + "/api/upstream_targets/{upstream_target_id}": { + "get": { + "tags": [ + "Nginx" + ], + "operationId": "get_upstream_target", + "parameters": [ + { + "name": "upstream_target_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Get upstream target info", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpstreamTargetInfo" + } + } + } + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + } + }, + "delete": { + "tags": [ + "Nginx" + ], + "operationId": "remove_upstream_target", + "parameters": [ + { + "name": "upstream_target_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Upstream target removed successfully", + "content": { + "application/json": { + "schema": { + "default": null + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + } + }, + "patch": { + "tags": [ + "Nginx" + ], + "operationId": "update_upstream_target", + "parameters": [ + { + "name": "upstream_target_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateUpstreamTargetRequestBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Upstream target updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateUpstreamTargetInfoResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Invalid request" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/api/upstreams": { + "get": { + "tags": [ + "Nginx" + ], + "operationId": "get_upstream_list", + "responses": { + "200": { + "description": "List upstreams", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpstreamListResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + }, + "post": { + "tags": [ + "Nginx" + ], + "operationId": "create_upstream", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateUpstreamRequestBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Upstream created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpstreamInfoResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "422": { + "description": "Invalid request" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/api/upstreams/{upstream_id}": { + "get": { + "tags": [ + "Nginx" + ], + "operationId": "get_upstream", + "parameters": [ + { + "name": "upstream_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Get upstream info", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpstreamInfoResponse" + } + } + } + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + } + }, + "delete": { + "tags": [ + "Nginx" + ], + "operationId": "remove_upstream", + "parameters": [ + { + "name": "upstream_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Upstream removed successfully", + "content": { + "application/json": { + "schema": { + "default": null + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + } + }, + "patch": { + "tags": [ + "Nginx" + ], + "operationId": "update_upstream", + "parameters": [ + { + "name": "upstream_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateUpstreamRequestBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Upstream updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateUpstreamInfoResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Invalid request" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/api/upstreams/{upstream_id}/targets": { + "post": { + "tags": [ + "Nginx" + ], + "operationId": "add_upstream_target", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateUpstreamTargetInfo" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Upstream target created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpstreamTargetInfoResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "422": { + "description": "Invalid request" + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/api/user/me": { "get": { "tags": [ @@ -157,6 +508,102 @@ } } }, + "CreateUpstreamRequestBody": { + "type": "object", + "required": [ + "name", + "protocol", + "upstream_targets" + ], + "properties": { + "algorithm": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "protocol": { + "type": "string" + }, + "sticky_session": { + "type": [ + "boolean", + "null" + ] + }, + "upstream_targets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UpstreamTargetInfo" + } + } + } + }, + "CreateUpstreamTargetInfo": { + "type": "object", + "required": [ + "upstream_id", + "host", + "port" + ], + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + }, + "host": { + "type": "string" + }, + "is_backup": { + "type": [ + "boolean", + "null" + ] + }, + "port": { + "type": "integer", + "format": "int64" + }, + "upstream_id": { + "type": "string", + "format": "uuid" + }, + "weight": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + } + }, + "GetUpstreamParams": { + "type": "object", + "properties": { + "include_targets": { + "type": [ + "boolean", + "null" + ] + } + } + }, + "GetUpstreamTargetsParams": { + "type": "object", + "properties": { + "include_upstream": { + "type": [ + "boolean", + "null" + ] + } + } + }, "HealthInfo": { "type": "object", "description": "System health information", @@ -212,6 +659,486 @@ } } }, + "PaginationInfo": { + "type": "object", + "description": "Pagination information included in API responses", + "required": [ + "total_items", + "total_pages", + "current_page", + "per_page" + ], + "properties": { + "current_page": { + "type": "integer", + "format": "int32", + "description": "Current page number", + "minimum": 0 + }, + "per_page": { + "type": "integer", + "format": "int32", + "description": "Items per page", + "minimum": 0 + }, + "total_items": { + "type": "integer", + "format": "int64", + "description": "Total number of items", + "minimum": 0 + }, + "total_pages": { + "type": "integer", + "format": "int32", + "description": "Total number of pages", + "minimum": 0 + } + } + }, + "UpdateUpstreamInfoResponse": { + "type": "object", + "required": [ + "id", + "name", + "protocol", + "algorithm", + "sticky_session", + "created_at", + "updated_at", + "upstream_targets" + ], + "properties": { + "algorithm": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "created_by": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "protocol": { + "type": "string" + }, + "sticky_session": { + "type": "boolean" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "upstream_targets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UpstreamTargetBasicInfo" + } + } + } + }, + "UpdateUpstreamRequestBody": { + "type": "object", + "properties": { + "algorithm": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "protocol": { + "type": [ + "string", + "null" + ] + }, + "sticky_session": { + "type": [ + "boolean", + "null" + ] + }, + "upstream_targets": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/UpstreamTargetBasicUpdateInfo" + } + } + } + }, + "UpdateUpstreamTargetInfoResponse": { + "type": "object", + "required": [ + "id", + "host", + "port", + "enabled", + "is_backup", + "weight", + "created_at", + "updated_at", + "upstream_id" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "enabled": { + "type": "boolean" + }, + "host": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "is_backup": { + "type": "boolean" + }, + "port": { + "type": "integer", + "format": "int64" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "upstream_id": { + "type": "string", + "format": "uuid" + }, + "weight": { + "type": "integer", + "format": "int32" + } + } + }, + "UpdateUpstreamTargetRequestBody": { + "type": "object", + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + }, + "host": { + "type": [ + "string", + "null" + ] + }, + "is_backup": { + "type": [ + "boolean", + "null" + ] + }, + "port": { + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "weight": { + "type": [ + "integer", + "null" + ], + "format": "int32" + } + } + }, + "UpstreamBasicInfo": { + "type": "object", + "required": [ + "id", + "name", + "protocol", + "created_at", + "updated_at" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "protocol": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "UpstreamInfoResponse": { + "type": "object", + "required": [ + "id", + "name", + "protocol", + "algorithm", + "sticky_session", + "created_at", + "updated_at", + "upstream_targets" + ], + "properties": { + "algorithm": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "created_by": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "protocol": { + "type": "string" + }, + "sticky_session": { + "type": "boolean" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "upstream_targets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UpstreamTargetBasicInfo" + } + } + } + }, + "UpstreamListResponse": { + "type": "object", + "required": [ + "items", + "pagination" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UpstreamInfoResponse" + } + }, + "pagination": { + "$ref": "#/components/schemas/PaginationInfo" + } + } + }, + "UpstreamTargetBasicInfo": { + "type": "object", + "required": [ + "id", + "target_host", + "target_port", + "enabled", + "is_backup", + "weight", + "created_at", + "updated_at" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "is_backup": { + "type": "boolean" + }, + "target_host": { + "type": "string" + }, + "target_port": { + "type": "integer", + "format": "int64" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "weight": { + "type": "integer", + "format": "int32" + } + } + }, + "UpstreamTargetBasicUpdateInfo": { + "type": "object", + "required": [ + "id", + "enabled" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "id": { + "type": "integer", + "format": "int64" + } + } + }, + "UpstreamTargetInfo": { + "type": "object", + "required": [ + "id", + "target_host", + "target_port", + "enabled", + "is_backup", + "weight", + "created_at", + "updated_at", + "upstream_id" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "is_backup": { + "type": "boolean" + }, + "target_host": { + "type": "string" + }, + "target_port": { + "type": "integer", + "format": "int64" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "upstream": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/UpstreamBasicInfo" + } + ] + }, + "upstream_id": { + "type": "string", + "format": "uuid" + }, + "weight": { + "type": "integer", + "format": "int32" + } + } + }, + "UpstreamTargetInfoResponse": { + "type": "object", + "required": [ + "id", + "host", + "port", + "enabled", + "is_backup", + "weight", + "created_at", + "updated_at", + "upstream_id" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "enabled": { + "type": "boolean" + }, + "host": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "is_backup": { + "type": "boolean" + }, + "port": { + "type": "integer", + "format": "int64" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "upstream_id": { + "type": "string", + "format": "uuid" + }, + "weight": { + "type": "integer", + "format": "int32" + } + } + }, "UserInfo": { "type": "object", "description": "System health information", @@ -245,6 +1172,10 @@ { "name": "User", "description": "User management API" + }, + { + "name": "Nginx", + "description": "Nginx management API" } ] } \ No newline at end of file diff --git a/apps/frontend/app/generated/api-client/api-client.ts b/apps/frontend/app/generated/api-client/api-client.ts index 0b47c95..34dea83 100644 --- a/apps/frontend/app/generated/api-client/api-client.ts +++ b/apps/frontend/app/generated/api-client/api-client.ts @@ -1,6 +1,42 @@ export namespace Schemas { // export type AdminInitRequest = { password: string; setup_secret: string; username: 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; + }; + export type CreateUpstreamTargetInfo = { + enabled?: (boolean | null) | undefined; + host: string; + is_backup?: (boolean | null) | undefined; + port: number; + upstream_id: string; + weight?: (number | null) | undefined; + }; + export type GetUpstreamParams = Partial<{ include_targets: boolean | null }>; + export type GetUpstreamTargetsParams = Partial<{ include_upstream: boolean | null }>; export type HealthInfo = { errors?: (Array | null) | undefined; is_initialized: boolean; @@ -9,6 +45,77 @@ export namespace Schemas { version: 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 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; + }; + export type UpdateUpstreamRequestBody = Partial<{ + algorithm: string | null; + name: string | null; + protocol: string | null; + sticky_session: boolean | null; + upstream_targets: Array | 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; + }; + export type UpstreamListResponse = { items: Array; 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 }; // @@ -42,6 +149,95 @@ export namespace Endpoints { parameters: never; responses: { 200: Schemas.HealthInfo; 404: unknown }; }; + export type get_Get_upstream_target = { + method: "GET"; + path: "/api/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/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/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/upstreams"; + requestFormat: "json"; + parameters: never; + responses: { 200: Schemas.UpstreamListResponse; 500: unknown }; + }; + export type post_Create_upstream = { + method: "POST"; + path: "/api/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/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/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/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/upstreams/{upstream_id}/targets"; + requestFormat: "json"; + parameters: { + body: Schemas.CreateUpstreamTargetInfo; + }; + responses: { 200: Schemas.UpstreamTargetInfoResponse; 401: unknown; 422: unknown; 500: unknown }; + }; export type get_Get_user_info = { method: "GET"; path: "/api/user/me"; @@ -58,11 +254,24 @@ export type EndpointByMethod = { post: { "/api/auth/init_admin": Endpoints.post_Init_admin; "/api/auth/login": Endpoints.post_Login; + "/api/upstreams": Endpoints.post_Create_upstream; + "/api/upstreams/{upstream_id}/targets": Endpoints.post_Add_upstream_target; }; get: { "/api/health/info": Endpoints.get_Get_health_info; + "/api/upstream_targets/{upstream_target_id}": Endpoints.get_Get_upstream_target; + "/api/upstreams": Endpoints.get_Get_upstream_list; + "/api/upstreams/{upstream_id}": Endpoints.get_Get_upstream; "/api/user/me": Endpoints.get_Get_user_info; }; + delete: { + "/api/upstream_targets/{upstream_target_id}": Endpoints.delete_Remove_upstream_target; + "/api/upstreams/{upstream_id}": Endpoints.delete_Remove_upstream; + }; + patch: { + "/api/upstream_targets/{upstream_target_id}": Endpoints.patch_Update_upstream_target; + "/api/upstreams/{upstream_id}": Endpoints.patch_Update_upstream; + }; }; // @@ -70,6 +279,8 @@ export type EndpointByMethod = { // export type PostEndpoints = EndpointByMethod["post"]; export type GetEndpoints = EndpointByMethod["get"]; +export type DeleteEndpoints = EndpointByMethod["delete"]; +export type PatchEndpoints = EndpointByMethod["patch"]; // // @@ -364,6 +575,68 @@ export class ApiClient { } // + // + delete( + path: Path, + ...params: MaybeOptionalArg< + TEndpoint extends { parameters: infer UParams } + ? NotNever extends true + ? UParams & { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean } + : { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean } + : { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean } + > + ): Promise, { data: {} }>["data"]>; + + delete( + path: Path, + ...params: MaybeOptionalArg< + TEndpoint extends { parameters: infer UParams } + ? NotNever extends true + ? UParams & { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean } + : { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean } + : { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean } + > + ): Promise>; + + delete( + path: Path, + ...params: MaybeOptionalArg + ): Promise { + return this.request("delete", path, ...params); + } + // + + // + patch( + path: Path, + ...params: MaybeOptionalArg< + TEndpoint extends { parameters: infer UParams } + ? NotNever extends true + ? UParams & { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean } + : { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean } + : { overrides?: RequestInit; withResponse?: false; throwOnStatusError?: boolean } + > + ): Promise, { data: {} }>["data"]>; + + patch( + path: Path, + ...params: MaybeOptionalArg< + TEndpoint extends { parameters: infer UParams } + ? NotNever extends true + ? UParams & { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean } + : { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean } + : { overrides?: RequestInit; withResponse?: true; throwOnStatusError?: boolean } + > + ): Promise>; + + patch( + path: Path, + ...params: MaybeOptionalArg + ): Promise { + return this.request("patch", path, ...params); + } + // + // /** * Generic request method with full type-safety for any endpoint diff --git a/apps/frontend/app/generated/api-client/tanstack-client.ts b/apps/frontend/app/generated/api-client/tanstack-client.ts index 0cd843e..5469fe8 100644 --- a/apps/frontend/app/generated/api-client/tanstack-client.ts +++ b/apps/frontend/app/generated/api-client/tanstack-client.ts @@ -43,6 +43,8 @@ const createQueryKey = ( // export type PostEndpoints = EndpointByMethod["post"]; export type GetEndpoints = EndpointByMethod["get"]; +export type DeleteEndpoints = EndpointByMethod["delete"]; +export type PatchEndpoints = EndpointByMethod["patch"]; // // @@ -130,6 +132,66 @@ export class TanstackQueryApiClient { } // + // + delete( + path: Path, + ...params: MaybeOptionalArg + ) { + 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; + }, + queryKey: queryKey, + }), + }; + + return query; + } + // + + // + patch( + path: Path, + ...params: MaybeOptionalArg + ) { + 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; + }, + queryKey: queryKey, + }), + }; + + return query; + } + // + // /** * Generic mutation method with full type-safety for any endpoint; it doesnt require parameters to be passed initially -- 2.49.1 From 10cc8f9d9768d8fc5a657776b0eae2ceb1e05a25 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Wed, 31 Dec 2025 18:02:45 +0800 Subject: [PATCH 33/43] Fix incorrect path --- .../nginx/upstream/create_upstream.rs | 2 +- .../nginx/upstream/create_upstream_target.rs | 2 +- .../restricted/nginx/upstream/get_upstream.rs | 4 +-- .../nginx/upstream/get_upstream_target.rs | 2 +- .../nginx/upstream/remove_upstream.rs | 2 +- .../nginx/upstream/remove_upstream_target.rs | 2 +- .../nginx/upstream/update_upstream.rs | 2 +- .../nginx/upstream/update_upstream_target.rs | 2 +- apps/api/swagger.json | 8 ++--- .../app/generated/api-client/api-client.ts | 36 +++++++++---------- 10 files changed, 31 insertions(+), 31 deletions(-) diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream.rs b/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream.rs index 0319076..67cb4e7 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream.rs @@ -83,7 +83,7 @@ impl From for ConcreteCreateUpstreamRequestBody { #[axum::debug_handler] #[utoipa::path( post, - path = "/api/upstreams", + path = "/api/nginx/upstreams", request_body = CreateUpstreamRequestBody, responses( (status = 200, description = "Upstream created successfully", body = UpstreamInfoResponse), diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream_target.rs b/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream_target.rs index 9865058..1584871 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream_target.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream/create_upstream_target.rs @@ -51,7 +51,7 @@ impl From for ConcreteCreateUpstreamTargetInfo { #[axum::debug_handler] #[utoipa::path( post, - path = "/api/upstreams/{upstream_id}/targets", + path = "/api/nginx/upstreams/{upstream_id}/targets", request_body = CreateUpstreamTargetInfo, responses( (status = 200, description = "Upstream target created successfully", body = UpstreamTargetInfoResponse), diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream.rs b/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream.rs index 0d16351..7c2fd7c 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream.rs @@ -42,7 +42,7 @@ impl From for ConcreteGetUpstreamParams { #[utoipa::path( get, - path = "/api/upstreams", + path = "/api/nginx/upstreams", responses( (status = 200, description = "List upstreams", body = UpstreamListResponse), (status = 500, description = "Internal server error"), @@ -72,7 +72,7 @@ pub async fn get_upstream_list( #[utoipa::path( get, - path = "/api/upstreams/{upstream_id}", + path = "/api/nginx/upstreams/{upstream_id}", responses( (status = 200, description = "Get upstream info", body = UpstreamInfoResponse), (status = 404, description = "Not found"), diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream_target.rs b/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream_target.rs index 2e66d1b..6c012ce 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream_target.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream_target.rs @@ -38,7 +38,7 @@ impl From for ConcreteGetUpstreamTargetsParams { #[utoipa::path( get, - path = "/api/upstream_targets/{upstream_target_id}", + path = "/api/nginx/upstream_targets/{upstream_target_id}", responses( (status = 200, description = "Get upstream target info", body = UpstreamTargetInfo), (status = 404, description = "Not found"), diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream.rs b/apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream.rs index 656a24e..9b8ea83 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream.rs @@ -16,7 +16,7 @@ use crate::{ #[utoipa::path( delete, - path = "/api/upstreams/{upstream_id}", + path = "/api/nginx/upstreams/{upstream_id}", responses( (status = 200, description = "Upstream removed successfully", body = ()), (status = 401, description = "Unauthorized"), diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream_target.rs b/apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream_target.rs index 40b0266..e4c858e 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream_target.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream/remove_upstream_target.rs @@ -16,7 +16,7 @@ use crate::{ #[utoipa::path( delete, - path = "/api/upstream_targets/{upstream_target_id}", + path = "/api/nginx/upstream_targets/{upstream_target_id}", responses( (status = 200, description = "Upstream target removed successfully", body = ()), (status = 401, description = "Unauthorized"), diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/update_upstream.rs b/apps/api/src/routes/api/restricted/nginx/upstream/update_upstream.rs index 94b23af..86da499 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream/update_upstream.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream/update_upstream.rs @@ -50,7 +50,7 @@ impl From for UpdateUpstreamInfo { #[utoipa::path( patch, - path = "/api/upstreams/{upstream_id}", + path = "/api/nginx/upstreams/{upstream_id}", request_body = UpdateUpstreamRequestBody, responses( (status = 200, description = "Upstream updated successfully", body = UpdateUpstreamInfoResponse), diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/update_upstream_target.rs b/apps/api/src/routes/api/restricted/nginx/upstream/update_upstream_target.rs index 30c1bb0..ef8ac57 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream/update_upstream_target.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream/update_upstream_target.rs @@ -42,7 +42,7 @@ impl From for UpdateUpstreamTargetInfo { #[utoipa::path( patch, - path = "/api/upstream_targets/{upstream_target_id}", + path = "/api/nginx/upstream_targets/{upstream_target_id}", request_body = UpdateUpstreamTargetRequestBody, responses( (status = 200, description = "Upstream target updated successfully", body = UpdateUpstreamTargetInfoResponse), diff --git a/apps/api/swagger.json b/apps/api/swagger.json index b43e131..ae006db 100644 --- a/apps/api/swagger.json +++ b/apps/api/swagger.json @@ -106,7 +106,7 @@ } } }, - "/api/upstream_targets/{upstream_target_id}": { + "/api/nginx/upstream_targets/{upstream_target_id}": { "get": { "tags": [ "Nginx" @@ -232,7 +232,7 @@ } } }, - "/api/upstreams": { + "/api/nginx/upstreams": { "get": { "tags": [ "Nginx" @@ -292,7 +292,7 @@ } } }, - "/api/upstreams/{upstream_id}": { + "/api/nginx/upstreams/{upstream_id}": { "get": { "tags": [ "Nginx" @@ -418,7 +418,7 @@ } } }, - "/api/upstreams/{upstream_id}/targets": { + "/api/nginx/upstreams/{upstream_id}/targets": { "post": { "tags": [ "Nginx" diff --git a/apps/frontend/app/generated/api-client/api-client.ts b/apps/frontend/app/generated/api-client/api-client.ts index 34dea83..1d42ada 100644 --- a/apps/frontend/app/generated/api-client/api-client.ts +++ b/apps/frontend/app/generated/api-client/api-client.ts @@ -151,7 +151,7 @@ export namespace Endpoints { }; export type get_Get_upstream_target = { method: "GET"; - path: "/api/upstream_targets/{upstream_target_id}"; + path: "/api/nginx/upstream_targets/{upstream_target_id}"; requestFormat: "json"; parameters: { path: { upstream_target_id: string }; @@ -160,7 +160,7 @@ export namespace Endpoints { }; export type delete_Remove_upstream_target = { method: "DELETE"; - path: "/api/upstream_targets/{upstream_target_id}"; + path: "/api/nginx/upstream_targets/{upstream_target_id}"; requestFormat: "json"; parameters: { path: { upstream_target_id: string }; @@ -169,7 +169,7 @@ export namespace Endpoints { }; export type patch_Update_upstream_target = { method: "PATCH"; - path: "/api/upstream_targets/{upstream_target_id}"; + path: "/api/nginx/upstream_targets/{upstream_target_id}"; requestFormat: "json"; parameters: { path: { upstream_target_id: string }; @@ -186,14 +186,14 @@ export namespace Endpoints { }; export type get_Get_upstream_list = { method: "GET"; - path: "/api/upstreams"; + path: "/api/nginx/upstreams"; requestFormat: "json"; parameters: never; responses: { 200: Schemas.UpstreamListResponse; 500: unknown }; }; export type post_Create_upstream = { method: "POST"; - path: "/api/upstreams"; + path: "/api/nginx/upstreams"; requestFormat: "json"; parameters: { body: Schemas.CreateUpstreamRequestBody; @@ -202,7 +202,7 @@ export namespace Endpoints { }; export type get_Get_upstream = { method: "GET"; - path: "/api/upstreams/{upstream_id}"; + path: "/api/nginx/upstreams/{upstream_id}"; requestFormat: "json"; parameters: { path: { upstream_id: string }; @@ -211,7 +211,7 @@ export namespace Endpoints { }; export type delete_Remove_upstream = { method: "DELETE"; - path: "/api/upstreams/{upstream_id}"; + path: "/api/nginx/upstreams/{upstream_id}"; requestFormat: "json"; parameters: { path: { upstream_id: string }; @@ -220,7 +220,7 @@ export namespace Endpoints { }; export type patch_Update_upstream = { method: "PATCH"; - path: "/api/upstreams/{upstream_id}"; + path: "/api/nginx/upstreams/{upstream_id}"; requestFormat: "json"; parameters: { path: { upstream_id: string }; @@ -231,7 +231,7 @@ export namespace Endpoints { }; export type post_Add_upstream_target = { method: "POST"; - path: "/api/upstreams/{upstream_id}/targets"; + path: "/api/nginx/upstreams/{upstream_id}/targets"; requestFormat: "json"; parameters: { body: Schemas.CreateUpstreamTargetInfo; @@ -254,23 +254,23 @@ export type EndpointByMethod = { post: { "/api/auth/init_admin": Endpoints.post_Init_admin; "/api/auth/login": Endpoints.post_Login; - "/api/upstreams": Endpoints.post_Create_upstream; - "/api/upstreams/{upstream_id}/targets": Endpoints.post_Add_upstream_target; + "/api/nginx/upstreams": Endpoints.post_Create_upstream; + "/api/nginx/upstreams/{upstream_id}/targets": Endpoints.post_Add_upstream_target; }; get: { "/api/health/info": Endpoints.get_Get_health_info; - "/api/upstream_targets/{upstream_target_id}": Endpoints.get_Get_upstream_target; - "/api/upstreams": Endpoints.get_Get_upstream_list; - "/api/upstreams/{upstream_id}": Endpoints.get_Get_upstream; + "/api/nginx/upstream_targets/{upstream_target_id}": Endpoints.get_Get_upstream_target; + "/api/nginx/upstreams": Endpoints.get_Get_upstream_list; + "/api/nginx/upstreams/{upstream_id}": Endpoints.get_Get_upstream; "/api/user/me": Endpoints.get_Get_user_info; }; delete: { - "/api/upstream_targets/{upstream_target_id}": Endpoints.delete_Remove_upstream_target; - "/api/upstreams/{upstream_id}": Endpoints.delete_Remove_upstream; + "/api/nginx/upstream_targets/{upstream_target_id}": Endpoints.delete_Remove_upstream_target; + "/api/nginx/upstreams/{upstream_id}": Endpoints.delete_Remove_upstream; }; patch: { - "/api/upstream_targets/{upstream_target_id}": Endpoints.patch_Update_upstream_target; - "/api/upstreams/{upstream_id}": Endpoints.patch_Update_upstream; + "/api/nginx/upstream_targets/{upstream_target_id}": Endpoints.patch_Update_upstream_target; + "/api/nginx/upstreams/{upstream_id}": Endpoints.patch_Update_upstream; }; }; -- 2.49.1 From cb65d4e9f7d18df2101a22ee1d4a505b5e55bcfe Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Wed, 31 Dec 2025 18:03:02 +0800 Subject: [PATCH 34/43] fix incorrect Extension --- apps/api/src/routes/api/restricted/user/me.rs | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/apps/api/src/routes/api/restricted/user/me.rs b/apps/api/src/routes/api/restricted/user/me.rs index a95d5de..125ddc1 100644 --- a/apps/api/src/routes/api/restricted/user/me.rs +++ b/apps/api/src/routes/api/restricted/user/me.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use tracing::error; use crate::{ - middlewares::request_info::RequestInfo, + middlewares::request_info::AuthenticatedRequestInfo, routes::{AppState, api::openapi::tag::USER_TAG}, }; @@ -38,15 +38,9 @@ pub struct UserInfo { )] pub async fn get_user_info( State(app_state): State>, - request_info: Extension>, + request_info: AuthenticatedRequestInfo, ) -> Response { - let user_id = match request_info.user_id { - Some(id) => id, - None => { - error!("User ID not found in request info"); - return (StatusCode::UNAUTHORIZED).into_response(); - } - }; + let user_id = request_info.user_id; match app_state.service.user.get_user_by_id(user_id, None).await { Ok(user) => { -- 2.49.1 From 46801fba9918204b5166b09dcd850031dadb9dfa Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Wed, 31 Dec 2025 18:03:42 +0800 Subject: [PATCH 35/43] improve error logging --- apps/api/src/errors/api_error.rs | 4 ++++ apps/api/src/services/agent_client.rs | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/api/src/errors/api_error.rs b/apps/api/src/errors/api_error.rs index e972587..674d04c 100644 --- a/apps/api/src/errors/api_error.rs +++ b/apps/api/src/errors/api_error.rs @@ -1,14 +1,17 @@ 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 for ApiError { fn from(err: ServiceError) -> Self { + error!("Service error occurred: {:?}", err); ApiError::ServiceError(err) } } @@ -21,6 +24,7 @@ impl From for ApiError { 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(), } diff --git a/apps/api/src/services/agent_client.rs b/apps/api/src/services/agent_client.rs index 09c01aa..efb5ee4 100644 --- a/apps/api/src/services/agent_client.rs +++ b/apps/api/src/services/agent_client.rs @@ -8,7 +8,7 @@ use agent_client::{ }, models::{ValidateAndReloadBody, ValidateBody, WriteConfigBody}, }; -use tracing::warn; +use tracing::{error, warn}; use crate::{configs::agent::AgentSettings, errors::service_error::ServiceError}; @@ -23,6 +23,7 @@ pub enum AgentError { impl From 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), -- 2.49.1 From a0b4df745ed46071232a36fec679120ee9c67e21 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Wed, 31 Dec 2025 18:03:54 +0800 Subject: [PATCH 36/43] fix upstream does not contain a target when init --- apps/api/src/services/nginx/info/upstream.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/api/src/services/nginx/info/upstream.rs b/apps/api/src/services/nginx/info/upstream.rs index cd0f078..17dcdf2 100644 --- a/apps/api/src/services/nginx/info/upstream.rs +++ b/apps/api/src/services/nginx/info/upstream.rs @@ -54,13 +54,13 @@ impl NginxConfigProvider for UpstreamInfo { .iter() .map(|target| target.to_nginx_config(Some(indent.unwrap_or(0) + INDENT_SIZE))) .collect(); - - format!( - "upstream {} {{\n{}\n}}", - self.name, - targets_config.join("\n".indent(indent.unwrap_or(0) + INDENT_SIZE).as_str()) - ) - .indent(indent.unwrap_or(0)) + let targets_config_str = if targets_config.is_empty() { + "server 127.0.0.1:65535 down; # placeholder target".to_string() + } else { + targets_config.join("\n") + } + .indent(indent.unwrap_or(0) + INDENT_SIZE); + format!("upstream {} {{\n{}\n}}", self.name, targets_config_str).indent(indent.unwrap_or(0)) } } -- 2.49.1 From c4634b18f9823cf5eb3d1e0a0a39fda542dda332 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Wed, 31 Dec 2025 18:09:19 +0800 Subject: [PATCH 37/43] Fix CORS method not allowed --- apps/api/src/middlewares.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/api/src/middlewares.rs b/apps/api/src/middlewares.rs index 8799c1d..5d8d634 100644 --- a/apps/api/src/middlewares.rs +++ b/apps/api/src/middlewares.rs @@ -9,7 +9,7 @@ use axum::{ http::{HeaderValue, Method, StatusCode, Uri}, }; use tower::{ServiceBuilder, timeout::TimeoutLayer}; -use tower_http::cors::{AllowHeaders, AllowOrigin, CorsLayer}; +use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer}; use tracing::warn; use crate::{configs::server::CORSSettings, routes::AppState}; @@ -34,6 +34,7 @@ pub fn apply_root_middleware( pub fn get_cors_layer(cors_settings: Arc) -> CorsLayer { let mut cors_layer = CorsLayer::new() .allow_credentials(true) + .allow_methods(AllowMethods::mirror_request()) .allow_headers(AllowHeaders::mirror_request()); let allowed_origins = &cors_settings.allowed_origins; -- 2.49.1 From 9860dddf6083bb5b6bda256d2acdff644353466b Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Wed, 31 Dec 2025 18:09:30 +0800 Subject: [PATCH 38/43] Fix incorrect upstream_target config --- apps/api/src/services/nginx/info/upstream_target.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/services/nginx/info/upstream_target.rs b/apps/api/src/services/nginx/info/upstream_target.rs index 129bae3..b558566 100644 --- a/apps/api/src/services/nginx/info/upstream_target.rs +++ b/apps/api/src/services/nginx/info/upstream_target.rs @@ -130,7 +130,7 @@ impl From for upstream_target::ActiveModel { impl NginxConfigProvider for UpstreamTargetInfo { fn to_nginx_config(&self, indent: Option) -> String { format!( - "{}:{} weight={}{}{}", + "server {}:{} weight={}{}{};", self.target_host, self.target_port, self.weight, -- 2.49.1 From 75097a661b22c4a8d7f66d7f2afcab97501be6e9 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Wed, 31 Dec 2025 19:16:55 +0800 Subject: [PATCH 39/43] Add filtering options for upstream targets in get_upstreams --- .../restricted/nginx/upstream/get_upstream.rs | 10 +++++- apps/api/src/services/nginx/upstream.rs | 36 ++++++++++++++++--- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream.rs b/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream.rs index 7c2fd7c..205a8b5 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream.rs @@ -55,7 +55,14 @@ pub async fn get_upstream_list( ) -> AxumResult, ServiceError> { let upstream_service = &state.service.nginx.get_upstream_service(); let upstreams = upstream_service - .get_upstreams(Some(pagination.clone().into()), None) + .get_upstreams( + Some(pagination.clone().into()), + Some(GetUpstreamOptions { + include_targets: true, + filter_by_enabled: false, + }), + None, + ) .await?; // @@ -93,6 +100,7 @@ pub async fn get_upstream( upstream_id, Some(GetUpstreamOptions { include_targets: true, + filter_by_enabled: false, }), None, ) diff --git a/apps/api/src/services/nginx/upstream.rs b/apps/api/src/services/nginx/upstream.rs index 088244f..ea8c835 100644 --- a/apps/api/src/services/nginx/upstream.rs +++ b/apps/api/src/services/nginx/upstream.rs @@ -1,8 +1,8 @@ use std::sync::Arc; use sea_orm::{ - ActiveModelTrait, ColumnTrait, DatabaseConnection, DatabaseTransaction, EntityTrait, - ModelTrait, QueryFilter, QuerySelect, TransactionTrait, + ActiveModelTrait, ColumnTrait, DatabaseConnection, DatabaseTransaction, EntityTrait, ExprTrait, + ModelTrait, QueryFilter, QuerySelect, QueryTrait, TransactionTrait, }; use database::generated::entities::{upstream, upstream_target}; @@ -38,6 +38,7 @@ pub trait UpstreamService: Send + Sync { async fn get_upstreams( &self, pagination: Option, + options: Option, tx: Option<&mut DatabaseTransaction>, ) -> Result, ServiceError>; async fn update_upstream( @@ -93,6 +94,7 @@ pub struct UpstreamServiceImpl { #[derive(Default)] pub struct GetUpstreamOptions { pub include_targets: bool, + pub filter_by_enabled: bool, } #[derive(Default)] @@ -168,6 +170,9 @@ impl UpstreamService for UpstreamServiceImpl { )))?; let targets = upstream_target::Entity::find() .filter(upstream_target::Column::UpstreamId.eq(upstream_id)) + .apply_if(Some(concrete_options.filter_by_enabled), |query, _v| { + query.filter(upstream_target::Column::Enabled.eq(true)) + }) .all(*conn) .await?; (up, targets) @@ -191,6 +196,7 @@ impl UpstreamService for UpstreamServiceImpl { async fn get_upstreams( &self, pagination: Option, + options: Option, tx: Option<&mut DatabaseTransaction>, ) -> Result, ServiceError> { let r = with_conn!(&*self.connection, tx, conn, { @@ -201,7 +207,24 @@ impl UpstreamService for UpstreamServiceImpl { } else { find_query }; - find_query.all(*conn).await? + 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()) @@ -375,7 +398,9 @@ impl UpstreamService for UpstreamServiceImpl { }); let active_model = target.apply_to_model(current_model); - let r = active_model.update(&*self.connection).await?; + let r = with_conn!(&*self.connection, tx, conn, { + active_model.update(*conn).await? + }); Ok(r.into()) } @@ -505,6 +530,7 @@ mod tests { up_id, Some(GetUpstreamOptions { include_targets: true, + filter_by_enabled: false, }), None, ) @@ -559,7 +585,7 @@ mod tests { let svc = UpstreamServiceImpl::new(Arc::new(db)); - let res = svc.get_upstreams(None, None).await; + 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); -- 2.49.1 From 545bc66f8c412f558485d9e57026d9ebefbad6b2 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Wed, 31 Dec 2025 19:17:14 +0800 Subject: [PATCH 40/43] Fix: invalid config when all are backup --- apps/api/src/services/nginx/info/upstream.rs | 25 +++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/apps/api/src/services/nginx/info/upstream.rs b/apps/api/src/services/nginx/info/upstream.rs index 17dcdf2..f5590b3 100644 --- a/apps/api/src/services/nginx/info/upstream.rs +++ b/apps/api/src/services/nginx/info/upstream.rs @@ -13,6 +13,8 @@ use crate::{ set_if_some, }; +const PLACEHOLDER_TARGET: &str = "server 127.0.0.1:65535 down; # placeholder target\n"; + #[derive(Clone)] pub struct UpstreamInfo { pub id: Uuid, @@ -54,12 +56,18 @@ impl NginxConfigProvider for UpstreamInfo { .iter() .map(|target| target.to_nginx_config(Some(indent.unwrap_or(0) + INDENT_SIZE))) .collect(); - let targets_config_str = if targets_config.is_empty() { - "server 127.0.0.1:65535 down; # placeholder target".to_string() + let mut targets_config_str = if targets_config.is_empty() { + PLACEHOLDER_TARGET.to_string() } else { - targets_config.join("\n") + let mut r = targets_config.join("\n"); + r.push('\n'); + r } .indent(indent.unwrap_or(0) + INDENT_SIZE); + if self.upstream_targets.iter().all(|v| v.is_backup) { + targets_config_str + .push_str(&PLACEHOLDER_TARGET.indent(indent.unwrap_or(0) + INDENT_SIZE)); + } format!("upstream {} {{\n{}\n}}", self.name, targets_config_str).indent(indent.unwrap_or(0)) } } @@ -106,6 +114,17 @@ impl From for UpstreamInfo { } } +impl From<(upstream::Model, Option>)> for UpstreamInfo { + fn from(data: (upstream::Model, Option>)) -> 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)> for UpstreamInfo { fn from(data: (upstream::Model, Vec)) -> Self { let (upstream_model, upstream_target_models) = data; -- 2.49.1 From 3be9ecc4c1068b0654d1e2838770d5b27556905d Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Wed, 31 Dec 2025 20:10:47 +0800 Subject: [PATCH 41/43] Refactor: improve config formatting, clean up imports --- apps/api/src/routes/api/restricted/user/me.rs | 2 +- apps/api/src/services/nginx/builder.rs | 3 +- apps/api/src/services/nginx/info/upstream.rs | 52 ++++++++++++++++--- 3 files changed, 47 insertions(+), 10 deletions(-) diff --git a/apps/api/src/routes/api/restricted/user/me.rs b/apps/api/src/routes/api/restricted/user/me.rs index 125ddc1..a794c9a 100644 --- a/apps/api/src/routes/api/restricted/user/me.rs +++ b/apps/api/src/routes/api/restricted/user/me.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use axum::{ - Extension, Json, + Json, extract::State, http::StatusCode, response::{IntoResponse, Response}, diff --git a/apps/api/src/services/nginx/builder.rs b/apps/api/src/services/nginx/builder.rs index c042d05..4a55df2 100644 --- a/apps/api/src/services/nginx/builder.rs +++ b/apps/api/src/services/nginx/builder.rs @@ -36,7 +36,8 @@ impl NginxConfigProvider for NginxConfigBuilder { } // TODO: Add other sections like servers, locations, etc. - + // trailing newline for file ending + config.push('\n'); config } } diff --git a/apps/api/src/services/nginx/info/upstream.rs b/apps/api/src/services/nginx/info/upstream.rs index f5590b3..4c38b61 100644 --- a/apps/api/src/services/nginx/info/upstream.rs +++ b/apps/api/src/services/nginx/info/upstream.rs @@ -2,6 +2,7 @@ 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::{ @@ -13,7 +14,7 @@ use crate::{ set_if_some, }; -const PLACEHOLDER_TARGET: &str = "server 127.0.0.1:65535 down; # placeholder target\n"; +const PLACEHOLDER_TARGET: &str = "server 127.0.0.1:65535 down; # placeholder target"; #[derive(Clone)] pub struct UpstreamInfo { @@ -56,18 +57,53 @@ impl NginxConfigProvider for UpstreamInfo { .iter() .map(|target| target.to_nginx_config(Some(indent.unwrap_or(0) + INDENT_SIZE))) .collect(); - let mut targets_config_str = if targets_config.is_empty() { - PLACEHOLDER_TARGET.to_string() - } else { - let mut r = targets_config.join("\n"); - r.push('\n'); - r + + 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); + .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)); } + format!("upstream {} {{\n{}\n}}", self.name, targets_config_str).indent(indent.unwrap_or(0)) } } -- 2.49.1 From 5e1a8364c718e02b38808607ef924be6b1fca24e Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Wed, 31 Dec 2025 20:26:20 +0800 Subject: [PATCH 42/43] Fix: update test database query results to include upstream target models --- .../src/routes/api/restricted/nginx/upstream/get_upstream.rs | 5 ++++- apps/api/src/services/nginx/upstream.rs | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream.rs b/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream.rs index 205a8b5..948f056 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream.rs @@ -167,7 +167,10 @@ mod tests { }; let db = MockDatabase::new(DatabaseBackend::Sqlite) - .append_query_results(vec![vec![u1.clone(), u2.clone()]]) + .append_query_results(vec![vec![ + (u1.clone(), None::), + (u2.clone(), None::), + ]]) .into_connection(); let router = get_router_with_state(db.clone()); diff --git a/apps/api/src/services/nginx/upstream.rs b/apps/api/src/services/nginx/upstream.rs index ea8c835..db7f160 100644 --- a/apps/api/src/services/nginx/upstream.rs +++ b/apps/api/src/services/nginx/upstream.rs @@ -580,7 +580,10 @@ mod tests { }; let db = MockDatabase::new(DatabaseBackend::Sqlite) - .append_query_results(vec![vec![u1.clone(), u2.clone()]]) + .append_query_results(vec![vec![ + (u1.clone(), None::), + (u2.clone(), None::), + ]]) .into_connection(); let svc = UpstreamServiceImpl::new(Arc::new(db)); -- 2.49.1 From d21459802c5e67295d0cf713adb35264133cf9cd Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Thu, 1 Jan 2026 10:40:44 +0800 Subject: [PATCH 43/43] Add total upstream count retrieval to UpstreamService --- .../restricted/nginx/upstream/get_upstream.rs | 50 ++++++++++++++----- apps/api/src/services/nginx/upstream.rs | 31 +++++++++++- 2 files changed, 67 insertions(+), 14 deletions(-) diff --git a/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream.rs b/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream.rs index 948f056..827f282 100644 --- a/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream.rs +++ b/apps/api/src/routes/api/restricted/nginx/upstream/get_upstream.rs @@ -54,23 +54,32 @@ pub async fn get_upstream_list( State(state): State>, ) -> AxumResult, ServiceError> { let upstream_service = &state.service.nginx.get_upstream_service(); - let upstreams = upstream_service - .get_upstreams( + + 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, - ) - .await?; + ), + 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: 0, - total_pages: 0, + 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, }, @@ -118,19 +127,21 @@ pub async fn get_upstream( #[cfg(test)] mod tests { use super::*; - use std::sync::Arc; + use std::{collections::BTreeMap, sync::Arc}; use axum::http::StatusCode; use axum_test::TestServer; - use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase}; + use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, Value}; use database::generated::entities::{upstream, upstream_target}; - use crate::configs::{FromConfig, ProgramSettings}; - - use crate::routes::api::restricted::nginx::upstream::get_upstream_router; - use crate::routes::api::restricted::nginx::upstream::info::response::UpstreamInfoResponse; - use crate::services::get_app_service; + 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(); @@ -171,6 +182,10 @@ mod tests { (u1.clone(), None::), (u2.clone(), None::), ]]) + .append_query_results(vec![vec![BTreeMap::from([( + "count".to_string(), + Value::BigInt(Some(2)), + )])]]) .into_connection(); let router = get_router_with_state(db.clone()); @@ -181,6 +196,7 @@ mod tests { let body = res.json::(); assert_eq!(body.items.len(), 2); assert_eq!(body.pagination.current_page, 1u32); + assert_eq!(body.pagination.total_pages, 1u32); } #[tokio::test] @@ -233,6 +249,10 @@ mod tests { async fn extractor_pagination_validation_rejects_bad_values() { let db = MockDatabase::new(DatabaseBackend::Sqlite) .append_query_results(vec![Vec::::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"); @@ -304,6 +324,10 @@ mod tests { async fn handler_get_upstream_list_empty_returns_empty_items() { let db = MockDatabase::new(DatabaseBackend::Sqlite) .append_query_results(vec![Vec::::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()); diff --git a/apps/api/src/services/nginx/upstream.rs b/apps/api/src/services/nginx/upstream.rs index db7f160..9678989 100644 --- a/apps/api/src/services/nginx/upstream.rs +++ b/apps/api/src/services/nginx/upstream.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use sea_orm::{ ActiveModelTrait, ColumnTrait, DatabaseConnection, DatabaseTransaction, EntityTrait, ExprTrait, - ModelTrait, QueryFilter, QuerySelect, QueryTrait, TransactionTrait, + FromQueryResult, ModelTrait, QueryFilter, QuerySelect, QueryTrait, TransactionTrait, }; use database::generated::entities::{upstream, upstream_target}; @@ -29,6 +29,11 @@ pub trait UpstreamService: Send + Sync { create_info: UpstreamCreateInfo, tx: Option<&mut DatabaseTransaction>, ) -> Result; + async fn get_total_upstreams( + &self, + options: Option, + tx: Option<&mut DatabaseTransaction>, + ) -> Result; async fn get_upstream( &self, upstream_id: uuid::Uuid, @@ -97,6 +102,9 @@ pub struct GetUpstreamOptions { pub filter_by_enabled: bool, } +#[allow(dead_code)] +pub struct UpstreamTotalCountOptions {} + #[derive(Default)] pub struct GetUpstreamTargetOptions { pub include_upstream: bool, @@ -152,6 +160,27 @@ impl UpstreamService for UpstreamServiceImpl { Ok(r.into()) } + async fn get_total_upstreams( + &self, + _options: Option, + tx: Option<&mut DatabaseTransaction>, + ) -> Result { + #[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") + .into_model::() + .one(*conn) + .await? + }); + Ok(count_info.map_or(0, |c| c.count) as u64) + } + async fn get_upstream( &self, upstream_id: uuid::Uuid, -- 2.49.1