mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-08 08:04:25 +00:00
* refactor: replace email with permissioned_as for triggers/schedules
Add a new `permissioned_as` column (format: `u/{username}`, `g/{group}`,
or raw email) to all trigger tables and schedule. This value is used
directly for job permission checks, removing the need for email lookups
when creating/updating triggers.
- Migration: add permissioned_as to all 9 trigger tables + schedule,
drop email from trigger tables (schedule keeps it for backwards compat)
- Backend: resolve_email() (async, DB) -> resolve_permissioned_as() (sync)
- Email cache: get_email_from_permissioned_as() with quick_cache for
places that still need email (fetch_api_authed, schedule backwards compat)
- Frontend: rename email/preserve_email -> permissioned_as/preserve_permissioned_as
in deploy data and OpenAPI schemas
- Tests updated for new field names and u/{username} format
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix sqlx/build
* update ee ref
* refactor: simplify resolve_edited_by to always use authed username
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix compile + migration
* update ref
* test: add trigger trait method tests for permissioned_as queries
Add tests that call TriggerCrud and Listener trait methods directly
to verify dynamic SQL correctly references the permissioned_as column.
Covers get_trigger_by_path, list_triggers, set_trigger_mode, and
fetch_enabled_unlistened_triggers for all trigger types.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* update sqlx
* fix: use permissioned_as directly for schedules and fix audit RLS for groups
- Schedule: permissioned_as only set on create, not on edit/set_enabled
- Schedule: stop reading email column, use get_email_from_permissioned_as
- Triggers: use fetch_api_authed_from_permissioned_as instead of edited_by
- Triggers: rename listener fields for clarity (username -> edited_by)
- Fix audit author username for group permissioned_as (g/test -> group-test)
to match session.user, preventing RLS policy violations on audit_partitioned
- OpenAPI: remove permissioned_as/preserve_permissioned_as from EditSchedule
- Add backwards-compat comments for schedule email writes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* chore: regenerate system prompts for permissioned_as field
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix build
* refactor: generalize onBehalfOf naming, add permissioned_as to EditSchedule
- Frontend: rename onBehalfOfPermissionedAs -> onBehalfOf with comments
explaining it carries emails for flows/scripts and permissioned_as for
triggers/schedules
- Frontend: rename getOnBehalfOfEmail -> getOnBehalfOf,
getOnBehalfOfPermissionedAsForDeploy -> getOnBehalfOfForDeploy,
customOnBehalfOfEmails -> customOnBehalfOf
- Backend: add optional permissioned_as/preserve_permissioned_as to
EditSchedule with COALESCE (only updates when provided)
- Backend: add on_behalf_of audit log for schedule edit
- Backend: remove unused resolve_on_behalf_of_permissioned_as
- Tests: remove email assertions from schedule update test (email is
just backwards compat, only permissioned_as matters)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: preserve email column when permissioned_as is preserved on schedule edit
Derive email from the preserved permissioned_as via cache lookup instead
of always writing authed.email. This keeps the email column consistent
with the old behavior for backwards compat with old workers.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: update deploy UI labels from "edited by" to "run as" for triggers
Triggers now use permissioned_as (not edited_by) for permissions, so
update the deploy UI wording to reflect this. Also update wm_deployers
group description to mention schedules and permissioned_as.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use u/username format for custom trigger/schedule deploy selection
When picking a custom user for trigger/schedule deployment, store
u/${username} (permissioned_as format) instead of the email. Flows/scripts
continue to use email format for on_behalf_of_email.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: show u/username format for "me" option in trigger deploy selector
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor: simplify OnBehalfOfSelector to return the right format per kind
OnBehalfOfSelector now handles the email vs permissioned_as format
internally based on kind:
- triggers: returns u/username, displays u/username in all options
- flows/scripts/apps: returns email, displays username
The onSelect callback now takes (choice, value?) where value is already
in the correct format. Parent components just store it directly without
needing to know about the format difference.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: always show u/username format in OnBehalfOfSelector for all kinds
Display is now consistent: all kinds show u/username in the selector.
The returned value still differs (email for flows/scripts, u/username
for triggers) since the backend APIs expect different formats.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: replace email with permissioned_as in http_trigger test insert
The email column was dropped from trigger tables in the migration.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: review fixes — migration, app policy, capture cleanup, naming
- Migration: remove DEFAULT '', use nullable → populate → SET NOT NULL
- App policy: set both on_behalf_of and on_behalf_of_email for all choices
- OnBehalfOfSelector: return OnBehalfOfDetails {email, permissionedAs} instead of ambiguous value
- Remove unused email field from Capture struct and query
- Rename getSourceEmail/getTargetEmail → getSourceOnBehalfOf/getTargetOnBehalfOf
- Rename test functions from preserve_email to preserve_permissioned_as
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: add permissioned_as to all test schedule INSERTs
Since the migration no longer uses DEFAULT '', all INSERTs must
explicitly provide permissioned_as. Updated test fixtures and
schedule_push tests.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: strip permissioned_as from exports/sync, fix OpenAPI required field
- Add permissioned_as to workspace export strip list (like edited_by)
- Add permissioned_as to CLI TriggerFile Omit list
- Fix TriggerExtraProperty.required: email → permissioned_as
- Regenerate frontend and CLI types
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: remove accidentally committed generated files
These directories are gitignored and should not be tracked.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* chore: regenerate system prompts for permissioned_as schema changes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: remove permissioned_as from CLI TriggerFile Omit list
Already stripped in workspace export, no need to also omit from the type.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: optimize email cache key and revert TriggerFile Omit change
- Use single concatenated string for cache key instead of (String, String) tuple
- Remove permissioned_as from CLI TriggerFile Omit (already stripped in export)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: zero-allocation email cache lookups using Equivalent trait
Use a borrowed EmailCacheKey(&str, &str) for cache lookups via
quick_cache's Equivalent support. Only allocates (String, String)
on cache miss for insert. This is called on every trigger fire
and schedule push.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: add permissioned_as to Schedule required fields in OpenAPI spec
The backend always returns permissioned_as (non-optional String),
so the schema should reflect that.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: handle group- prefix in migration UPDATE statements
edited_by can be 'group-{name}' for group-owned triggers/schedules.
The migration now correctly maps these to 'g/{name}' format instead
of incorrectly producing 'u/group-{name}'.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Revert "fix: handle group- prefix in migration UPDATE statements"
This reverts commit 0971392b38.
* fix: use superadmin email to resolve permissioned_as in schedule migration
For users upgrading from older versions where edited_by may not reflect
the actual schedule owner, check if the email belongs to a superadmin
and look up their username. Otherwise fall back to edited_by.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: fall back to superadmin email when not in workspace usr table
If the superadmin isn't a member of the workspace, use their email
as raw permissioned_as instead of falling back to edited_by.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: always update permissioned_as and email on schedule edit
Consistent with pre-refactor behavior where email and edited_by
were always updated on every edit. permissioned_as is now always
set (to editing user or preserved value), removing the COALESCE
that previously preserved it when not provided.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add schedule permission tests and centralize group prefix constants
Tests: schedule create/update for normal user, workspace admin, and
superadmin not in workspace. Verifies schedule fields (email,
permissioned_as, edited_by) and pushed job fields (permissioned_as,
permissioned_as_email).
Constants: centralize "u/", "g/", "group-" as PERMISSIONED_AS_USER_PREFIX,
PERMISSIONED_AS_GROUP_PREFIX, USERNAME_GROUP_PREFIX.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use @unknown.windmill.dev for synthetic email fallback
Prevents privilege escalation: a user with username like
'superadmin_secret' would get superadmin via the synthetic
email matching SUPERADMIN_SECRET_EMAIL. Using a different
subdomain avoids any collision with hardcoded @windmill.dev emails.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* update ee ref
* sqlx
* chore: regenerate system prompts after main merge
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* chore: update ee-repo-ref to bda51bc33bcb573659e7ff07d0a23ff6e23b8148
This commit updates the EE repository reference after PR #468 was merged in windmill-ee-private.
Previous ee-repo-ref: 8cf1802f8fe183f430830590b4f3172a50207843
New ee-repo-ref: bda51bc33bcb573659e7ff07d0a23ff6e23b8148
Automated by sync-ee-ref workflow.
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
610 lines
20 KiB
Rust
610 lines
20 KiB
Rust
use std::collections::HashMap;
|
|
|
|
use quick_cache::sync::Cache;
|
|
use serde::{Deserialize, Serialize};
|
|
use sqlx::{types::Json as SqlxJson, FromRow};
|
|
use tokio::sync::{RwLock, RwLockReadGuard};
|
|
use windmill_common::{
|
|
error::{Error, Result},
|
|
flows::Retry,
|
|
utils::ExpiringCacheEntry,
|
|
worker::CLOUD_HOSTED,
|
|
DB,
|
|
};
|
|
use windmill_types::s3::S3Object;
|
|
|
|
use windmill_api_auth::ApiAuthed;
|
|
use windmill_trigger::TriggerMode;
|
|
|
|
pub mod handler;
|
|
pub mod http_trigger_auth;
|
|
|
|
lazy_static::lazy_static! {
|
|
pub static ref HTTP_ACCESS_CACHE: Cache<(String, String, ApiAuthed), ExpiringCacheEntry<()>> = Cache::new(100);
|
|
pub static ref HTTP_AUTH_CACHE: Cache<(String, String, ApiAuthed), ExpiringCacheEntry<http_trigger_auth::AuthenticationMethod>> = Cache::new(100);
|
|
|
|
pub static ref HTTP_ROUTERS_CACHE: RwLock<RoutersCache> = RwLock::new(RoutersCache {
|
|
routers: HashMap::new(),
|
|
version: 0,
|
|
});
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Clone)]
|
|
pub struct TriggerRoute {
|
|
pub path: String,
|
|
pub script_path: String,
|
|
pub is_flow: bool,
|
|
pub route_path: String,
|
|
pub workspace_id: String,
|
|
pub request_type: RequestType,
|
|
pub authentication_method: AuthenticationMethod,
|
|
pub edited_by: String,
|
|
pub permissioned_as: String,
|
|
pub static_asset_config: Option<sqlx::types::Json<S3Object>>,
|
|
pub is_static_website: bool,
|
|
pub authentication_resource_path: Option<String>,
|
|
pub workspaced_route: bool,
|
|
pub wrap_body: bool,
|
|
pub raw_string: bool,
|
|
pub error_handler_path: Option<String>,
|
|
pub error_handler_args: Option<sqlx::types::Json<HashMap<String, serde_json::Value>>>,
|
|
pub retry: Option<sqlx::types::Json<Retry>>,
|
|
pub mode: TriggerMode,
|
|
}
|
|
|
|
pub struct RoutersCache {
|
|
pub routers: HashMap<HttpMethod, matchit::Router<TriggerRoute>>,
|
|
pub version: i64,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, sqlx::Type, Debug, Clone, Copy, Hash, Eq, PartialEq)]
|
|
#[sqlx(type_name = "HTTP_METHOD", rename_all = "lowercase")]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum HttpMethod {
|
|
Get,
|
|
Post,
|
|
Put,
|
|
Delete,
|
|
Patch,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, sqlx::Type, Debug, Clone, Copy, PartialEq)]
|
|
#[sqlx(type_name = "REQUEST_TYPE", rename_all = "snake_case")]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum RequestType {
|
|
Sync,
|
|
Async,
|
|
SyncSse,
|
|
}
|
|
|
|
impl TryFrom<&http::Method> for HttpMethod {
|
|
type Error = Error;
|
|
fn try_from(method: &http::Method) -> Result<Self> {
|
|
match method {
|
|
&http::Method::GET => Ok(HttpMethod::Get),
|
|
&http::Method::POST => Ok(HttpMethod::Post),
|
|
&http::Method::PUT => Ok(HttpMethod::Put),
|
|
&http::Method::DELETE => Ok(HttpMethod::Delete),
|
|
&http::Method::PATCH => Ok(HttpMethod::Patch),
|
|
_ => Err(Error::BadRequest("Invalid HTTP method".to_string())),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(sqlx::Type, Serialize, Deserialize, Debug, PartialEq, Clone, Copy)]
|
|
#[sqlx(type_name = "AUTHENTICATION_METHOD", rename_all = "snake_case")]
|
|
#[serde(rename_all(serialize = "snake_case", deserialize = "snake_case"))]
|
|
pub enum AuthenticationMethod {
|
|
None,
|
|
Windmill,
|
|
ApiKey,
|
|
BasicHttp,
|
|
CustomScript,
|
|
Signature,
|
|
}
|
|
|
|
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
|
|
pub struct HttpConfig {
|
|
pub route_path: String,
|
|
pub route_path_key: String,
|
|
pub request_type: RequestType,
|
|
pub authentication_method: AuthenticationMethod,
|
|
pub http_method: HttpMethod,
|
|
pub summary: Option<String>,
|
|
pub description: Option<String>,
|
|
pub static_asset_config: Option<SqlxJson<S3Object>>,
|
|
pub is_static_website: bool,
|
|
pub authentication_resource_path: Option<String>,
|
|
pub workspaced_route: bool,
|
|
pub wrap_body: bool,
|
|
pub raw_string: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct HttpConfigRequest {
|
|
#[serde(default)]
|
|
pub route_path: String,
|
|
pub request_type: RequestType,
|
|
pub authentication_method: AuthenticationMethod,
|
|
pub http_method: HttpMethod,
|
|
pub summary: Option<String>,
|
|
pub description: Option<String>,
|
|
pub static_asset_config: Option<SqlxJson<S3Object>>,
|
|
pub is_static_website: bool,
|
|
pub authentication_resource_path: Option<String>,
|
|
pub workspaced_route: Option<bool>,
|
|
pub wrap_body: Option<bool>,
|
|
pub raw_string: Option<bool>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct HttpConfigRequestHelper {
|
|
#[serde(default)]
|
|
route_path: String,
|
|
request_type: Option<RequestType>,
|
|
is_async: Option<bool>,
|
|
authentication_method: AuthenticationMethod,
|
|
http_method: HttpMethod,
|
|
summary: Option<String>,
|
|
description: Option<String>,
|
|
static_asset_config: Option<SqlxJson<S3Object>>,
|
|
is_static_website: bool,
|
|
authentication_resource_path: Option<String>,
|
|
workspaced_route: Option<bool>,
|
|
wrap_body: Option<bool>,
|
|
raw_string: Option<bool>,
|
|
}
|
|
|
|
impl<'de> Deserialize<'de> for HttpConfigRequest {
|
|
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
let helper = HttpConfigRequestHelper::deserialize(deserializer)?;
|
|
|
|
// Determine request_type with backward compatibility
|
|
let request_type = if let Some(mode) = helper.request_type {
|
|
mode
|
|
} else if let Some(is_async) = helper.is_async {
|
|
if is_async {
|
|
RequestType::Async
|
|
} else {
|
|
RequestType::Sync
|
|
}
|
|
} else {
|
|
RequestType::Sync
|
|
};
|
|
|
|
Ok(HttpConfigRequest {
|
|
route_path: helper.route_path,
|
|
request_type,
|
|
authentication_method: helper.authentication_method,
|
|
http_method: helper.http_method,
|
|
summary: helper.summary,
|
|
description: helper.description,
|
|
static_asset_config: helper.static_asset_config,
|
|
is_static_website: helper.is_static_website,
|
|
authentication_resource_path: helper.authentication_resource_path,
|
|
workspaced_route: helper.workspaced_route,
|
|
wrap_body: helper.wrap_body,
|
|
raw_string: helper.raw_string,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Regex patterns for route validation
|
|
lazy_static::lazy_static! {
|
|
// Matches named params like :id or wildcards like :* or *
|
|
pub static ref ROUTE_PATH_KEY_RE: regex::Regex = regex::Regex::new(r"(/)?(:|\*)[-\w]+").unwrap();
|
|
pub static ref VALID_ROUTE_PATH_RE: regex::Regex = regex::Regex::new(r"^(\*[-\w]+$|:?[-\w]+)(/(\*[-\w]+$|:?[-\w]+))*$").unwrap();
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct RouteExists {
|
|
pub route_path: String,
|
|
pub http_method: HttpMethod,
|
|
pub trigger_path: Option<String>,
|
|
pub workspaced_route: Option<bool>,
|
|
}
|
|
|
|
pub fn validate_authentication_method(
|
|
authentication_method: AuthenticationMethod,
|
|
raw_string: Option<bool>,
|
|
) -> Result<()> {
|
|
match (authentication_method, raw_string) {
|
|
(AuthenticationMethod::CustomScript, raw) if !raw.unwrap_or(false) => {
|
|
Err(Error::BadRequest(
|
|
"To use custom script authentication, please enable the raw body option."
|
|
.to_string(),
|
|
))
|
|
}
|
|
_ => Ok(()),
|
|
}
|
|
}
|
|
|
|
pub async fn refresh_routers(db: &DB) -> Result<(bool, RwLockReadGuard<'_, RoutersCache>)> {
|
|
let version = sqlx::query_scalar!("SELECT last_value FROM http_trigger_version_seq",)
|
|
.fetch_one(db)
|
|
.await?;
|
|
let routers_cache = HTTP_ROUTERS_CACHE.read().await;
|
|
if routers_cache.version == 0 || version > routers_cache.version {
|
|
drop(routers_cache);
|
|
let mut routers = HashMap::new();
|
|
|
|
for http_method in [
|
|
HttpMethod::Get,
|
|
HttpMethod::Post,
|
|
HttpMethod::Put,
|
|
HttpMethod::Patch,
|
|
HttpMethod::Delete,
|
|
] {
|
|
let triggers = sqlx::query_as!(
|
|
TriggerRoute,
|
|
r#"
|
|
SELECT
|
|
path,
|
|
script_path,
|
|
is_flow,
|
|
route_path,
|
|
authentication_resource_path,
|
|
workspace_id,
|
|
request_type AS "request_type: _",
|
|
authentication_method AS "authentication_method: _",
|
|
edited_by,
|
|
permissioned_as,
|
|
static_asset_config AS "static_asset_config: _",
|
|
wrap_body,
|
|
raw_string,
|
|
workspaced_route,
|
|
is_static_website,
|
|
error_handler_path,
|
|
error_handler_args as "error_handler_args: _",
|
|
retry as "retry: _",
|
|
mode as "mode: _"
|
|
FROM
|
|
http_trigger
|
|
WHERE
|
|
http_method = $1 AND
|
|
(mode = 'enabled'::TRIGGER_MODE OR mode = 'suspended'::TRIGGER_MODE)
|
|
"#,
|
|
&http_method as &HttpMethod
|
|
)
|
|
.fetch_all(db)
|
|
.await?;
|
|
|
|
let mut router = matchit::Router::new();
|
|
|
|
for trigger in triggers {
|
|
let full_path = if trigger.workspaced_route || *CLOUD_HOSTED {
|
|
format!("/{}/{}", trigger.workspace_id, trigger.route_path)
|
|
} else {
|
|
format!("/{}", trigger.route_path)
|
|
};
|
|
|
|
if trigger.is_static_website {
|
|
router
|
|
.insert(format!("{}/*wm_subpath", full_path), trigger.clone())
|
|
.unwrap_or_else(|e| {
|
|
tracing::warn!(
|
|
"Failed to consider HTTP route {}/*wm_subpath: {:?}",
|
|
full_path,
|
|
e,
|
|
);
|
|
});
|
|
}
|
|
router
|
|
.insert(full_path.clone(), trigger.clone())
|
|
.unwrap_or_else(|e| {
|
|
tracing::warn!("Failed to consider HTTP route {}: {:?}", full_path, e,);
|
|
});
|
|
}
|
|
|
|
routers.insert(http_method, router);
|
|
}
|
|
|
|
let mut routers_cache = HTTP_ROUTERS_CACHE.write().await;
|
|
*routers_cache = RoutersCache { routers, version };
|
|
|
|
Ok((true, routers_cache.downgrade()))
|
|
} else {
|
|
tracing::debug!("No HTTP routers refresh needed");
|
|
Ok((false, routers_cache))
|
|
}
|
|
}
|
|
|
|
pub async fn refresh_routers_loop(
|
|
db: &DB,
|
|
mut killpill_rx: tokio::sync::broadcast::Receiver<()>,
|
|
) -> () {
|
|
match refresh_routers(db).await {
|
|
Ok(_) => {
|
|
tracing::info!("Loaded HTTP routers");
|
|
}
|
|
Err(err) => {
|
|
tracing::error!("Error loading HTTP routers: {err:#}");
|
|
}
|
|
};
|
|
let db = db.clone();
|
|
tokio::spawn(async move {
|
|
loop {
|
|
tokio::select! {
|
|
_ = killpill_rx.recv() => {
|
|
break;
|
|
}
|
|
_ = tokio::time::sleep(std::time::Duration::from_secs(60)) => {
|
|
match refresh_routers(&db).await {
|
|
Ok((true, _)) => {
|
|
tracing::info!("Refreshed HTTP routers");
|
|
}
|
|
Err(err) => {
|
|
tracing::error!("Error refreshing HTTP routers: {err:#}");
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
pub struct HttpTrigger;
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_request_type_backward_compatibility() {
|
|
// Test with new request_type field
|
|
let json_new = r#"{
|
|
"route_path": "/test",
|
|
"request_type": "sync_sse",
|
|
"authentication_method": "none",
|
|
"http_method": "get",
|
|
"is_static_website": false
|
|
}"#;
|
|
let config: HttpConfigRequest = serde_json::from_str(json_new).unwrap();
|
|
assert_eq!(config.request_type, RequestType::SyncSse);
|
|
|
|
// Test with legacy is_async = true
|
|
let json_legacy_async = r#"{
|
|
"route_path": "/test",
|
|
"is_async": true,
|
|
"authentication_method": "none",
|
|
"http_method": "get",
|
|
"is_static_website": false
|
|
}"#;
|
|
let config: HttpConfigRequest = serde_json::from_str(json_legacy_async).unwrap();
|
|
assert_eq!(config.request_type, RequestType::Async);
|
|
|
|
// Test with legacy is_async = false
|
|
let json_legacy_sync = r#"{
|
|
"route_path": "/test",
|
|
"is_async": false,
|
|
"authentication_method": "none",
|
|
"http_method": "get",
|
|
"is_static_website": false
|
|
}"#;
|
|
let config: HttpConfigRequest = serde_json::from_str(json_legacy_sync).unwrap();
|
|
assert_eq!(config.request_type, RequestType::Sync);
|
|
|
|
// Test with neither field (default to sync)
|
|
let json_default = r#"{
|
|
"route_path": "/test",
|
|
"authentication_method": "none",
|
|
"http_method": "get",
|
|
"is_static_website": false
|
|
}"#;
|
|
let config: HttpConfigRequest = serde_json::from_str(json_default).unwrap();
|
|
assert_eq!(config.request_type, RequestType::Sync);
|
|
|
|
// Test that request_type takes precedence over is_async
|
|
let json_both = r#"{
|
|
"route_path": "/test",
|
|
"request_type": "sync_sse",
|
|
"is_async": true,
|
|
"authentication_method": "none",
|
|
"http_method": "get",
|
|
"is_static_website": false
|
|
}"#;
|
|
let config: HttpConfigRequest = serde_json::from_str(json_both).unwrap();
|
|
assert_eq!(config.request_type, RequestType::SyncSse);
|
|
}
|
|
|
|
// --- HttpMethod ---
|
|
|
|
#[test]
|
|
fn test_http_method_from_http_get() {
|
|
let method = HttpMethod::try_from(&http::Method::GET).unwrap();
|
|
assert_eq!(method, HttpMethod::Get);
|
|
}
|
|
|
|
#[test]
|
|
fn test_http_method_from_http_post() {
|
|
let method = HttpMethod::try_from(&http::Method::POST).unwrap();
|
|
assert_eq!(method, HttpMethod::Post);
|
|
}
|
|
|
|
#[test]
|
|
fn test_http_method_from_http_put() {
|
|
let method = HttpMethod::try_from(&http::Method::PUT).unwrap();
|
|
assert_eq!(method, HttpMethod::Put);
|
|
}
|
|
|
|
#[test]
|
|
fn test_http_method_from_http_delete() {
|
|
let method = HttpMethod::try_from(&http::Method::DELETE).unwrap();
|
|
assert_eq!(method, HttpMethod::Delete);
|
|
}
|
|
|
|
#[test]
|
|
fn test_http_method_from_http_patch() {
|
|
let method = HttpMethod::try_from(&http::Method::PATCH).unwrap();
|
|
assert_eq!(method, HttpMethod::Patch);
|
|
}
|
|
|
|
#[test]
|
|
fn test_http_method_unsupported() {
|
|
let result = HttpMethod::try_from(&http::Method::HEAD);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_http_method_options_unsupported() {
|
|
let result = HttpMethod::try_from(&http::Method::OPTIONS);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
// --- HttpMethod serde ---
|
|
|
|
#[test]
|
|
fn test_http_method_serde_roundtrip() {
|
|
for method in [
|
|
HttpMethod::Get,
|
|
HttpMethod::Post,
|
|
HttpMethod::Put,
|
|
HttpMethod::Delete,
|
|
HttpMethod::Patch,
|
|
] {
|
|
let json = serde_json::to_value(method).unwrap();
|
|
let deserialized: HttpMethod = serde_json::from_value(json).unwrap();
|
|
assert_eq!(method, deserialized);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_http_method_serialize_lowercase() {
|
|
assert_eq!(serde_json::to_value(HttpMethod::Get).unwrap(), "get");
|
|
assert_eq!(serde_json::to_value(HttpMethod::Post).unwrap(), "post");
|
|
}
|
|
|
|
// --- RequestType serde ---
|
|
|
|
#[test]
|
|
fn test_request_type_serde_roundtrip() {
|
|
for rt in [RequestType::Sync, RequestType::Async, RequestType::SyncSse] {
|
|
let json = serde_json::to_value(rt).unwrap();
|
|
let deserialized: RequestType = serde_json::from_value(json).unwrap();
|
|
assert_eq!(rt, deserialized);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_request_type_serialize_values() {
|
|
assert_eq!(serde_json::to_value(RequestType::Sync).unwrap(), "sync");
|
|
assert_eq!(serde_json::to_value(RequestType::Async).unwrap(), "async");
|
|
assert_eq!(
|
|
serde_json::to_value(RequestType::SyncSse).unwrap(),
|
|
"sync_sse"
|
|
);
|
|
}
|
|
|
|
// --- AuthenticationMethod serde ---
|
|
|
|
#[test]
|
|
fn test_authentication_method_serde_roundtrip() {
|
|
for method in [
|
|
AuthenticationMethod::None,
|
|
AuthenticationMethod::Windmill,
|
|
AuthenticationMethod::ApiKey,
|
|
AuthenticationMethod::BasicHttp,
|
|
AuthenticationMethod::CustomScript,
|
|
AuthenticationMethod::Signature,
|
|
] {
|
|
let json = serde_json::to_value(method).unwrap();
|
|
let deserialized: AuthenticationMethod = serde_json::from_value(json).unwrap();
|
|
assert_eq!(method, deserialized);
|
|
}
|
|
}
|
|
|
|
// --- validate_authentication_method ---
|
|
|
|
#[test]
|
|
fn test_validate_auth_none_ok() {
|
|
assert!(validate_authentication_method(AuthenticationMethod::None, None).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_auth_windmill_ok() {
|
|
assert!(validate_authentication_method(AuthenticationMethod::Windmill, None).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_auth_custom_script_requires_raw() {
|
|
assert!(validate_authentication_method(AuthenticationMethod::CustomScript, None).is_err());
|
|
assert!(
|
|
validate_authentication_method(AuthenticationMethod::CustomScript, Some(false))
|
|
.is_err()
|
|
);
|
|
assert!(
|
|
validate_authentication_method(AuthenticationMethod::CustomScript, Some(true)).is_ok()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_auth_signature_without_raw_ok() {
|
|
assert!(validate_authentication_method(AuthenticationMethod::Signature, None).is_ok());
|
|
}
|
|
|
|
// --- Route path regex ---
|
|
|
|
#[test]
|
|
fn test_valid_route_path() {
|
|
assert!(VALID_ROUTE_PATH_RE.is_match("users"));
|
|
assert!(VALID_ROUTE_PATH_RE.is_match("users/:id"));
|
|
assert!(VALID_ROUTE_PATH_RE.is_match("api/v1/users"));
|
|
assert!(VALID_ROUTE_PATH_RE.is_match("api/v1/:id"));
|
|
assert!(VALID_ROUTE_PATH_RE.is_match("files/*path"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_invalid_route_path() {
|
|
assert!(!VALID_ROUTE_PATH_RE.is_match(""));
|
|
assert!(!VALID_ROUTE_PATH_RE.is_match("/leading-slash"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_route_path_key_regex() {
|
|
assert!(ROUTE_PATH_KEY_RE.is_match("/:id"));
|
|
assert!(ROUTE_PATH_KEY_RE.is_match("/*path"));
|
|
assert!(ROUTE_PATH_KEY_RE.is_match("/users/:userId/posts/:postId"));
|
|
}
|
|
|
|
// --- HttpConfig deserialization ---
|
|
|
|
#[test]
|
|
fn test_http_config_request_full() {
|
|
let json = r#"{
|
|
"route_path": "api/v1/users",
|
|
"request_type": "async",
|
|
"authentication_method": "api_key",
|
|
"http_method": "post",
|
|
"is_static_website": false,
|
|
"workspaced_route": true,
|
|
"wrap_body": true,
|
|
"raw_string": false
|
|
}"#;
|
|
let config: HttpConfigRequest = serde_json::from_str(json).unwrap();
|
|
assert_eq!(config.route_path, "api/v1/users");
|
|
assert_eq!(config.request_type, RequestType::Async);
|
|
assert_eq!(config.authentication_method, AuthenticationMethod::ApiKey);
|
|
assert_eq!(config.http_method, HttpMethod::Post);
|
|
assert_eq!(config.workspaced_route, Some(true));
|
|
assert_eq!(config.wrap_body, Some(true));
|
|
}
|
|
|
|
#[test]
|
|
fn test_http_config_request_minimal() {
|
|
let json = r#"{
|
|
"authentication_method": "none",
|
|
"http_method": "get",
|
|
"is_static_website": false
|
|
}"#;
|
|
let config: HttpConfigRequest = serde_json::from_str(json).unwrap();
|
|
assert_eq!(config.route_path, "");
|
|
assert_eq!(config.request_type, RequestType::Sync);
|
|
assert!(config.workspaced_route.is_none());
|
|
assert!(config.summary.is_none());
|
|
}
|
|
}
|