Files
windmill/backend/windmill-trigger-http/src/lib.rs
T
Ruben Fiszel 5b3913052e refactor: convert read-hot globals to AtomicBool/I64 and ArcSwap (#8815)
* refactor: extract load helpers from reload_setting family

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: convert atomic primitive globals to AtomicBool/AtomicI64

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: convert CRITICAL_*/HUB_API_SECRET/INSTANCE_EVENTS_WEBHOOK/JWT_SECRET to ArcSwap

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: pin ee-repo-ref to arcswap-refactor EE branch commit

* refactor: convert BASE_URL/HUB_BASE_URL/MIN_VERSION/LICENSE_KEY*/LICENSE_KEY_ID to ArcSwap

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: convert worker hot-path globals to ArcSwap (WORKER_CONFIG et al)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: pin ee-repo-ref to combined arcswap-urls+worker EE commit

* chore: update ee-repo-ref to d8be8f88cb8898c8f6b27421989d53528223815d

This commit updates the EE repository reference after PR #532 was merged in windmill-ee-private.

Previous ee-repo-ref: c375aaaac9ec0fc0480993627d0defc8054c31a4

New ee-repo-ref: d8be8f88cb8898c8f6b27421989d53528223815d

Automated by sync-ee-ref workflow.

* fix: cleanup unused imports + fix 2 missed WORKER_CONFIG readers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to ce0f8fbbbde09c4a858312d2d8716d224e99042c

This commit updates the EE repository reference after PR #534 was merged in windmill-ee-private.

Previous ee-repo-ref: 450b601b5aba0ca0b2045f4b5071aa8701b4bfb7

New ee-repo-ref: ce0f8fbbbde09c4a858312d2d8716d224e99042c

Automated by sync-ee-ref workflow.

* fix: secret_backend_integration test — BASE_URL.write().await → .store()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: convert APP_WORKSPACED_ROUTE to AtomicBool for symmetry with HTTP_ROUTE_WORKSPACED_ROUTE

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to e587df8 (post-#535 merge)

---------

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>
2026-04-14 00:04:10 +00:00

613 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,
global_settings::HTTP_ROUTE_WORKSPACED_ROUTE,
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();
let http_route_workspaced = HTTP_ROUTE_WORKSPACED_ROUTE.load(std::sync::atomic::Ordering::Relaxed);
for trigger in triggers {
let full_path =
if trigger.workspaced_route || *CLOUD_HOSTED || http_route_workspaced {
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());
}
}