refactor: complete trigger extraction by removing originals from windmill-api

Remove the original triggers/ and native_triggers/ directories from
windmill-api/src/ and replace `pub mod` declarations with `pub use`
re-exports from the windmill-triggers crate. This completes the extraction
by ensuring trigger code is only compiled as part of windmill-triggers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-02-06 12:42:24 +00:00
co-authored by Claude Opus 4.6
parent 9b165361d9
commit 5aaecf0da3
47 changed files with 2 additions and 14635 deletions
+2 -2
View File
@@ -171,7 +171,7 @@ pub mod teams_approvals_ee;
mod teams_approvals_oss;
#[cfg(feature = "native_trigger")]
pub mod native_triggers;
pub use windmill_triggers::native_triggers;
mod public_app_layer;
mod public_app_rate_limit;
mod static_assets;
@@ -187,7 +187,7 @@ pub mod teams_ee;
mod teams_oss;
mod token;
mod tracing_init;
pub mod triggers;
pub use windmill_triggers::triggers;
mod users;
#[cfg(feature = "private")]
pub mod users_ee;
@@ -1,566 +0,0 @@
use crate::{
db::ApiAuthed,
native_triggers::{
delete_native_trigger, delete_token_by_prefix, get_native_trigger, get_token_by_prefix,
get_workspace_integration, list_native_triggers, store_native_trigger,
update_native_trigger_error, External, NativeTrigger, NativeTriggerConfig,
NativeTriggerData, ServiceName,
},
users::{create_token_internal, NewToken},
utils::check_scopes,
};
use axum::{
extract::{Path, Query},
routing::{delete, get, post},
Extension, Json, Router,
};
use serde::{Deserialize, Serialize};
use sqlx::PgConnection;
use std::sync::Arc;
use windmill_audit::{audit_oss::audit_log, ActionKind};
use windmill_common::{
db::UserDB,
error::{Error, JsonResult, Result},
utils::rd_string,
DB,
};
async fn require_is_writer_on_runnable(
authed: &ApiAuthed,
path: &str,
is_flow: bool,
w_id: &str,
db: DB,
) -> Result<()> {
if is_flow {
crate::flows::require_is_writer(authed, path, w_id, db).await
} else {
crate::scripts::require_is_writer(authed, path, w_id, db).await
}
}
#[derive(Debug, Deserialize)]
pub struct ListQuery {
pub page: Option<usize>,
pub per_page: Option<usize>,
pub path: Option<String>,
pub is_flow: Option<bool>,
}
#[derive(Debug, Serialize)]
pub struct FullTriggerResponse<T: Serialize> {
#[serde(flatten)]
pub windmill_data: NativeTrigger,
pub external_data: T,
}
#[derive(Debug, Serialize)]
pub struct CreateTriggerResponse {
pub external_id: String,
}
async fn new_webhook_token(
tx: &mut PgConnection,
db: &DB,
authed: &ApiAuthed,
script_path: &str,
is_flow: bool,
workspace_id: &str,
service_name: ServiceName,
) -> Result<String> {
let kind = if is_flow { "flows" } else { "scripts" };
let scopes = vec![format!("jobs:run:{kind}:{script_path}")];
let label = format!("webhook-{}-{}", service_name.as_str(), rd_string(5));
let token_config = NewToken::new(
Some(label),
None,
None,
Some(scopes),
Some(workspace_id.to_owned()),
);
let token = create_token_internal(&mut *tx, &db, &authed, token_config).await?;
Ok(token)
}
async fn create_native_trigger<T: External>(
Extension(handler): Extension<Arc<T>>,
Extension(service_name): Extension<ServiceName>,
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path(workspace_id): Path<String>,
Json(data): Json<NativeTriggerData<T::ServiceConfig>>,
) -> JsonResult<CreateTriggerResponse> {
check_scopes(&authed, || {
format!("native_triggers:write:{}", &data.script_path)
})?;
require_is_writer_on_runnable(
&authed,
&data.script_path,
data.is_flow,
&workspace_id,
db.clone(),
)
.await?;
let mut tx = user_db.begin(&authed).await?;
let webhook_token = new_webhook_token(
&mut *tx,
&db,
&authed,
&data.script_path,
data.is_flow,
&workspace_id,
service_name,
)
.await?;
let integration = get_workspace_integration(&mut *tx, &workspace_id, service_name).await?;
let oauth_data: T::OAuthData = serde_json::from_value(integration.oauth_data).map_err(|e| {
Error::InternalErr(format!(
"Failed to parse {} OAuth data: {}",
T::DISPLAY_NAME,
e
))
})?;
let resp = handler
.create(
&workspace_id,
&oauth_data,
&webhook_token,
&data,
&db,
&mut tx,
)
.await?;
let (external_id, _) = handler.external_id_and_metadata_from_response(&resp);
// update the created external trigger with a new uri containing the external_id
handler
.update(
&workspace_id,
&oauth_data,
&external_id,
&webhook_token,
&data,
&db,
&mut tx,
)
.await?;
// Fetch the updated trigger data from the external service and extract service_config
let trigger_data = handler
.get(&workspace_id, &oauth_data, &external_id, &db, &mut tx)
.await?;
let service_config = handler.extract_service_config_from_trigger_data(&trigger_data)?;
let config = NativeTriggerConfig {
script_path: data.script_path.clone(),
is_flow: data.is_flow,
webhook_token,
};
store_native_trigger(
&mut *tx,
&workspace_id,
service_name,
&external_id,
&config,
service_config,
)
.await?;
audit_log(
&mut *tx,
&authed,
&format!("native_triggers.{}.create", service_name),
ActionKind::Create,
&workspace_id,
Some(&external_id),
None,
)
.await?;
tx.commit().await?;
Ok(Json(CreateTriggerResponse { external_id }))
}
async fn update_native_trigger_handler<T: External>(
Extension(handler): Extension<Arc<T>>,
Extension(service_name): Extension<ServiceName>,
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((workspace_id, external_id)): Path<(String, String)>,
Json(data): Json<NativeTriggerData<T::ServiceConfig>>,
) -> Result<String> {
check_scopes(&authed, || {
format!("native_triggers:write:{}", &data.script_path)
})?;
require_is_writer_on_runnable(
&authed,
&data.script_path,
data.is_flow,
&workspace_id,
db.clone(),
)
.await?;
let mut tx = user_db.begin(&authed).await?;
let existing = get_native_trigger(&mut *tx, &workspace_id, service_name, &external_id)
.await?
.ok_or_else(|| Error::NotFound(format!("Native trigger not found: {}", external_id)))?;
// Look up the full token using the stored prefix (use db, not tx, for token table)
let webhook_token = match get_token_by_prefix(&db, &existing.webhook_token_prefix).await? {
Some(token) => token,
None => {
tracing::warn!(
"Webhook token not found for trigger {} (prefix: {}), recreating token",
external_id,
existing.webhook_token_prefix
);
new_webhook_token(
&mut *tx,
&db,
&authed,
&data.script_path,
data.is_flow,
&workspace_id,
service_name,
)
.await?
}
};
let integration = get_workspace_integration(&mut *tx, &workspace_id, service_name).await?;
let oauth_data: T::OAuthData = serde_json::from_value(integration.oauth_data).map_err(|e| {
Error::InternalErr(format!(
"Failed to parse {} OAuth data: {}",
T::DISPLAY_NAME,
e
))
})?;
handler
.update(
&workspace_id,
&oauth_data,
&external_id,
&webhook_token,
&data,
&db,
&mut tx,
)
.await?;
// Fetch the updated trigger data from the external service and extract service_config
let trigger_data = handler
.get(&workspace_id, &oauth_data, &external_id, &db, &mut tx)
.await?;
let service_config = handler.extract_service_config_from_trigger_data(&trigger_data)?;
let config = NativeTriggerConfig {
script_path: data.script_path.clone(),
is_flow: data.is_flow,
webhook_token,
};
store_native_trigger(
&mut *tx,
&workspace_id,
service_name,
&external_id,
&config,
service_config,
)
.await?;
audit_log(
&mut *tx,
&authed,
&format!("native_triggers.{}.update", service_name),
ActionKind::Update,
&workspace_id,
Some(&external_id),
None,
)
.await?;
tx.commit().await?;
Ok(format!("Native trigger updated"))
}
async fn get_native_trigger_handler<T: External>(
Extension(handler): Extension<Arc<T>>,
Extension(service_name): Extension<ServiceName>,
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((workspace_id, external_id)): Path<(String, String)>,
) -> JsonResult<FullTriggerResponse<T::TriggerData>> {
let mut tx = user_db.begin(&authed).await?;
let windmill_trigger = get_native_trigger(&mut *tx, &workspace_id, service_name, &external_id)
.await?
.ok_or_else(|| Error::NotFound(format!("Native trigger not found: {}", external_id)))?;
check_scopes(&authed, || {
format!("native_triggers:read:{}", &windmill_trigger.script_path)
})?;
require_is_writer_on_runnable(
&authed,
&windmill_trigger.script_path,
windmill_trigger.is_flow,
&workspace_id,
db.clone(),
)
.await?;
let integration = get_workspace_integration(&mut *tx, &workspace_id, service_name).await?;
let oauth_data: T::OAuthData = serde_json::from_value(integration.oauth_data).map_err(|e| {
Error::InternalErr(format!(
"Failed to parse {} OAuth data: {}",
T::DISPLAY_NAME,
e
))
})?;
let native_trigger = handler
.get(&workspace_id, &oauth_data, &external_id, &db, &mut tx)
.await;
let native_trigger_config = match native_trigger {
Ok(native_cfg) => {
// Clear error if it was set
if windmill_trigger.error.is_some() {
update_native_trigger_error(
&mut *tx,
&workspace_id,
service_name,
&external_id,
None,
)
.await?;
}
native_cfg
}
Err(Error::NotFound(_)) => {
let error_msg = "Trigger no longer exists on external service".to_string();
tracing::warn!(
"Native trigger no longer exists on external service {}, setting error",
service_name
);
update_native_trigger_error(
&mut *tx,
&workspace_id,
service_name,
&external_id,
Some(&error_msg),
)
.await?;
tx.commit().await?;
return Err(Error::NotFound(format!(
"Trigger '{}' no longer exists on external service {}",
external_id, service_name
)));
}
Err(e) => return Err(e),
};
let full_resp = Json(FullTriggerResponse {
windmill_data: windmill_trigger,
external_data: native_trigger_config,
});
Ok(full_resp)
}
async fn delete_native_trigger_handler<T: External>(
Extension(handler): Extension<Arc<T>>,
Extension(service_name): Extension<ServiceName>,
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((workspace_id, external_id)): Path<(String, String)>,
) -> Result<String> {
let mut tx = user_db.begin(&authed).await?;
let existing = get_native_trigger(&mut *tx, &workspace_id, service_name, &external_id)
.await?
.ok_or_else(|| Error::NotFound(format!("Native trigger not found: {}", external_id)))?;
check_scopes(&authed, || {
format!("native_triggers:write:{}", &existing.script_path)
})?;
require_is_writer_on_runnable(
&authed,
&existing.script_path,
existing.is_flow,
&workspace_id,
db.clone(),
)
.await?;
let integration = get_workspace_integration(&mut *tx, &workspace_id, service_name).await?;
let oauth_data: T::OAuthData = serde_json::from_value(integration.oauth_data).map_err(|e| {
Error::InternalErr(format!(
"Failed to parse {} OAuth data: {}",
T::DISPLAY_NAME,
e
))
})?;
handler
.delete(&workspace_id, &oauth_data, &external_id, &db, &mut tx)
.await?;
let deleted =
delete_native_trigger(&mut *tx, &workspace_id, service_name, &external_id).await?;
if !deleted {
return Err(Error::NotFound(format!("Native trigger not found")));
}
// Delete the webhook token using its prefix
if !delete_token_by_prefix(&db, &existing.webhook_token_prefix).await? {
tracing::warn!(
"Webhook token not found when deleting trigger {} (prefix: {})",
external_id,
existing.webhook_token_prefix
);
}
audit_log(
&mut *tx,
&authed,
&format!("native_triggers.{}.delete", service_name),
ActionKind::Delete,
&workspace_id,
Some(&external_id),
None,
)
.await?;
tx.commit().await?;
Ok(format!("Native trigger deleted"))
}
async fn exists_native_trigger_handler<T: External>(
Extension(service_name): Extension<ServiceName>,
_authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((workspace_id, external_id)): Path<(String, String)>,
) -> JsonResult<bool> {
let exists = sqlx::query_scalar!(
r#"
SELECT EXISTS(
SELECT 1
FROM native_trigger
WHERE
workspace_id = $1 AND
service_name = $2 AND
external_id = $3
)
"#,
workspace_id,
service_name as ServiceName,
external_id
)
.fetch_one(&db)
.await?
.unwrap_or(false);
Ok(Json(exists))
}
async fn list_native_triggers_handler<T: External>(
Extension(service_name): Extension<ServiceName>,
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path(workspace_id): Path<String>,
Query(query): Query<ListQuery>,
) -> JsonResult<Vec<NativeTrigger>> {
let mut tx = user_db.begin(&authed).await?;
let triggers = list_native_triggers(
&mut *tx,
&workspace_id,
service_name,
query.page,
query.per_page,
query.path.as_deref(),
query.is_flow,
)
.await?;
tx.commit().await?;
Ok(Json(triggers))
}
pub fn service_routes<T: External + 'static>(handler: T) -> Router {
let additional_routes = handler.additional_routes();
let service_name = T::SERVICE_NAME;
let handler_arc = Arc::new(handler);
let standard_routes = Router::new()
.route("/create", post(create_native_trigger::<T>))
.route("/list", get(list_native_triggers_handler::<T>))
.route("/get/:external_id", get(get_native_trigger_handler::<T>))
.route(
"/update/:external_id",
post(update_native_trigger_handler::<T>),
)
.route(
"/delete/:external_id",
delete(delete_native_trigger_handler::<T>),
)
.route(
"/exists/:external_id",
get(exists_native_trigger_handler::<T>),
);
standard_routes
.merge(additional_routes)
.layer(Extension(handler_arc))
.layer(Extension(service_name))
}
/// Generates routes for all registered native trigger services.
/// When adding a new service, add a new `.nest()` call here.
pub fn generate_native_trigger_routers() -> Router {
let router = Router::new();
#[cfg(feature = "native_trigger")]
{
use crate::native_triggers::nextcloud::NextCloud;
// Register all service routes here
// When adding a new service:
// 1. Import the handler: use crate::native_triggers::newservice::NewServiceHandler;
// 2. Add the route: .nest("/newservice", service_routes(NewServiceHandler))
return router.nest("/nextcloud", service_routes(NextCloud));
// Add new services here:
// .nest("/newservice", service_routes(NewServiceHandler))
}
#[cfg(not(feature = "native_trigger"))]
{
router
}
}
File diff suppressed because it is too large Load Diff
@@ -1,304 +0,0 @@
use reqwest::Method;
use serde::{Deserialize, Serialize};
use serde_json::value::to_raw_value;
use sqlx::PgConnection;
use std::collections::HashMap;
use windmill_common::{
error::{Error, Result},
BASE_URL, DB,
};
use crate::native_triggers::{
generate_webhook_service_url,
nextcloud::{
routes, NextCloud, NextCloudOAuthData, NextCloudTriggerData, NextcloudServiceConfig,
OcsResponse,
},
External, NativeTriggerData, ServiceName,
};
lazy_static::lazy_static! {
pub static ref TOKEN_NEEDED: Box<serde_json::value::RawValue> = to_raw_value(&serde_json::json!({
"user_roles": ["owner", "trigger"]
})).unwrap();
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct FullNextcloudPayload {
pub http_method: String,
pub uri: String,
pub token_needed: Box<serde_json::value::RawValue>,
#[serde(flatten)]
service_config: NextcloudServiceConfig,
}
impl FullNextcloudPayload {
async fn new(
w_id: &str,
external_id: Option<&str>,
webhook_token: &str,
data: &NativeTriggerData<NextcloudServiceConfig>,
) -> FullNextcloudPayload {
let base_url = &*BASE_URL.read().await;
let uri = generate_webhook_service_url(
base_url,
w_id,
&data.script_path,
data.is_flow,
external_id,
ServiceName::Nextcloud,
webhook_token,
);
FullNextcloudPayload {
http_method: http::Method::POST.to_string().to_uppercase(),
uri,
token_needed: TOKEN_NEEDED.clone(),
service_config: data.service_config.clone(),
}
}
}
#[derive(Debug, Deserialize)]
pub struct RegisterWebhookResponse {
pub id: i64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct User {
pub uid: String,
#[serde(rename = "displayName")]
pub display_name: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct WebhookPayload {
pub event: EventPayload,
pub user: User,
pub time: i64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct EventPayload {
pub node: Node,
#[serde(rename = "class")]
pub class_name: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Node {
pub id: i64,
pub path: String,
}
#[async_trait::async_trait]
impl External for NextCloud {
type ServiceConfig = NextcloudServiceConfig;
type TriggerData = NextCloudTriggerData;
type OAuthData = NextCloudOAuthData;
type CreateResponse = RegisterWebhookResponse;
const SERVICE_NAME: ServiceName = ServiceName::Nextcloud;
const DISPLAY_NAME: &'static str = "Nextcloud";
const SUPPORT_WEBHOOK: bool = true;
const TOKEN_ENDPOINT: &'static str = "/apps/oauth2/api/v1/token";
const REFRESH_ENDPOINT: &'static str = "/apps/oauth2/api/v1/token";
async fn create(
&self,
w_id: &str,
oauth_data: &Self::OAuthData,
webhook_token: &str,
data: &NativeTriggerData<Self::ServiceConfig>,
db: &DB,
tx: &mut PgConnection,
) -> Result<Self::CreateResponse> {
// During create, we don't have external_id yet (it comes from NextCloud's response)
let full_nextcloud_payload =
FullNextcloudPayload::new(w_id, None, webhook_token, data).await;
let url = format!(
"{}/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks",
oauth_data.base_url
);
let mut headers = HashMap::new();
headers.insert("OCS-APIRequest".to_string(), "true".to_string());
let ocs_response = self
.http_client_request::<OcsResponse<RegisterWebhookResponse>, _>(
&url,
Method::POST,
w_id,
tx,
db,
Some(headers),
Some(&full_nextcloud_payload),
)
.await?;
Ok(ocs_response.ocs.data)
}
async fn update(
&self,
w_id: &str,
oauth_data: &Self::OAuthData,
external_id: &str,
webhook_token: &str,
data: &NativeTriggerData<Self::ServiceConfig>,
db: &DB,
tx: &mut PgConnection,
) -> Result<()> {
// During update, we have the external_id so include it in the webhook URL
let full_nextcloud_payload =
FullNextcloudPayload::new(w_id, Some(external_id), webhook_token, data).await;
let url = format!(
"{}/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks/{}",
oauth_data.base_url, external_id
);
let mut headers = HashMap::new();
headers.insert("OCS-APIRequest".to_string(), "true".to_string());
let _ = self
.http_client_request::<serde_json::Value, _>(
&url,
Method::POST,
w_id,
tx,
db,
Some(headers),
Some(&full_nextcloud_payload),
)
.await?;
Ok(())
}
async fn get(
&self,
w_id: &str,
oauth_data: &Self::OAuthData,
external_id: &str,
db: &DB,
tx: &mut PgConnection,
) -> Result<Self::TriggerData> {
let url = format!(
"{}/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks/{}",
oauth_data.base_url, external_id
);
let mut headers = HashMap::new();
headers.insert("OCS-APIRequest".to_string(), "true".to_string());
let ocs_response: OcsResponse<NextCloudTriggerData> = self
.http_client_request::<_, ()>(&url, Method::GET, w_id, tx, db, Some(headers), None)
.await?;
Ok(ocs_response.ocs.data)
}
async fn delete(
&self,
w_id: &str,
oauth_data: &Self::OAuthData,
external_id: &str,
db: &DB,
tx: &mut PgConnection,
) -> Result<()> {
let url = format!(
"{}/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks/{}",
oauth_data.base_url, external_id
);
let mut headers = HashMap::new();
headers.insert("OCS-APIRequest".to_string(), "true".to_string());
let _: serde_json::Value = self
.http_client_request::<_, ()>(&url, Method::DELETE, w_id, tx, db, Some(headers), None)
.await
.or_else(|e| match &e {
Error::InternalErr(msg) if msg.contains("404") => Ok(serde_json::Value::Null),
_ => Err(e),
})?;
Ok(())
}
async fn exists(
&self,
w_id: &str,
oauth_data: &Self::OAuthData,
external_id: &str,
db: &DB,
tx: &mut PgConnection,
) -> Result<bool> {
let url = format!(
"{}/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks/{}",
oauth_data.base_url, external_id
);
let mut headers = HashMap::new();
headers.insert("OCS-APIRequest".to_string(), "true".to_string());
let _ = self
.http_client_request::<serde_json::Value, ()>(
&url,
Method::GET,
w_id,
tx,
db,
Some(headers),
None,
)
.await?;
Ok(true)
}
async fn list_all(
&self,
w_id: &str,
oauth_data: &Self::OAuthData,
db: &DB,
tx: &mut PgConnection,
) -> Result<Vec<Self::TriggerData>> {
let url = format!(
"{}/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks",
oauth_data.base_url
);
let mut headers = HashMap::new();
headers.insert("OCS-APIRequest".to_string(), "true".to_string());
let ocs_response = self
.http_client_request::<OcsResponse<Vec<NextCloudTriggerData>>, ()>(
&url,
Method::GET,
w_id,
tx,
db,
Some(headers),
None,
)
.await?;
Ok(ocs_response.ocs.data)
}
fn external_id_and_metadata_from_response(
&self,
resp: &Self::CreateResponse,
) -> (String, Option<serde_json::Value>) {
(resp.id.to_string(), None)
}
fn get_external_id_from_trigger_data(&self, data: &Self::TriggerData) -> String {
data.id.to_string()
}
fn additional_routes(&self) -> axum::Router {
routes::nextcloud_routes(self.clone())
}
}
@@ -1,64 +0,0 @@
use serde::{Deserialize, Serialize};
pub mod external;
mod routes;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct NextCloudOAuthData {
pub base_url: String,
pub access_token: String,
pub refresh_token: Option<String>,
pub token_expires_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct OcsResponse<T = String> {
pub ocs: OcsData<T>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Meta {
status: String,
#[serde(rename = "statuscode")]
status_code: u16,
message: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct OcsData<T> {
pub meta: Meta,
pub data: T,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NextCloudEventType {
pub name: String,
pub description: Option<String>,
pub path: Option<String>,
pub parameters: Option<serde_json::Value>,
}
#[derive(Copy, Clone)]
pub struct NextCloud;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NextcloudServiceConfig {
pub event: String,
pub event_filter: Option<Box<serde_json::value::RawValue>>,
pub user_id_filter: Option<String>,
pub headers: Option<Box<serde_json::value::RawValue>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NextCloudTriggerData {
#[serde(skip_serializing)]
pub id: i64,
#[serde(skip_serializing)]
pub uri: String,
pub event: String,
pub event_filter: Option<Box<serde_json::value::RawValue>>,
pub user_id_filter: Option<String>,
pub headers: Option<Box<serde_json::value::RawValue>>,
}
@@ -1,66 +0,0 @@
use std::{collections::HashMap, sync::Arc};
use axum::{extract::Path, routing::get, Extension, Json, Router};
use http::Method;
use windmill_common::{
db::UserDB,
error::{Error, JsonResult},
DB,
};
use crate::{
db::ApiAuthed,
native_triggers::{
get_workspace_integration,
nextcloud::{NextCloudEventType, OcsResponse},
External, OAuthConfig, ServiceName,
},
};
async fn list_available_events<T: External>(
authed: ApiAuthed,
Extension(handler): Extension<Arc<T>>,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path(workspace_id): Path<String>,
) -> JsonResult<Vec<NextCloudEventType>> {
let mut tx = user_db.clone().begin(&authed).await?;
let integration =
get_workspace_integration(&mut *tx, &workspace_id, ServiceName::Nextcloud).await?;
let auth = serde_json::from_value::<OAuthConfig>(integration.oauth_data)
.map_err(|e| Error::InternalErr(format!("Failed to parse NextCloud OAuth data: {}", e)))?;
let url = format!(
"{}/ocs/v2.php/apps/integration_windmill/api/v1/list/events",
&auth.base_url,
);
let mut headers = HashMap::new();
headers.insert("OCS-APIRequest".to_string(), "true".to_string());
let ocs_response = handler
.http_client_request::<OcsResponse, ()>(
&url,
Method::GET,
&workspace_id,
&mut *tx,
&db,
Some(headers),
None,
)
.await?;
tx.commit().await?;
let events = serde_json::from_str(&ocs_response.ocs.data)
.map_err(|e| Error::InternalErr(format!("Failed to parse NextCloud events data: {}", e)))?;
Ok(Json(events))
}
pub fn nextcloud_routes<T: External>(service: T) -> Router {
let service = Arc::new(service);
Router::new()
.route("/events", get(list_available_events::<T>))
.layer(Extension(service))
}
@@ -1,416 +0,0 @@
use std::collections::HashMap;
use windmill_common::error::Result;
use windmill_common::DB;
use serde::Serialize;
use crate::native_triggers::{
decrypt_oauth_data, list_native_triggers, update_native_trigger_error,
update_native_trigger_service_config, External, ServiceName,
};
#[derive(Debug, Serialize)]
pub struct TriggerSyncInfo {
pub external_id: String,
pub script_path: String,
pub action: SyncAction,
}
#[derive(Debug, Serialize)]
pub enum SyncAction {
ErrorSet(String),
ErrorCleared,
ConfigUpdated,
}
#[derive(Debug, Serialize)]
pub struct SyncError {
pub resource_path: String,
pub error_message: String,
pub error_type: String,
}
#[derive(Debug)]
pub struct BackgroundSyncResult {
pub workspaces_processed: usize,
pub total_synced: usize,
pub total_errors: usize,
pub service_results: HashMap<ServiceName, ServiceSyncResult>,
}
#[derive(Debug)]
pub struct ServiceSyncResult {
pub synced_triggers: Vec<TriggerSyncInfo>,
pub errors: Vec<SyncError>,
}
pub async fn sync_all_triggers(db: &DB) -> Result<BackgroundSyncResult> {
tracing::info!("Starting native triggers sync");
let mut service_results: HashMap<ServiceName, ServiceSyncResult> = HashMap::new();
let mut total_synced = 0;
let mut total_errors = 0;
let mut workspaces_processed = 0;
// Sync all registered services
// Each service only syncs workspaces that have the corresponding integration configured
#[cfg(feature = "native_trigger")]
{
use crate::native_triggers::nextcloud::NextCloud;
let (service_name, result) = sync_service_triggers(db, NextCloud).await;
total_synced += result.synced_triggers.len();
total_errors += result.errors.len();
service_results.insert(service_name, result);
// Add new services here:
// use crate::native_triggers::newservice::NewService;
// let (service_name, result) = sync_service_triggers(db, NewService).await;
// total_synced += result.synced_triggers.len();
// total_errors += result.errors.len();
// service_results.insert(service_name, result);
}
// Count unique workspaces processed across all services
for result in service_results.values() {
workspaces_processed += result
.synced_triggers
.iter()
.map(|t| &t.external_id)
.collect::<std::collections::HashSet<_>>()
.len();
}
let result =
BackgroundSyncResult { workspaces_processed, total_synced, total_errors, service_results };
tracing::info!(
"Completed native triggers sync: {} updated, {} errors",
result.total_synced,
result.total_errors
);
Ok(result)
}
async fn sync_service_triggers<T: External>(
db: &DB,
handler: T,
) -> (ServiceName, ServiceSyncResult) {
let mut all_synced_triggers = Vec::new();
let mut all_errors = Vec::new();
// Only sync workspaces that have the corresponding integration configured
let workspaces_with_integration = match sqlx::query_scalar!(
r#"
SELECT wi.workspace_id
FROM workspace_integrations wi
JOIN workspace w ON w.id = wi.workspace_id
WHERE wi.service_name = $1
AND wi.oauth_data IS NOT NULL
AND w.deleted = false
"#,
T::SERVICE_NAME as ServiceName
)
.fetch_all(db)
.await
{
Ok(workspaces) => workspaces,
Err(e) => {
tracing::error!(
"Error querying workspaces with {} integration: {:#}",
T::SERVICE_NAME.as_str(),
e
);
all_errors.push(SyncError {
resource_path: "database".to_string(),
error_message: format!("Failed to query workspaces: {}", e),
error_type: "database_error".to_string(),
});
return (
T::SERVICE_NAME,
ServiceSyncResult { synced_triggers: Vec::new(), errors: all_errors },
);
}
};
if workspaces_with_integration.is_empty() {
tracing::debug!(
"No workspaces with {} integration configured, skipping sync",
T::SERVICE_NAME.as_str()
);
return (
T::SERVICE_NAME,
ServiceSyncResult { synced_triggers: Vec::new(), errors: Vec::new() },
);
}
tracing::info!(
"Found {} workspaces with {} integration configured",
workspaces_with_integration.len(),
T::SERVICE_NAME.as_str()
);
for workspace_id in workspaces_with_integration {
let sync_result = sync_workspace_triggers::<T>(db, &workspace_id, &handler).await;
match sync_result {
Ok((synced_triggers, errors)) => {
all_synced_triggers.extend(synced_triggers);
all_errors.extend(errors);
}
Err(e) => {
tracing::error!(
"Error syncing {} triggers for workspace {}: {:#}",
T::SERVICE_NAME.as_str(),
workspace_id,
e
);
all_errors.push(SyncError {
resource_path: format!("workspace:{}", workspace_id),
error_message: format!("Failed to sync workspace: {}", e),
error_type: "workspace_sync_error".to_string(),
});
}
}
}
(
T::SERVICE_NAME,
ServiceSyncResult { synced_triggers: all_synced_triggers, errors: all_errors },
)
}
#[cfg(feature = "native_trigger")]
pub async fn sync_workspace_triggers<T: External>(
db: &DB,
workspace_id: &str,
handler: &T,
) -> Result<(Vec<TriggerSyncInfo>, Vec<SyncError>)> {
tracing::info!(
"Syncing {} triggers for workspace '{}'",
T::SERVICE_NAME.as_str(),
workspace_id
);
let windmill_triggers =
list_native_triggers(db, workspace_id, T::SERVICE_NAME, None, None, None, None).await?;
if windmill_triggers.is_empty() {
tracing::info!(
"No {} triggers found for workspace '{}'",
T::SERVICE_NAME.as_str(),
workspace_id
);
return Ok((Vec::new(), Vec::new()));
}
let mut all_synced_triggers = Vec::new();
let mut all_sync_errors = Vec::new();
let oauth_data = {
match decrypt_oauth_data(db, db, workspace_id, T::SERVICE_NAME).await {
Ok(oauth_data) => oauth_data,
Err(e) => {
tracing::error!(
"Failed to get workspace integration OAuth data for {}: {}",
workspace_id,
e
);
all_sync_errors.push(SyncError {
resource_path: format!("workspace:{}", workspace_id),
error_message: format!("Failed to get workspace integration OAuth data: {}", e),
error_type: "oauth_error".to_string(),
});
return Ok((Vec::new(), all_sync_errors));
}
}
};
let mut tx = db.begin().await?;
let external_triggers = match handler
.list_all(workspace_id, &oauth_data, db, &mut tx)
.await
{
Ok(triggers) => triggers,
Err(e) => {
tracing::error!(
"Failed to fetch external triggers for {}: {}",
workspace_id,
e
);
all_sync_errors.push(SyncError {
resource_path: format!("workspace:{}", workspace_id),
error_message: format!("Failed to fetch external triggers: {}", e),
error_type: "external_service_error".to_string(),
});
return Ok((Vec::new(), all_sync_errors));
}
};
tx.commit().await?;
// Build a map of external trigger IDs to their data
let mut external_trigger_map: HashMap<String, &T::TriggerData> = HashMap::new();
for external_trigger in &external_triggers {
let external_id = handler.get_external_id_from_trigger_data(external_trigger);
external_trigger_map.insert(external_id, external_trigger);
}
for trigger in &windmill_triggers {
if !external_trigger_map.contains_key(&trigger.external_id) {
// Trigger no longer exists on external service - set error
let error_msg = "Trigger no longer exists on external service".to_string();
if trigger.error.as_deref() != Some(&error_msg) {
tracing::info!(
"Trigger (external_id: '{}', script_path: '{}') no longer exists in external service, setting error",
trigger.external_id,
trigger.script_path
);
match update_native_trigger_error(
db,
workspace_id,
T::SERVICE_NAME,
&trigger.external_id,
Some(&error_msg),
)
.await
{
Ok(()) => {
all_synced_triggers.push(TriggerSyncInfo {
external_id: trigger.external_id.clone(),
script_path: trigger.script_path.clone(),
action: SyncAction::ErrorSet(error_msg),
});
}
Err(e) => {
tracing::error!(
"Failed to update error for trigger (external_id: '{}'): {}",
trigger.external_id,
e
);
all_sync_errors.push(SyncError {
resource_path: format!("workspace:{}", workspace_id),
error_message: format!(
"Failed to update error for trigger (external_id: '{}'): {}",
trigger.external_id, e
),
error_type: "database_update_error".to_string(),
});
}
}
}
} else {
// Trigger exists on external service
let external_trigger_data = external_trigger_map.get(&trigger.external_id).unwrap();
// Clear error if it was set
if trigger.error.is_some() {
tracing::info!(
"Trigger (external_id: '{}', script_path: '{}') exists on external service, clearing error",
trigger.external_id,
trigger.script_path
);
match update_native_trigger_error(
db,
workspace_id,
T::SERVICE_NAME,
&trigger.external_id,
None,
)
.await
{
Ok(()) => {
all_synced_triggers.push(TriggerSyncInfo {
external_id: trigger.external_id.clone(),
script_path: trigger.script_path.clone(),
action: SyncAction::ErrorCleared,
});
}
Err(e) => {
tracing::error!(
"Failed to clear error for trigger (external_id: '{}'): {}",
trigger.external_id,
e
);
all_sync_errors.push(SyncError {
resource_path: format!("workspace:{}", workspace_id),
error_message: format!(
"Failed to clear error for trigger (external_id: '{}'): {}",
trigger.external_id, e
),
error_type: "database_update_error".to_string(),
});
}
}
}
// Compare service_config and update if different
let external_service_config =
handler.extract_service_config_from_trigger_data(external_trigger_data)?;
let stored_service_config = trigger
.service_config
.clone()
.unwrap_or(serde_json::Value::Null);
if external_service_config != stored_service_config {
tracing::info!(
"Trigger (external_id: '{}', script_path: '{}') config differs from external service, updating local config",
trigger.external_id,
trigger.script_path
);
match update_native_trigger_service_config(
db,
workspace_id,
T::SERVICE_NAME,
&trigger.external_id,
&external_service_config,
)
.await
{
Ok(()) => {
all_synced_triggers.push(TriggerSyncInfo {
external_id: trigger.external_id.clone(),
script_path: trigger.script_path.clone(),
action: SyncAction::ConfigUpdated,
});
}
Err(e) => {
tracing::error!(
"Failed to update config for trigger (external_id: '{}'): {}",
trigger.external_id,
e
);
all_sync_errors.push(SyncError {
resource_path: format!("workspace:{}", workspace_id),
error_message: format!(
"Failed to update config for trigger (external_id: '{}'): {}",
trigger.external_id, e
),
error_type: "database_update_error".to_string(),
});
}
}
} else {
tracing::info!(
"Trigger (external_id: '{}', script_path: '{}') config is the same as external service, no update needed",
trigger.external_id,
trigger.script_path
);
}
}
}
tracing::info!(
"Sync completed for {} in workspace '{}'. Updated: {}, Errors: {}",
T::SERVICE_NAME.as_str(),
workspace_id,
all_synced_triggers.len(),
all_sync_errors.len()
);
Ok((all_synced_triggers, all_sync_errors))
}
@@ -1,522 +0,0 @@
use axum::{
extract::Path,
routing::{delete, get, post},
Extension, Json, Router,
};
#[cfg(feature = "native_trigger")]
use serde_json::to_value;
use sqlx::prelude::FromRow;
use strum::IntoEnumIterator;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
#[cfg(feature = "native_trigger")]
use windmill_audit::{audit_oss::audit_log, ActionKind};
use windmill_common::{
db::UserDB,
error::{Error, JsonResult, Result},
utils::require_admin,
variables::{build_crypt, encrypt},
DB,
};
#[cfg(feature = "native_trigger")]
use crate::{
db::ApiAuthed,
native_triggers::{delete_workspace_integration, store_workspace_integration, ServiceName},
};
#[cfg(feature = "native_trigger")]
use windmill_oauth::{OClient, Url, OAUTH_HTTP_CLIENT};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
const STATE_EXPIRATION_SECONDS: i64 = 600; // 10 minutes
/// Generate a signed OAuth state that is cluster-safe.
/// The state contains: workspace_id, service_name, timestamp, and nonce.
/// It's signed with HMAC-SHA256 using the workspace key.
#[cfg(feature = "native_trigger")]
async fn generate_signed_state(
db: &DB,
workspace_id: &str,
service_name: ServiceName,
) -> Result<String> {
use windmill_common::variables::get_workspace_key;
let nonce = uuid::Uuid::new_v4().to_string();
let timestamp = chrono::Utc::now().timestamp();
let payload = format!(
"{}:{}:{}:{}",
workspace_id,
service_name.as_str(),
timestamp,
nonce
);
// Get workspace key for signing
let key = get_workspace_key(workspace_id, db).await?;
let mut mac =
HmacSha256::new_from_slice(key.as_bytes()).map_err(|e| Error::InternalErr(e.to_string()))?;
mac.update(payload.as_bytes());
let signature = mac.finalize().into_bytes();
// Encode as: base64(payload):base64(signature)
let encoded_payload = URL_SAFE_NO_PAD.encode(payload.as_bytes());
let encoded_signature = URL_SAFE_NO_PAD.encode(signature);
Ok(format!("{}:{}", encoded_payload, encoded_signature))
}
/// Validate a signed OAuth state.
/// Returns true if the state is valid (correct signature and not expired).
#[cfg(feature = "native_trigger")]
async fn validate_signed_state(db: &DB, state: &str, workspace_id: &str) -> Result<bool> {
use windmill_common::variables::get_workspace_key;
let parts: Vec<&str> = state.split(':').collect();
if parts.len() != 2 {
return Ok(false);
}
let encoded_payload = parts[0];
let encoded_signature = parts[1];
// Decode payload
let payload_bytes = match URL_SAFE_NO_PAD.decode(encoded_payload) {
Ok(bytes) => bytes,
Err(_) => return Ok(false),
};
let payload = match String::from_utf8(payload_bytes) {
Ok(s) => s,
Err(_) => return Ok(false),
};
// Parse payload: workspace_id:service_name:timestamp:nonce
let payload_parts: Vec<&str> = payload.split(':').collect();
if payload_parts.len() != 4 {
return Ok(false);
}
let state_workspace_id = payload_parts[0];
let timestamp: i64 = match payload_parts[2].parse() {
Ok(ts) => ts,
Err(_) => return Ok(false),
};
// Verify workspace_id matches
if state_workspace_id != workspace_id {
return Ok(false);
}
// Check expiration
let now = chrono::Utc::now().timestamp();
if now - timestamp > STATE_EXPIRATION_SECONDS {
return Ok(false);
}
// Verify signature
let key = get_workspace_key(workspace_id, db).await?;
let mut mac =
HmacSha256::new_from_slice(key.as_bytes()).map_err(|e| Error::InternalErr(e.to_string()))?;
mac.update(payload.as_bytes());
let received_signature = match URL_SAFE_NO_PAD.decode(encoded_signature) {
Ok(bytes) => bytes,
Err(_) => return Ok(false),
};
Ok(mac.verify_slice(&received_signature).is_ok())
}
#[derive(Debug, Serialize)]
pub struct IntegrationStatusResponse {
pub connected: bool,
pub service_name: ServiceName,
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
pub created_by: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct ListIntegrationsResponse {
pub integrations: Vec<IntegrationStatusResponse>,
}
#[derive(Debug, Serialize)]
pub struct ConnectIntegrationResponse {
pub auth_url: String,
}
#[derive(FromRow, Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceOAuthConfig {
pub client_id: String,
pub client_secret: String,
pub base_url: String,
pub access_token: Option<String>
}
#[derive(Debug, Serialize)]
pub struct OAuthConfigResponse {
pub configured: bool,
pub base_url: Option<String>,
pub redirect_uri: Option<String>,
}
#[cfg(feature = "native_trigger")]
async fn generate_connect_url(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((workspace_id, service_name)): Path<(String, ServiceName)>,
Json(RedirectUri { redirect_uri }): Json<RedirectUri>,
) -> JsonResult<String> {
require_admin(authed.is_admin, &workspace_id)?;
let oauth_config =
get_workspace_oauth_config_as_oauth_config(&db, &workspace_id, service_name).await?;
// Generate a signed state that is cluster-safe
let state = generate_signed_state(&db, &workspace_id, service_name).await?;
let auth_url = build_authorization_url(&oauth_config, &state, &redirect_uri);
Ok(Json(auth_url))
}
#[cfg(feature = "native_trigger")]
async fn delete_integration(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((workspace_id, service_name)): Path<(String, ServiceName)>,
) -> JsonResult<String> {
require_admin(authed.is_admin, &workspace_id)?;
let mut tx = user_db.begin(&authed).await?;
let deleted = delete_workspace_integration(&mut *tx, &workspace_id, service_name).await?;
if !deleted {
return Err(Error::NotFound(format!(
"{} integration not found for workspace",
service_name
)));
}
audit_log(
&mut *tx,
&authed,
&format!("workspace_integrations.{}.disconnect", service_name),
ActionKind::Delete,
&workspace_id,
Some(&format!("Disconnected {} integration", service_name)),
None,
)
.await?;
tx.commit().await?;
Ok(Json(format!(
"{} integration disconnected successfully",
service_name
)))
}
#[derive(FromRow, Debug, Deserialize, Serialize)]
struct WorkspaceIntegrations {
service_name: ServiceName,
oauth_data: Option<sqlx::types::Json<WorkspaceOAuthConfig>>,
}
#[cfg(feature = "native_trigger")]
async fn list_integrations(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(_user_db): Extension<UserDB>,
Path(workspace_id): Path<String>,
) -> JsonResult<Vec<WorkspaceIntegrations>> {
require_admin(authed.is_admin, &workspace_id)?;
let mut tx = db.begin().await?;
let integrations = sqlx::query_as!(
WorkspaceIntegrations,
r#"
SELECT
oauth_data as "oauth_data!: sqlx::types::Json<WorkspaceOAuthConfig>",
service_name as "service_name!: ServiceName"
FROM
workspace_integrations
WHERE
workspace_id = $1
"#,
workspace_id
)
.fetch_all(&mut *tx)
.await?;
let key_value = integrations
.into_iter()
.map(|integration| (integration.service_name, integration.oauth_data))
.collect::<std::collections::HashMap<_, _>>();
let integrations = ServiceName::iter()
.map(|service_name| WorkspaceIntegrations {
service_name: service_name,
oauth_data: key_value.get(&service_name).cloned().flatten(),
})
.collect::<Vec<_>>();
tx.commit().await?;
Ok(Json(integrations))
}
async fn integration_exist(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((workspace_id, service_name)): Path<(String, ServiceName)>,
) -> JsonResult<bool> {
let mut tx = user_db.begin(&authed).await?;
let exists = sqlx::query_scalar!(
r#"
SELECT EXISTS (
SELECT 1
FROM workspace_integrations
WHERE workspace_id = $1
AND service_name = $2
AND oauth_data IS NOT NULL
)
"#,
workspace_id,
service_name as ServiceName
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(false);
Ok(Json(exists))
}
#[derive(Debug, Deserialize)]
struct RedirectUri {
redirect_uri: String,
}
#[cfg(feature = "native_trigger")]
async fn oauth_callback(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((workspace_id, service_name, code, state)): Path<(String, ServiceName, String, String)>,
Json(RedirectUri { redirect_uri }): Json<RedirectUri>,
) -> JsonResult<String> {
require_admin(authed.is_admin, &workspace_id)?;
// Validate the signed state (cluster-safe, no DB storage needed)
let state_was_valid = validate_signed_state(&db, &state, &workspace_id).await?;
if !state_was_valid {
return Err(Error::BadRequest(
"Invalid or expired state parameter".to_string(),
));
}
let oauth_config =
get_workspace_oauth_config::<WorkspaceOAuthConfig>(&db, &workspace_id, service_name)
.await?;
let token_response =
exchange_code_for_token(&oauth_config, service_name, &code, &redirect_uri).await?;
let mut tx = user_db.begin(&authed).await?;
let mc = build_crypt(&db, &workspace_id).await?;
let mut oauth_data = serde_json::to_value(oauth_config).unwrap();
let encrypted_access_token = encrypt(&mc, &token_response.access_token);
oauth_data["access_token"] = serde_json::Value::String(encrypted_access_token);
if let Some(refresh_token) = token_response.refresh_token {
let encrypted_refresh_token = encrypt(&mc, &refresh_token);
oauth_data["refresh_token"] = serde_json::Value::String(encrypted_refresh_token);
}
if let Some(expires_in) = token_response.expires_in {
let expires_at = chrono::Utc::now() + chrono::Duration::seconds(expires_in as i64);
oauth_data["token_expires_at"] = serde_json::Value::String(expires_at.to_rfc3339());
}
store_workspace_integration(&mut *tx, &authed, &workspace_id, service_name, oauth_data).await?;
audit_log(
&mut *tx,
&authed,
&format!("workspace_integrations.{}.connect", service_name),
ActionKind::Create,
&workspace_id,
Some(&format!("Connected {} integration via OAuth", service_name)),
None,
)
.await?;
tx.commit().await?;
Ok(Json(format!(
"{} integration connected successfully via OAuth",
service_name
)))
}
/// Token response from OAuth token exchange
#[derive(Debug, Deserialize)]
struct TokenResponse {
access_token: String,
refresh_token: Option<String>,
#[serde(default)]
expires_in: Option<u64>,
}
/// Build an OAuth client for native trigger services using windmill-oauth.
#[cfg(feature = "native_trigger")]
fn build_native_oauth_client(
config: &WorkspaceOAuthConfig,
service_name: ServiceName,
redirect_uri: &str,
) -> Result<OClient> {
let auth_url = Url::parse(&format!("{}{}", config.base_url, service_name.auth_endpoint()))
.map_err(|e| Error::InternalErr(format!("Invalid auth URL: {}", e)))?;
let token_url = Url::parse(&format!("{}{}", config.base_url, service_name.token_endpoint()))
.map_err(|e| Error::InternalErr(format!("Invalid token URL: {}", e)))?;
let redirect = Url::parse(redirect_uri).map_err(|e| {
Error::BadRequest(format!(
"Invalid redirect URI '{}': {}. The redirect URI must be an absolute URL (e.g., https://example.com/callback)",
redirect_uri, e
))
})?;
let mut client = OClient::new(config.client_id.clone(), auth_url, token_url);
client.set_client_secret(config.client_secret.clone());
client.set_redirect_url(redirect);
Ok(client)
}
/// Exchange authorization code for tokens using windmill-oauth.
#[cfg(feature = "native_trigger")]
async fn exchange_code_for_token(
config: &WorkspaceOAuthConfig,
service_name: ServiceName,
code: &str,
redirect_uri: &str,
) -> Result<TokenResponse> {
let client = build_native_oauth_client(config, service_name, redirect_uri)?;
let token_response: TokenResponse = client
.exchange_code(code.to_string())
.with_client(&*OAUTH_HTTP_CLIENT)
.execute()
.await
.map_err(|e| Error::InternalErr(format!("Failed to exchange code for token: {:?}", e)))?;
Ok(token_response)
}
async fn get_workspace_oauth_config<T: DeserializeOwned>(
db: &DB,
workspace_id: &str,
service_name: ServiceName,
) -> Result<T> {
let oauth_configs = sqlx::query_scalar!(
r#"
SELECT
oauth_data
FROM
workspace_integrations
WHERE
workspace_id = $1 AND
service_name = $2
"#,
workspace_id,
service_name as ServiceName
)
.fetch_optional(db)
.await?
.ok_or(Error::NotFound(format!(
"Integration for service {} not found",
service_name.as_str()
)))?;
let config = serde_json::from_value::<T>(oauth_configs)
.map_err(|e| Error::InternalErr(format!("Failed to parse OAuth config: {}", e)))?;
Ok(config)
}
#[cfg(feature = "native_trigger")]
pub async fn create_workspace_integration(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((workspace_id, service_name)): Path<(String, ServiceName)>,
Json(oauth_data): Json<WorkspaceOAuthConfig>,
) -> Result<()> {
require_admin(authed.is_admin, &workspace_id)?;
let mut tx = user_db.begin(&authed).await?;
store_workspace_integration(
&mut tx,
&authed,
&workspace_id,
service_name,
to_value(oauth_data).unwrap(),
)
.await?;
tx.commit().await?;
Ok(())
}
#[inline]
async fn get_workspace_oauth_config_as_oauth_config(
db: &DB,
workspace_id: &str,
service_name: ServiceName,
) -> Result<WorkspaceOAuthConfig> {
get_workspace_oauth_config::<WorkspaceOAuthConfig>(db, workspace_id, service_name).await
}
fn build_authorization_url(
config: &WorkspaceOAuthConfig,
state: &str,
redirect_uri: &str,
) -> String {
let params = [
("response_type", "code"),
("client_id", &config.client_id),
("redirect_uri", redirect_uri),
("state", state),
("scope", "read write"),
];
let query_string = params
.iter()
.map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
.collect::<Vec<_>>()
.join("&");
format!("{}/apps/oauth2/authorize?{}", config.base_url, query_string)
}
pub fn workspaced_service() -> Router {
let router = Router::new()
.route("/list", get(list_integrations))
.route("/:service_name/exists", get(integration_exist))
.route("/:service_name/create", post(create_workspace_integration))
.route(
"/:service_name/generate_connect_url",
post(generate_connect_url),
)
.route("/:service_name/delete", delete(delete_integration))
.route("/:service_name/callback/:code/:state", post(oauth_callback));
Router::new().nest("/integrations", router)
}
@@ -1,67 +0,0 @@
#[cfg(not(feature = "private"))]
use crate::triggers::TriggerData;
#[allow(unused)]
#[cfg(feature = "private")]
pub use super::handler_ee::*;
#[cfg(not(feature = "private"))]
use {
super::EmailTrigger,
crate::{
db::{ApiAuthed, DB},
triggers::TriggerCrud,
},
axum::async_trait,
sqlx::PgConnection,
windmill_common::error::{Error, Result},
windmill_git_sync::DeployedObject,
};
#[cfg(not(feature = "private"))]
#[async_trait]
impl TriggerCrud for EmailTrigger {
type Trigger = ();
type TriggerConfig = ();
type TriggerConfigRequest = ();
type TestConnectionConfig = ();
const TABLE_NAME: &'static str = "";
const TRIGGER_TYPE: &'static str = "";
const SUPPORTS_SERVER_STATE: bool = false;
const SUPPORTS_TEST_CONNECTION: bool = false;
const ROUTE_PREFIX: &'static str = "/email_triggers";
const DEPLOYMENT_NAME: &'static str = "";
const IS_ALLOWED_ON_CLOUD: bool = false;
fn get_deployed_object(path: String) -> DeployedObject {
DeployedObject::EmailTrigger { path }
}
async fn create_trigger(
&self,
_db: &DB,
_tx: &mut PgConnection,
_authed: &ApiAuthed,
_w_id: &str,
_trigger: TriggerData<Self::TriggerConfigRequest>,
) -> Result<()> {
Err(Error::BadRequest(
"Email triggers are not available in open source version".to_string(),
))
}
async fn update_trigger(
&self,
_db: &DB,
_executor: &mut PgConnection,
_authed: &ApiAuthed,
_workspace_id: &str,
_path: &str,
_trigger: TriggerData<Self::TriggerConfigRequest>,
) -> Result<()> {
Err(Error::BadRequest(
"Email triggers are not available in open source version".to_string(),
))
}
}
@@ -1,11 +0,0 @@
#[cfg(feature = "private")]
mod handler_ee;
pub mod handler_oss;
#[cfg(feature = "private")]
mod mod_ee;
#[cfg(feature = "private")]
pub use mod_ee::*;
#[derive(Copy, Clone)]
pub struct EmailTrigger;
-144
View File
@@ -1,144 +0,0 @@
use serde::{
de::{self, MapAccess, Visitor},
Deserialize, Deserializer,
};
use serde_json::Value;
use std::fmt;
#[derive(Deserialize)]
pub struct JsonFilter {
pub key: String,
pub value: Value,
}
#[derive(Deserialize)]
#[serde(untagged)]
pub enum Filter {
JsonFilter(JsonFilter),
}
struct SupersetVisitor<'a> {
key: &'a str,
value_to_check: &'a Value,
}
impl<'de, 'a> Visitor<'de> for SupersetVisitor<'a> {
type Value = bool;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a JSON object with a specific key at the top level")
}
fn visit_map<V>(self, mut map: V) -> std::result::Result<Self::Value, V::Error>
where
V: MapAccess<'de>,
{
let mut result = false;
let mut found = false;
// Must consume entire map to satisfy deserializer contract
while let Some(key) = map.next_key::<String>()? {
if !found && key == self.key {
let json_value: Value = map.next_value()?;
result = is_superset(&json_value, self.value_to_check);
found = true;
} else {
// Skip values we don't need (cheaper than full deserialization)
let _ = map.next_value::<de::IgnoredAny>()?;
}
}
Ok(result)
}
}
pub fn is_superset(json_value: &Value, value_to_check: &Value) -> bool {
match (json_value, value_to_check) {
(Value::Object(json_map), Value::Object(check_map)) => {
check_map.iter().all(|(k, v)| {
json_map
.get(k)
.map_or(false, |json_val| is_superset(json_val, v))
})
}
(Value::Array(json_array), Value::Array(check_array)) => {
check_array.iter().all(|check_item| {
json_array
.iter()
.any(|json_item| is_superset(json_item, check_item))
})
}
_ => json_value == value_to_check,
}
}
pub fn is_value_superset<'a, 'de, D>(
deserializer: D,
key: &'a str,
value_to_check: &'a Value,
) -> std::result::Result<bool, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_map(SupersetVisitor { key, value_to_check })
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_filter_with_other_top_level_keys() {
let payload = r#"{"event_type": "test", "other": "data"}"#;
let key = "event_type";
let value = json!("test");
let mut deserializer = serde_json::Deserializer::from_str(payload);
let result = is_value_superset(&mut deserializer, key, &value).unwrap();
assert!(result, "Should match when key exists with correct value");
}
#[test]
fn test_filter_with_key_not_first() {
let payload = r#"{"other": "data", "event_type": "test"}"#;
let key = "event_type";
let value = json!("test");
let mut deserializer = serde_json::Deserializer::from_str(payload);
let result = is_value_superset(&mut deserializer, key, &value).unwrap();
assert!(result, "Should match even when key is not first");
}
#[test]
fn test_filter_with_nested_object() {
let payload = r#"{"data": {"status": "active", "count": 5}, "other": "value"}"#;
let key = "data";
let value = json!({"status": "active"});
let mut deserializer = serde_json::Deserializer::from_str(payload);
let result = is_value_superset(&mut deserializer, key, &value).unwrap();
assert!(result, "Should match when nested object is superset");
}
#[test]
fn test_filter_no_match() {
let payload = r#"{"event_type": "other", "data": "value"}"#;
let key = "event_type";
let value = json!("test");
let mut deserializer = serde_json::Deserializer::from_str(payload);
let result = is_value_superset(&mut deserializer, key, &value).unwrap();
assert!(!result, "Should not match when value differs");
}
#[test]
fn test_filter_key_not_found() {
let payload = r#"{"other": "data"}"#;
let key = "event_type";
let value = json!("test");
let mut deserializer = serde_json::Deserializer::from_str(payload);
let result = is_value_superset(&mut deserializer, key, &value).unwrap();
assert!(!result, "Should not match when key doesn't exist");
}
}
@@ -1,64 +0,0 @@
#[allow(unused)]
#[cfg(feature = "private")]
pub use super::handler_ee::*;
#[cfg(not(feature = "private"))]
use {
super::GcpTrigger,
crate::{
db::{ApiAuthed, DB},
triggers::{TriggerCrud, TriggerData},
},
axum::async_trait,
sqlx::PgConnection,
windmill_common::error::{Error, Result},
windmill_git_sync::DeployedObject,
};
#[cfg(not(feature = "private"))]
#[async_trait]
impl TriggerCrud for GcpTrigger {
type Trigger = ();
type TriggerConfig = ();
type TriggerConfigRequest = ();
type TestConnectionConfig = ();
const TABLE_NAME: &'static str = "";
const TRIGGER_TYPE: &'static str = "";
const SUPPORTS_SERVER_STATE: bool = false;
const SUPPORTS_TEST_CONNECTION: bool = false;
const ROUTE_PREFIX: &'static str = "/gcp_triggers";
const DEPLOYMENT_NAME: &'static str = "";
const IS_ALLOWED_ON_CLOUD: bool = false;
fn get_deployed_object(path: String) -> DeployedObject {
DeployedObject::GcpTrigger { path }
}
async fn create_trigger(
&self,
_db: &DB,
_executor: &mut PgConnection,
_authed: &ApiAuthed,
_w_id: &str,
_trigger: TriggerData<Self::TriggerConfigRequest>,
) -> Result<()> {
Err(Error::BadRequest(
"GCP triggers are not available in open source version".to_string(),
))
}
async fn update_trigger(
&self,
_db: &DB,
_executor: &mut PgConnection,
_authed: &ApiAuthed,
_workspace_id: &str,
_path: &str,
_trigger: TriggerData<Self::TriggerConfigRequest>,
) -> Result<()> {
Err(Error::BadRequest(
"GCP triggers are not available in open source version".to_string(),
))
}
}
@@ -1,41 +0,0 @@
#[allow(unused)]
#[cfg(feature = "private")]
pub use super::listener_ee::*;
#[cfg(not(feature = "private"))]
use {
super::GcpTrigger,
crate::triggers::{listener::ListeningTrigger, Listener},
std::sync::Arc,
tokio::sync::RwLock,
windmill_common::{error::Result, jobs::JobTriggerKind, DB},
};
#[cfg(not(feature = "private"))]
#[async_trait::async_trait]
impl Listener for GcpTrigger {
type Consumer = ();
type Extra = ();
const JOB_TRIGGER_KIND: JobTriggerKind = JobTriggerKind::Gcp;
async fn get_consumer(
&self,
_db: &DB,
_listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
_err_message: Arc<RwLock<Option<String>>>,
_killpill_rx: tokio::sync::broadcast::Receiver<()>,
) -> Result<Option<Self::Consumer>> {
Ok(None)
}
async fn consume(
&self,
_db: &DB,
_consumer: Self::Consumer,
_listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
_err_message: Arc<RwLock<Option<String>>>,
_killpill_rx: tokio::sync::broadcast::Receiver<()>,
) {
()
}
}
@@ -1,16 +0,0 @@
#[cfg(feature = "private")]
mod handler_ee;
pub mod handler_oss;
#[cfg(feature = "private")]
mod listener_ee;
pub mod listener_oss;
#[cfg(feature = "private")]
mod mod_ee;
#[cfg(feature = "private")]
pub use mod_ee::*;
#[derive(Clone, Copy)]
pub struct GcpTrigger;
@@ -1,324 +0,0 @@
use crate::{
db::{ApiAuthed, DB},
jobs::cancel_jobs,
triggers::trigger_helpers::trigger_runnable_inner,
};
use axum::{
extract::{Extension, Path},
response::Json,
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::value::RawValue;
use sqlx::PgConnection;
use std::collections::HashMap;
use uuid::Uuid;
use windmill_common::{
db::UserDB,
error::{self, Error, Result},
jobs::JobTriggerKind,
triggers::TriggerMetadata,
};
#[derive(sqlx::FromRow)]
pub struct SuspendedTrigger {
pub script_path: String,
pub is_flow: bool,
pub edited_by: String,
pub email: String,
pub edited_at: DateTime<Utc>,
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<windmill_common::flows::Retry>>,
}
async fn get_suspended_trigger(
tx: &mut PgConnection,
workspace_id: &str,
trigger_kind: &JobTriggerKind,
path: &str,
) -> Result<SuspendedTrigger> {
match trigger_kind {
JobTriggerKind::Webhook | JobTriggerKind::Schedule => {
return Err(Error::BadRequest(format!(
"{} triggers do not support job reassignment",
trigger_kind
)));
}
_ => {}
}
let table_name = format!("{}_trigger", trigger_kind.to_string());
let fields = vec![
"script_path",
"is_flow",
"edited_by",
"email",
"edited_at",
"error_handler_path",
"error_handler_args",
"retry",
];
let sql = format!(
r#"SELECT
{}
FROM
{}
WHERE
workspace_id = $1 AND
path = $2
"#,
fields.join(", "),
table_name
);
sqlx::query_as(&sql)
.bind(workspace_id)
.bind(path)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| Error::NotFound(format!("Trigger not found at path: {}", path)))
}
struct JobWithArgs {
id: Uuid,
args: Option<sqlx::types::Json<HashMap<String, Box<RawValue>>>>,
created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Deserialize, Serialize, Default)]
pub struct ReassignJobsBody {
#[serde(default)]
pub job_ids: Option<Vec<Uuid>>,
}
pub async fn resume_suspended_trigger_jobs(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, trigger_kind, trigger_path)): Path<(String, JobTriggerKind, String)>,
Json(body): Json<ReassignJobsBody>,
) -> error::Result<Json<String>> {
let mut tx = user_db.clone().begin(&authed).await?;
let trigger = get_suspended_trigger(&mut *tx, &w_id, &trigger_kind, &trigger_path).await?;
let jobs = if let Some(job_ids) = body.job_ids.as_ref() {
if job_ids.is_empty() {
vec![]
} else {
sqlx::query_as!(
JobWithArgs,
r#"
SELECT
id,
args as "args: _",
created_at
FROM v2_job
WHERE workspace_id = $1
AND (
kind = 'unassigned_script'::JOB_KIND OR
kind = 'unassigned_flow'::JOB_KIND OR
kind = 'unassigned_singlestepflow'::JOB_KIND
)
AND trigger_kind = $2
AND trigger = $3
AND id = ANY($4)
"#,
w_id,
trigger_kind as _,
trigger_path,
job_ids as _,
)
.fetch_all(&mut *tx)
.await?
}
} else {
sqlx::query_as!(
JobWithArgs,
r#"
SELECT
id,
args as "args: _",
created_at
FROM v2_job
WHERE workspace_id = $1
AND (
kind = 'unassigned_script'::JOB_KIND OR
kind = 'unassigned_flow'::JOB_KIND OR
kind = 'unassigned_singlestepflow'::JOB_KIND
)
AND trigger_kind = $2
AND trigger = $3
"#,
w_id,
trigger_kind as _,
trigger_path,
)
.fetch_all(&mut *tx)
.await?
};
let trigger_metadata = TriggerMetadata::new(Some(trigger_path.clone()), trigger_kind);
let l = jobs.len();
for job in jobs {
// If job was created before trigger was edited, simply update it to unsuspend
// instead of deleting and repushing
if job.created_at > trigger.edited_at {
let job_kind = if trigger.is_flow {
windmill_common::jobs::JobKind::Flow
} else {
windmill_common::jobs::JobKind::Script
};
sqlx::query!(
"UPDATE v2_job SET kind = $1 WHERE id = $2",
job_kind as _,
job.id
)
.execute(&mut *tx)
.await?;
// Update the job to unsuspend it and set the correct kind
sqlx::query!(
"UPDATE v2_job_queue SET scheduled_for = now() WHERE id = $1",
job.id
)
.execute(&mut *tx)
.await?;
} else {
// Job was created after trigger edit - delete and repush with new configuration
// Pass the transaction to trigger_runnable_inner so everything is in the same transaction
let (_uuid, _delete_after_use, _early_return, tx_o) = trigger_runnable_inner(
&db,
Some(tx),
Some(user_db.clone()),
authed.clone(),
&w_id,
&trigger.script_path,
trigger.is_flow,
windmill_queue::PushArgsOwned {
extra: None,
args: job.args.map(|a| a.0).unwrap_or_default(),
},
trigger.retry.as_ref(),
trigger.error_handler_path.as_deref(),
trigger.error_handler_args.as_ref(),
trigger_path.clone(),
None,
trigger_metadata.clone(),
None,
)
.await?;
tx = match tx_o {
Some(tx) => tx,
None => {
return Err(error::Error::internal_err(
"Transaction should be returned when passed in".to_string(),
));
}
};
// Delete the unassigned job from all related tables
sqlx::query!("DELETE FROM v2_job_queue WHERE id = $1", job.id)
.execute(&mut *tx)
.await?;
sqlx::query!("DELETE FROM v2_job_runtime WHERE id = $1", job.id)
.execute(&mut *tx)
.await?;
sqlx::query!("DELETE FROM job_perms WHERE job_id = $1", job.id)
.execute(&mut *tx)
.await?;
sqlx::query!("DELETE FROM concurrency_key WHERE job_id = $1", job.id)
.execute(&mut *tx)
.await?;
sqlx::query!("DELETE FROM debounce_key WHERE job_id = $1", job.id)
.execute(&mut *tx)
.await?;
sqlx::query!("DELETE FROM debounce_stale_data WHERE job_id = $1", job.id)
.execute(&mut *tx)
.await?;
sqlx::query!("DELETE FROM v2_job WHERE id = $1", job.id)
.execute(&mut *tx)
.await?;
}
}
tx.commit().await?;
Ok(Json(format!("Reassigned {} jobs", l)))
}
pub async fn cancel_suspended_trigger_jobs(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, trigger_kind, trigger_path)): Path<(String, JobTriggerKind, String)>,
Json(body): Json<ReassignJobsBody>,
) -> error::Result<Json<String>> {
let mut tx = user_db.clone().begin(&authed).await?;
// Get the list of job IDs to cancel
let jobs_to_cancel = if let Some(job_ids) = body.job_ids.as_ref() {
if job_ids.is_empty() {
vec![]
} else {
sqlx::query_scalar!(
"SELECT id FROM v2_job
WHERE workspace_id = $1
AND (kind = 'unassigned_script'::JOB_KIND OR kind = 'unassigned_flow'::JOB_KIND OR kind = 'unassigned_singlestepflow'::JOB_KIND)
AND trigger_kind = $2
AND trigger = $3
AND id = ANY($4)",
w_id,
trigger_kind as _,
trigger_path,
job_ids as _,
)
.fetch_all(&mut *tx)
.await?
}
} else {
sqlx::query_scalar!(
"SELECT id FROM v2_job
WHERE workspace_id = $1
AND (kind = 'unassigned_script'::JOB_KIND OR kind = 'unassigned_flow'::JOB_KIND OR kind = 'unassigned_singlestepflow'::JOB_KIND)
AND trigger_kind = $2
AND trigger = $3",
w_id,
trigger_kind as _,
trigger_path,
)
.fetch_all(&mut *tx)
.await?
};
tx.commit().await?;
let count = jobs_to_cancel.len();
if count > 0 {
let cancelled_jobs = cancel_jobs(
jobs_to_cancel,
&db,
authed.username.as_str(),
w_id.as_str(),
true,
)
.await?;
Ok(Json(format!("Canceled {} jobs", cancelled_jobs.0.len())))
} else {
Ok(Json(format!("No jobs to cancel")))
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,225 +0,0 @@
use std::collections::HashMap;
use axum::{
extract::{FromRequest, Request},
response::Response,
};
use serde::{Deserialize, Serialize};
use serde_json::value::RawValue;
use windmill_common::{
error::Error,
triggers::{RunnableFormat, RunnableFormatVersion},
worker::to_raw_value,
DB,
};
use windmill_queue::PushArgsOwned;
use crate::{
args::{
build_headers, build_query, try_from_request_body, Body, RawWebhookArgs, WebhookArgs,
WebhookArgsMetadata,
},
db::ApiAuthed,
};
pub struct RawHttpTriggerArgs(pub RawWebhookArgs);
#[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,
}
impl TryFrom<&http::Method> for HttpMethod {
type Error = Error;
fn try_from(method: &http::Method) -> Result<Self, Self::Error> {
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())),
}
}
}
#[axum::async_trait]
impl<S> FromRequest<S, axum::body::Body> for RawHttpTriggerArgs
where
S: Send + Sync,
{
type Rejection = Response;
async fn from_request(request: Request, _state: &S) -> Result<Self, Self::Rejection> {
let args = try_from_request_body(request, _state, true).await?;
Ok(Self(args))
}
}
#[derive(Debug, Clone)]
pub struct HttpTriggerArgs(pub WebhookArgs);
impl RawHttpTriggerArgs {
pub async fn process_args(
self,
authed: &ApiAuthed,
db: &DB,
w_id: &str,
use_raw: bool,
) -> Result<HttpTriggerArgs, Error> {
if self.0.metadata.query_use_raw || self.0.metadata.query_wrap_body {
return Err(Error::BadRequest(
"Specifying use raw or wrap body with query args is not supported anymore on http routes, please set it in the trigger config".to_string(),
)
.into());
}
let args = self.0.process_args(authed, db, w_id, Some(use_raw)).await?;
Ok(HttpTriggerArgs(args))
}
}
#[derive(Serialize)]
struct HttpTriggerPreprocessorEvent<'a> {
kind: String,
route: &'a str,
path: &'a str,
body: Box<RawValue>,
raw_string: Option<String>,
params: &'a HashMap<String, String>,
headers: HashMap<String, Box<RawValue>>,
query: HashMap<String, Box<RawValue>>,
method: HttpMethod,
}
#[derive(Serialize)]
struct HttpTriggerWmTrigger<'a> {
route: &'a str,
path: &'a str,
params: &'a HashMap<String, String>,
query: HashMap<String, Box<RawValue>>,
headers: HashMap<String, Box<RawValue>>,
method: HttpMethod,
}
impl HttpTriggerArgs {
pub fn to_main_args(self, wrap_body: bool) -> Result<PushArgsOwned, Error> {
let mut extra = HashMap::new();
let WebhookArgsMetadata { raw_string, .. } = self.0.metadata;
if let Some(raw_string) = raw_string {
extra.insert("raw_string".to_string(), to_raw_value(&raw_string));
}
let extra = if extra.is_empty() { None } else { Some(extra) };
match self.0.body {
Body::HashMap(mut body) => {
if wrap_body {
body = HashMap::from([("body".to_string(), to_raw_value(&body))]);
}
Ok(PushArgsOwned { args: body, extra })
}
Body::NoHashMap(args) => {
let mut hm = HashMap::new();
hm.insert("body".to_string(), args);
Ok(PushArgsOwned { args: hm, extra })
}
}
}
pub fn to_args_from_format(
self,
route_path: &str,
called_path: &str,
params: &HashMap<String, String>,
format: RunnableFormat,
wrap_body: bool,
) -> Result<PushArgsOwned, Error> {
let headers = build_headers(&self.0.metadata.headers, None, true);
let query = build_query(self.0.metadata.query.as_deref(), None, true);
match format {
RunnableFormat { has_preprocessor: true, version: RunnableFormatVersion::V2 } => {
// we don't care about wrap_body in v2
self.to_v2_preprocessor_args(route_path, called_path, params, headers, query)
}
RunnableFormat { has_preprocessor: true, version: RunnableFormatVersion::V1 } => self
.to_v1_preprocessor_args(
route_path,
called_path,
params,
wrap_body,
headers,
query,
),
RunnableFormat { has_preprocessor: false, .. } => self.to_main_args(wrap_body),
}
}
fn to_v1_preprocessor_args(
self,
route_path: &str,
called_path: &str,
params: &HashMap<String, String>,
wrap_body: bool,
headers: HashMap<String, Box<RawValue>>,
query: HashMap<String, Box<RawValue>>,
) -> Result<PushArgsOwned, Error> {
let mut extra = HashMap::new();
let mut wm_trigger = HashMap::new();
wm_trigger.insert("kind".to_string(), to_raw_value(&"http".to_string()));
wm_trigger.insert(
"http".to_string(),
to_raw_value(&HttpTriggerWmTrigger {
route: route_path,
path: called_path,
method: (&self.0.metadata.method).try_into()?,
params,
query,
headers,
}),
);
extra.insert("wm_trigger".to_string(), to_raw_value(&wm_trigger));
let mut args = self.to_main_args(wrap_body)?;
args.extra.get_or_insert_default().extend(extra);
Ok(args)
}
pub fn to_v2_preprocessor_args(
self,
route_path: &str,
called_path: &str,
params: &HashMap<String, String>,
headers: HashMap<String, Box<RawValue>>,
query: HashMap<String, Box<RawValue>>,
) -> Result<PushArgsOwned, Error> {
let mut args = HashMap::new();
args.insert(
"event".to_string(),
to_raw_value(&HttpTriggerPreprocessorEvent {
kind: "http".to_string(),
body: to_raw_value(&self.0.body),
raw_string: self.0.metadata.raw_string,
headers,
query,
method: (&self.0.metadata.method).try_into()?,
route: route_path,
path: called_path,
params,
}),
);
Ok(PushArgsOwned { args, extra: None })
}
}
@@ -1,751 +0,0 @@
use axum::response::{IntoResponse, Response};
use base64::{
prelude::{BASE64_STANDARD, BASE64_URL_SAFE},
Engine,
};
use hmac::{Hmac, Mac};
use http::{header, HeaderMap, HeaderValue, StatusCode};
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use serde_json::json;
use sha1::Sha1;
use sha2::{Sha256, Sha512};
use std::{borrow::Cow, collections::HashMap};
pub type HmacSha256 = Hmac<Sha256>;
pub type HmacSha512 = Hmac<Sha512>;
pub type HmacSha1 = Hmac<Sha1>;
mod github {
use super::*;
pub struct Github;
impl WebhookHandler for Github {
fn handle_challenge_request<'header>(
&self,
_: &'header HeaderMap,
_: &SignatureConfigData,
_: &str,
) -> Result<Option<Response>, AuthenticationError> {
Ok(None)
}
fn get_hmac_authentication_data<'payload, 'header, 'prefix>(
&self,
headers: &'header HeaderMap,
raw_payload: &'payload str,
) -> Result<SignatureAuthenticationData<'payload, 'header, 'prefix>, AuthenticationError>
{
let github_secret_header = headers.try_get_webhook_header("X-Hub-Signature-256")?;
let authentication_data = SignatureAuthenticationData::new(
Cow::Borrowed(raw_payload),
github_secret_header,
Some("sha256="),
SignatureAuthenticationDetails::new(HmacAlgorithm::Sha256, Encoding::Hex),
);
Ok(authentication_data)
}
}
}
mod slack {
use super::*;
pub struct Slack;
impl WebhookHandler for Slack {
fn handle_challenge_request<'header>(
&self,
_: &'header HeaderMap,
_: &SignatureConfigData,
_: &str,
) -> Result<Option<Response>, AuthenticationError> {
Ok(None)
}
fn get_hmac_authentication_data<'payload, 'header, 'prefix>(
&self,
headers: &'header HeaderMap,
raw_payload: &'payload str,
) -> Result<SignatureAuthenticationData<'payload, 'header, 'prefix>, AuthenticationError>
{
let slack_secret_signature = headers.try_get_webhook_header("X-Slack-Signature")?;
let slack_timestamp_header =
headers.try_get_webhook_header("X-Slack-Request-Timestamp")?;
let signed_payload = format!("v0:{}:{}", slack_timestamp_header, raw_payload);
Ok(SignatureAuthenticationData::new(
Cow::Owned(signed_payload),
slack_secret_signature,
Some("v0="),
SignatureAuthenticationDetails::new(HmacAlgorithm::Sha256, Encoding::Hex),
))
}
}
}
mod stripe {
use super::*;
pub struct Stripe;
impl WebhookHandler for Stripe {
fn handle_challenge_request<'header>(
&self,
_: &'header HeaderMap,
_: &SignatureConfigData,
_: &str,
) -> Result<Option<Response>, AuthenticationError> {
Ok(None)
}
fn get_hmac_authentication_data<'payload, 'header, 'prefix>(
&self,
headers: &'header HeaderMap,
raw_payload: &'payload str,
) -> Result<SignatureAuthenticationData<'payload, 'header, 'prefix>, AuthenticationError>
{
let stripe_signature_header = headers.try_get_webhook_header("STRIPE-SIGNATURE")?;
let stripe_signature = parse_signature(stripe_signature_header, (",", "="));
let timestamp = *stripe_signature
.get("t")
.ok_or(AuthenticationError::InvalidTimestamp)?;
let v1 = *stripe_signature
.get("v1")
.ok_or(AuthenticationError::InvalidSignature)?;
let signed_payload = format!("{}.{}", timestamp, raw_payload);
Ok(SignatureAuthenticationData::new(
Cow::Owned(signed_payload),
v1,
None,
SignatureAuthenticationDetails::new(HmacAlgorithm::Sha256, Encoding::Hex),
))
}
}
}
mod tiktok {
use super::*;
pub struct TikTok;
impl WebhookHandler for TikTok {
fn handle_challenge_request<'header>(
&self,
_: &'header HeaderMap,
_: &SignatureConfigData,
_: &str,
) -> Result<Option<Response>, AuthenticationError> {
Ok(None)
}
fn get_hmac_authentication_data<'payload, 'header, 'prefix>(
&self,
headers: &'header HeaderMap,
raw_payload: &'payload str,
) -> Result<SignatureAuthenticationData<'payload, 'header, 'prefix>, AuthenticationError>
{
let tiktok_secret_signature = headers.try_get_webhook_header("TikTok-Signature")?;
let stripe_signature = parse_signature(tiktok_secret_signature, (",", "="));
let timestamp = *stripe_signature
.get("t")
.ok_or(AuthenticationError::InvalidTimestamp)?;
let s = *stripe_signature
.get("s")
.ok_or(AuthenticationError::InvalidSignature)?;
let signed_payload = format!("{}.{}", timestamp, raw_payload);
Ok(SignatureAuthenticationData::new(
Cow::Owned(signed_payload),
s,
None,
SignatureAuthenticationDetails::new(HmacAlgorithm::Sha256, Encoding::Hex),
))
}
}
}
mod twitch {
use super::*;
use http::header;
use serde_json::value::RawValue;
#[derive(Debug, Deserialize)]
struct TwitchCrcBody {
challenge: String,
#[allow(unused)]
subscription: Box<RawValue>,
}
pub struct Twitch;
impl WebhookHandler for Twitch {
fn get_hmac_authentication_data<'payload, 'header, 'prefix>(
&self,
headers: &'header HeaderMap,
raw_payload: &'payload str,
) -> Result<SignatureAuthenticationData<'payload, 'header, 'prefix>, AuthenticationError>
{
let twitch_secret_signature =
headers.try_get_webhook_header("Twitch-Eventsub-Message-Signature")?;
let twitch_message_id_header =
headers.try_get_webhook_header("Twitch-Eventsub-Message-Id")?;
let twitch_timestamp_header =
headers.try_get_webhook_header("Twitch-Eventsub-Message-Timestamp")?;
let message = format!(
"{}{}{}",
twitch_message_id_header, twitch_timestamp_header, raw_payload
);
Ok(SignatureAuthenticationData::new(
Cow::Owned(message),
twitch_secret_signature,
Some("sha256="),
SignatureAuthenticationDetails::new(HmacAlgorithm::Sha256, Encoding::Hex),
))
}
fn handle_challenge_request<'header>(
&self,
headers: &'header HeaderMap,
signature_config_data: &SignatureConfigData,
raw_payload: &str,
) -> Result<Option<Response>, AuthenticationError> {
let authentication_data = self.get_hmac_authentication_data(headers, raw_payload)?;
verify_hmac_signature(authentication_data, &signature_config_data.secret_key)?;
let twitch_eventsub_message_type =
headers.try_get_webhook_header("Twitch-Eventsub-Message-Type")?;
if twitch_eventsub_message_type != "webhook_callback_verification" {
return Ok(None);
}
let twitch_crc_body =
serde_json::from_str::<TwitchCrcBody>(raw_payload).map_err(|e| {
AuthenticationError::InvalidChallengeResponse(format!(
"Twitch :{}",
e.to_string()
))
})?;
let response = (
StatusCode::OK,
[(header::CONTENT_TYPE, "text/plain")],
twitch_crc_body.challenge.to_string(),
);
Ok(Some(response.into_response()))
}
}
}
mod zoom {
use axum::Json;
use super::*;
#[derive(Debug, Deserialize)]
struct ZoomPayload {
#[serde(rename = "plainToken")]
plain_token: String,
}
#[derive(Debug, Deserialize)]
#[allow(unused)]
struct ZoomChallengeResponse {
payload: ZoomPayload,
event_ts: u64,
event: String,
}
pub struct Zoom;
impl WebhookHandler for Zoom {
fn handle_challenge_request<'header>(
&self,
_: &'header HeaderMap,
signature_config_data: &SignatureConfigData,
raw_payload: &str,
) -> Result<Option<Response>, AuthenticationError> {
let Ok(zoom_request_body) = serde_json::from_str::<ZoomChallengeResponse>(raw_payload)
else {
return Ok(None);
};
if zoom_request_body.event != "endpoint.url_validation" {
return Ok(None);
}
let hmac_signature = calculate_hmac_signature(
HmacAlgorithm::Sha256,
&signature_config_data.secret_key,
&zoom_request_body.payload.plain_token,
);
let encoded_hmac_signature = encode_hmac_signature(Encoding::Hex, &hmac_signature);
let response = (
StatusCode::OK,
Json(json!({
"plainToken": zoom_request_body.payload.plain_token,
"encryptedToken": encoded_hmac_signature
})),
);
Ok(Some(response.into_response()))
}
fn get_hmac_authentication_data<'payload, 'header, 'prefix>(
&self,
headers: &'header HeaderMap,
raw_payload: &'payload str,
) -> Result<SignatureAuthenticationData<'payload, 'header, 'prefix>, AuthenticationError>
{
let zoom_signature_header = headers.try_get_webhook_header("x-zm-signature")?;
let zoom_timestamp_header = headers.try_get_webhook_header("x-zm-request-timestamp")?;
let message = format!("v0:{}:{}", zoom_timestamp_header, raw_payload);
Ok(SignatureAuthenticationData::new(
Cow::Owned(message),
zoom_signature_header,
Some("v0="),
SignatureAuthenticationDetails::new(HmacAlgorithm::Sha256, Encoding::Hex),
))
}
}
}
use constant_time_eq::constant_time_eq;
use github::Github;
use slack::Slack;
use stripe::Stripe;
use tiktok::TikTok;
use twitch::Twitch;
use zoom::Zoom;
#[derive(Debug)]
pub struct SignatureAuthenticationDetails {
pub algorithm_to_use: HmacAlgorithm,
pub header_key_encoding: Encoding,
}
impl SignatureAuthenticationDetails {
#[inline]
fn new(algorithm_to_use: HmacAlgorithm, header_key_encoding: Encoding) -> Self {
Self { algorithm_to_use, header_key_encoding }
}
}
fn parse_signature<'header>(
signature: &'header str,
splitters: (&str, &str),
) -> HashMap<&'header str, &'header str> {
let headers: HashMap<&str, &str> = signature
.split(splitters.0)
.map(|header| {
let mut key_and_value = header.split(splitters.1);
let key = key_and_value.next();
let value = key_and_value.next();
(key, value)
})
.filter_map(|(key, value)| match (key, value) {
(Some(key), Some(value)) => Some((key, value)),
_ => None,
})
.collect();
headers
}
#[derive(Debug)]
pub struct SignatureAuthenticationData<'payload, 'header, 'prefix> {
pub signed_payload: Cow<'payload, str>,
pub header_key_value: &'header str,
pub signature_prefix: Option<&'prefix str>,
pub config: SignatureAuthenticationDetails,
}
impl<'payload, 'header, 'prefix> SignatureAuthenticationData<'payload, 'header, 'prefix> {
pub fn new(
signed_payload: Cow<'payload, str>,
header_key_value: &'header str,
signature_prefix: Option<&'prefix str>,
config: SignatureAuthenticationDetails,
) -> Self {
Self { signed_payload, header_key_value, signature_prefix, config }
}
}
pub trait WebhookHandler {
fn handle_challenge_request<'header>(
&self,
headers: &'header HeaderMap,
signature_config_data: &SignatureConfigData,
raw_payload: &str,
) -> Result<Option<Response>, AuthenticationError>;
fn get_hmac_authentication_data<'payload, 'header, 'prefix>(
&self,
headers: &'header HeaderMap,
raw_payload: &'payload str,
) -> Result<SignatureAuthenticationData<'payload, 'header, 'prefix>, AuthenticationError>;
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum HmacAlgorithm {
Sha1,
Sha256,
Sha512,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Encoding {
Base64,
Base64Uri,
Hex,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SignatureAuthenticationMethod {
algorithm: HmacAlgorithm,
encoding: Encoding,
signature_header_name: String,
signature_prefix: Option<String>,
}
pub struct SignatureConfigData<'config> {
secret_key: &'config str,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SignatureAuthentication {
signature_provider: WebhookType,
secret_key: String,
authentication_config: Option<SignatureAuthenticationMethod>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BasicAuthAuthentication {
pub username: String,
pub password: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ApiKeyAuthentication {
pub api_key_header: String,
pub api_key_secret: String,
}
#[derive(Debug, Eq, PartialEq, Hash, Clone, Copy, Serialize, Deserialize)]
#[non_exhaustive]
pub enum WebhookType {
Github,
Slack,
Stripe,
TikTok,
Twitch,
Zoom,
Custom,
}
impl WebhookType {
pub fn get_webhook_handler(&self) -> Option<&'static dyn WebhookHandler> {
let handler: &'static dyn WebhookHandler = match *self {
WebhookType::Github => &Github,
WebhookType::Slack => &Slack,
WebhookType::Stripe => &Stripe,
WebhookType::TikTok => &TikTok,
WebhookType::Twitch => &Twitch,
WebhookType::Zoom => &Zoom,
WebhookType::Custom => return None,
};
Some(handler)
}
}
trait TryGetWebhookHeader {
fn try_get_webhook_header<'header>(
&'header self,
header_name: &str,
) -> Result<&'header str, AuthenticationError>;
}
impl TryGetWebhookHeader for HeaderMap<HeaderValue> {
fn try_get_webhook_header<'header>(
&'header self,
header_name: &str,
) -> Result<&'header str, AuthenticationError> {
let Some(signature_header) = self.get(header_name) else {
return Err(AuthenticationError::MissingHeader(header_name.to_string()));
};
let Some(signature_header) = signature_header.to_str().ok() else {
return Err(AuthenticationError::InvalidHeader(header_name.to_string()));
};
Ok(signature_header)
}
}
pub fn calculate_hmac_signature(algorithm: HmacAlgorithm, secret: &str, payload: &str) -> Vec<u8> {
match algorithm {
HmacAlgorithm::Sha1 => {
let mut mac =
HmacSha1::new_from_slice(secret.as_bytes()).expect("HMAC can take key of any size");
mac.update(payload.as_bytes());
mac.finalize().into_bytes().to_vec()
}
HmacAlgorithm::Sha256 => {
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
.expect("HMAC can take key of any size");
mac.update(payload.as_bytes());
mac.finalize().into_bytes().to_vec()
}
HmacAlgorithm::Sha512 => {
let mut mac = HmacSha512::new_from_slice(secret.as_bytes())
.expect("HMAC can take key of any size");
mac.update(payload.as_bytes());
mac.finalize().into_bytes().to_vec()
}
}
}
pub fn encode_hmac_signature(encoding: Encoding, hmac_signature: &[u8]) -> String {
match encoding {
Encoding::Hex => hex::encode(hmac_signature),
Encoding::Base64 => BASE64_STANDARD.encode(hmac_signature),
Encoding::Base64Uri => BASE64_URL_SAFE.encode(hmac_signature),
}
}
pub fn verify_hmac_signature(
authentication_data: SignatureAuthenticationData,
webhook_signing_secret: &str,
) -> Result<(), AuthenticationError> {
let hmac_signature = calculate_hmac_signature(
authentication_data.config.algorithm_to_use,
&webhook_signing_secret,
&authentication_data.signed_payload,
);
let encoded_signature = encode_hmac_signature(
authentication_data.config.header_key_encoding,
&hmac_signature,
);
let final_expected_signature =
if let Some(signature_prefix) = authentication_data.signature_prefix {
format!("{}{}", signature_prefix, encoded_signature)
} else {
encoded_signature
};
if !constant_time_eq(
final_expected_signature.as_bytes(),
authentication_data.header_key_value.as_bytes(),
) {
return Err(AuthenticationError::InvalidSignature);
}
Ok(())
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(untagged)]
pub enum AuthenticationMethod {
Signature(SignatureAuthentication),
BasicAuth(BasicAuthAuthentication),
ApiKey(ApiKeyAuthentication),
}
impl AuthenticationMethod {
pub fn authenticate_http_request(
&self,
headers: &HeaderMap,
raw_payload: Option<&String>,
) -> Result<Option<Response>, AuthenticationError> {
match self {
AuthenticationMethod::Signature(SignatureAuthentication {
secret_key,
authentication_config,
signature_provider,
}) => {
let raw_payload = raw_payload.ok_or(AuthenticationError::InvalidPayload)?;
let config_data = SignatureConfigData { secret_key: &secret_key };
let handler = signature_provider.get_webhook_handler();
let challenge_response = handler
.map(|handler| {
handler.handle_challenge_request(headers, &config_data, raw_payload)
})
.transpose()?
.flatten();
if let Some(challenge_response) = challenge_response {
return Ok(Some(challenge_response));
}
let authentication_data = match handler {
Some(handler) => handler.get_hmac_authentication_data(headers, raw_payload)?,
None => {
let authentication_config = authentication_config
.as_ref()
.ok_or(AuthenticationError::InvalidCustomConfig)?;
let signature_header_value = headers
.try_get_webhook_header(&authentication_config.signature_header_name)?;
SignatureAuthenticationData::new(
Cow::Borrowed(raw_payload),
signature_header_value,
authentication_config.signature_prefix.as_deref(),
SignatureAuthenticationDetails::new(
authentication_config.algorithm,
authentication_config.encoding,
),
)
}
};
verify_hmac_signature(authentication_data, &secret_key)?;
}
AuthenticationMethod::ApiKey(ApiKeyAuthentication {
api_key_header,
api_key_secret,
}) => {
let api_key_to_cmp = headers
.try_get_webhook_header(&api_key_header)
.map_err(|_| AuthenticationError::InvalidApiKey)?;
if api_key_to_cmp != api_key_secret {
return Err(AuthenticationError::InvalidApiKey);
}
}
AuthenticationMethod::BasicAuth(BasicAuthAuthentication { username, password }) => {
let mut credentials_store = headers
.try_get_webhook_header("Authorization")
.map_err(|_| AuthenticationError::UnauthorizedBasicHttpAuth)?
.split(' ');
let _ = credentials_store
.next()
.filter(|r#type| *r#type == "Basic")
.ok_or(AuthenticationError::UnauthorizedBasicHttpAuth)?;
let credentials_as_base64 = credentials_store
.next()
.ok_or(AuthenticationError::UnauthorizedBasicHttpAuth)?;
let credentials_from_base64_as_bytes = BASE64_STANDARD
.decode(credentials_as_base64.as_bytes())
.map_err(|_| AuthenticationError::UnauthorizedBasicHttpAuth)?;
let credentials_separated_with_colon =
String::from_utf8(credentials_from_base64_as_bytes)
.map_err(|_| AuthenticationError::UnauthorizedBasicHttpAuth)?;
let credentials = credentials_separated_with_colon.split(':').collect_vec();
if credentials.len() != 2 {
return Err(AuthenticationError::UnauthorizedBasicHttpAuth);
}
if credentials.get(0).unwrap() != username
|| credentials.get(1).unwrap() != password
{
return Err(AuthenticationError::UnauthorizedBasicHttpAuth);
}
}
}
Ok(None)
}
}
#[derive(thiserror::Error, Debug)]
#[allow(unused)]
pub enum AuthenticationError {
#[error("failed to parse timestamp")]
InvalidTimestamp,
#[error("invalid secret")]
InvalidSecret(#[from] base64::DecodeError),
#[error("invalid header `{0}`")]
InvalidHeader(String),
#[error("signature timestamp too old")]
TimestampTooOldError,
#[error("signature timestamp too far in future")]
FutureTimestampError,
#[error("missing header {0}")]
MissingHeader(String),
#[error("signature invalid")]
InvalidSignature,
#[error("payload invalid")]
InvalidPayload,
#[error("invalid custom config")]
InvalidCustomConfig,
#[error("invalid auth header: {0}")]
InvalidAuthHeader(String),
#[error("invalid api key")]
InvalidApiKey,
#[error("invalid challenge response: {0}")]
InvalidChallengeResponse(String),
#[error("")]
UnauthorizedBasicHttpAuth,
}
impl IntoResponse for AuthenticationError {
fn into_response(self) -> Response {
let (status, error_message) = match &self {
AuthenticationError::InvalidTimestamp
| AuthenticationError::InvalidPayload
| AuthenticationError::InvalidHeader(_)
| AuthenticationError::MissingHeader(_)
| AuthenticationError::TimestampTooOldError
| AuthenticationError::FutureTimestampError
| AuthenticationError::InvalidCustomConfig
| AuthenticationError::InvalidChallengeResponse(_) => {
(StatusCode::BAD_REQUEST, self.to_string())
}
AuthenticationError::InvalidSecret(_)
| AuthenticationError::InvalidSignature
| AuthenticationError::InvalidAuthHeader(_) => {
(StatusCode::UNAUTHORIZED, self.to_string())
}
AuthenticationError::UnauthorizedBasicHttpAuth => {
return (
StatusCode::UNAUTHORIZED,
[(header::WWW_AUTHENTICATE, r#"Basic realm="Restricted Area""#)],
"Unauthorized",
)
.into_response()
}
AuthenticationError::InvalidApiKey => {
return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response()
}
};
let body = json!({ "error": error_message });
let mut headers = HeaderMap::new();
headers.insert("Content-Type", HeaderValue::from_static("application/json"));
(status, headers, body.to_string()).into_response()
}
}
@@ -1,410 +0,0 @@
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,
s3_helpers::S3Object,
worker::CLOUD_HOSTED,
DB,
};
use crate::{db::ApiAuthed, triggers::TriggerMode, utils::ExpiringCacheEntry};
pub mod handler;
pub mod http_trigger_args;
pub mod http_trigger_auth;
lazy_static::lazy_static! {
static ref HTTP_ACCESS_CACHE: Cache<(String, String, ApiAuthed), ExpiringCacheEntry<()>> = Cache::new(100);
static ref HTTP_AUTH_CACHE: Cache<(String, String, ApiAuthed), ExpiringCacheEntry<http_trigger_auth::AuthenticationMethod>> = Cache::new(100);
static ref HTTP_ROUTERS_CACHE: RwLock<RoutersCache> = RwLock::new(RoutersCache {
routers: HashMap::new(),
version: 0,
});
}
#[derive(Debug, Deserialize, Clone)]
pub struct TriggerRoute {
path: String,
script_path: String,
is_flow: bool,
route_path: String,
workspace_id: String,
request_type: RequestType,
authentication_method: AuthenticationMethod,
edited_by: String,
email: String,
static_asset_config: Option<sqlx::types::Json<S3Object>>,
is_static_website: bool,
authentication_resource_path: Option<String>,
workspaced_route: bool,
wrap_body: bool,
raw_string: bool,
error_handler_path: Option<String>,
error_handler_args: Option<sqlx::types::Json<HashMap<String, serde_json::Value>>>,
retry: Option<sqlx::types::Json<Retry>>,
mode: TriggerMode,
}
pub struct RoutersCache {
routers: HashMap<HttpMethod, matchit::Router<TriggerRoute>>,
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 *
static ref ROUTE_PATH_KEY_RE: regex::Regex = regex::Regex::new(r"(/)?(:|\*)[-\w]+").unwrap();
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,
email,
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:#}");
}
_ => {}
}
}
}
}
});
}
#[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);
}
}
@@ -1,67 +0,0 @@
#[cfg(not(feature = "private"))]
use crate::triggers::TriggerData;
#[allow(unused)]
#[cfg(feature = "private")]
pub use super::handler_ee::*;
#[cfg(not(feature = "private"))]
use {
super::KafkaTrigger,
crate::{
db::{ApiAuthed, DB},
triggers::TriggerCrud,
},
axum::async_trait,
sqlx::PgConnection,
windmill_common::error::{Error, Result},
windmill_git_sync::DeployedObject,
};
#[cfg(not(feature = "private"))]
#[async_trait]
impl TriggerCrud for KafkaTrigger {
type Trigger = ();
type TriggerConfig = ();
type TriggerConfigRequest = ();
type TestConnectionConfig = ();
const TABLE_NAME: &'static str = "";
const TRIGGER_TYPE: &'static str = "";
const SUPPORTS_SERVER_STATE: bool = false;
const SUPPORTS_TEST_CONNECTION: bool = false;
const ROUTE_PREFIX: &'static str = "/kafka_triggers";
const DEPLOYMENT_NAME: &'static str = "";
const IS_ALLOWED_ON_CLOUD: bool = false;
fn get_deployed_object(path: String) -> DeployedObject {
DeployedObject::KafkaTrigger { path }
}
async fn create_trigger(
&self,
_db: &DB,
_tx: &mut PgConnection,
_authed: &ApiAuthed,
_w_id: &str,
_trigger: TriggerData<Self::TriggerConfigRequest>,
) -> Result<()> {
Err(Error::BadRequest(
"Kafka triggers are not available in open source version".to_string(),
))
}
async fn update_trigger(
&self,
_db: &DB,
_executor: &mut PgConnection,
_authed: &ApiAuthed,
_workspace_id: &str,
_path: &str,
_trigger: TriggerData<Self::TriggerConfigRequest>,
) -> Result<()> {
Err(Error::BadRequest(
"Kafka triggers are not available in open source version".to_string(),
))
}
}
@@ -1,41 +0,0 @@
#[allow(unused)]
#[cfg(feature = "private")]
pub use super::listener_ee::*;
#[cfg(not(feature = "private"))]
use {
super::KafkaTrigger,
crate::triggers::{listener::ListeningTrigger, Listener},
std::sync::Arc,
tokio::sync::RwLock,
windmill_common::{error::Result, jobs::JobTriggerKind, DB},
};
#[cfg(not(feature = "private"))]
#[async_trait::async_trait]
impl Listener for KafkaTrigger {
type Consumer = ();
type Extra = ();
const JOB_TRIGGER_KIND: JobTriggerKind = JobTriggerKind::Kafka;
async fn get_consumer(
&self,
_db: &DB,
_listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
_err_message: Arc<RwLock<Option<String>>>,
_killpill_rx: tokio::sync::broadcast::Receiver<()>,
) -> Result<Option<Self::Consumer>> {
Ok(None)
}
async fn consume(
&self,
_db: &DB,
_consumer: Self::Consumer,
_listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
_err_message: Arc<RwLock<Option<String>>>,
_killpill_rx: tokio::sync::broadcast::Receiver<()>,
) {
()
}
}
@@ -1,15 +0,0 @@
#[cfg(feature = "private")]
mod handler_ee;
pub mod handler_oss;
#[cfg(feature = "private")]
mod listener_ee;
pub mod listener_oss;
#[cfg(feature = "private")]
mod mod_ee;
#[cfg(feature = "private")]
pub use mod_ee::*;
#[derive(Copy, Clone)]
pub struct KafkaTrigger;
@@ -1,928 +0,0 @@
use std::{collections::HashMap, fmt::Debug, sync::Arc};
use crate::{
capture::insert_capture_payload,
db::ApiAuthed,
triggers::{
handler::TriggerCrud,
trigger_helpers::{trigger_runnable, TriggerJobArgs},
Trigger, TriggerErrorHandling, TriggerMode,
},
users::fetch_api_authed,
};
use async_trait::async_trait;
use itertools::Itertools;
use rand::seq::SliceRandom;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::value::RawValue;
use sql_builder::SqlBuilder;
use sqlx::{FromRow, Row};
use tokio::sync::RwLock;
use windmill_common::{
error::{Error, Result},
jobs::JobTriggerKind,
triggers::{TriggerKind, TriggerMetadata},
utils::report_critical_error,
DB, INSTANCE_NAME,
};
#[allow(unused)]
#[async_trait]
pub trait Listener: TriggerCrud + TriggerJobArgs {
type Consumer: Send;
type Extra: Send + Sync;
type ExtraState: Send + Sync;
const JOB_TRIGGER_KIND: JobTriggerKind;
const EXTRA_TRIGGER_AND_WHERE_CLAUSE: &[&'static str] = &[];
const EXTRA_CAPTURE_AND_WHERE_CLAUSE: &[&'static str] = &[];
async fn get_consumer(
&self,
db: &DB,
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
err_message: Arc<RwLock<Option<String>>>,
killpill_rx: tokio::sync::broadcast::Receiver<()>,
) -> Result<Option<Self::Consumer>>;
async fn consume(
&self,
db: &DB,
consumer: Self::Consumer,
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
err_message: Arc<RwLock<Option<String>>>,
killpill_rx: tokio::sync::broadcast::Receiver<()>,
extra: Option<&Self::ExtraState>,
);
async fn fetch_enabled_unlistened_triggers(
&self,
db: &DB,
) -> Result<Vec<ListeningTrigger<Self::TriggerConfig>>> {
let mut fields = vec![
"workspace_id",
"path",
"script_path",
"is_flow",
"edited_by",
"email",
"edited_at",
"extra_perms",
"mode",
"error_handler_path",
"error_handler_args",
"retry",
];
fields.extend_from_slice(Self::ADDITIONAL_SELECT_FIELDS);
let mut sqlb = SqlBuilder::select_from(Self::TABLE_NAME);
sqlb.fields(&fields)
.and_where("(mode = 'enabled'::TRIGGER_MODE OR mode = 'suspended'::TRIGGER_MODE)")
.and_where(
"(last_server_ping IS NULL OR last_server_ping < now() - interval '15 seconds')",
);
for where_clause in Self::EXTRA_TRIGGER_AND_WHERE_CLAUSE {
sqlb.and_where(where_clause);
}
let sql = sqlb
.sql()
.map_err(|e| Error::InternalErr(format!("SQL error: {}", e)))?;
let triggers: Vec<Trigger<Self::TriggerConfig>> =
sqlx::query_as(&sql).fetch_all(db).await?;
let triggers = triggers
.into_iter()
.map(|trigger| ListeningTrigger {
path: trigger.base.path,
workspace_id: trigger.base.workspace_id,
is_flow: trigger.base.is_flow,
username: trigger.base.edited_by,
email: trigger.base.email,
script_path: trigger.base.script_path,
trigger_config: trigger.config,
error_handling: Some(trigger.error_handling),
trigger_mode: true,
suspended_mode: trigger.base.mode == TriggerMode::Suspended,
})
.collect_vec();
Ok(triggers)
}
async fn fetch_unlistened_captures(
&self,
db: &DB,
) -> Result<Vec<ListeningTrigger<Self::TriggerConfig>>> {
let fields = vec![
"path",
"is_flow",
"workspace_id",
"owner AS username",
"email",
"trigger_config",
];
let mut sqlb = SqlBuilder::select_from("capture_config");
sqlb.fields(&fields)
.and_where(format!("trigger_kind = '{}'", Self::TRIGGER_KIND.to_key()))
.and_where("last_client_ping > NOW() - INTERVAL '10 seconds'")
.and_where("trigger_config IS NOT NULL")
.and_where(
"(last_server_ping IS NULL OR last_server_ping < NOW() - INTERVAL '15 seconds')",
);
for where_clause in Self::EXTRA_CAPTURE_AND_WHERE_CLAUSE {
sqlb.and_where(where_clause);
}
let sql = sqlb.sql().expect("failed to build SQL");
let captures: Vec<Capture<Self::TriggerConfig>> =
sqlx::query_as(&sql).fetch_all(db).await?;
let captures = captures
.into_iter()
.map(|capture| ListeningTrigger {
username: capture.username,
path: capture.path,
workspace_id: capture.workspace_id,
script_path: "".to_string(),
email: capture.email,
trigger_config: capture.trigger_config,
trigger_mode: false,
is_flow: capture.is_flow,
error_handling: None,
suspended_mode: false,
})
.collect_vec();
Ok(captures)
}
async fn get_extra_state(&self) -> Option<Self::ExtraState> {
None
}
async fn cleanup(
&self,
_db: &DB,
_listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
_extra: Option<&Self::ExtraState>,
) -> Result<()> {
Ok(())
}
async fn loop_ping(
&self,
db: &DB,
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
status: Arc<RwLock<Option<String>>>,
error_message: Option<String>,
) {
update_rw_lock(status.clone(), error_message).await;
loop {
if let None = self
.update_ping(db, listening_trigger, status.read().await.as_deref())
.await
{
return;
}
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
}
}
async fn update_ping(
&self,
db: &DB,
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
error: Option<&str>,
) -> Option<()> {
if listening_trigger.trigger_mode {
self.update_trigger_ping(db, listening_trigger, error).await
} else {
self.update_capture_ping(db, listening_trigger, error).await
}
}
async fn update_ping_and_loop_ping_status(
&self,
db: &DB,
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
loop_ping_status: Arc<RwLock<Option<String>>>,
error: Option<String>,
) -> Option<()> {
// update immediately the ping status and update the loop ping status so that the next loop pings will display the new status
update_rw_lock(loop_ping_status.clone(), error.clone()).await;
if let None = self
.update_ping(db, listening_trigger, error.as_deref())
.await
{
return None;
}
Some(())
}
async fn update_trigger_ping(
&self,
db: &DB,
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
error: Option<&str>,
) -> Option<()> {
let updated = sqlx::query_scalar::<_, i32>(&format!(
r#"
UPDATE
{}
SET
last_server_ping = now(), error = $1
WHERE
workspace_id = $2 AND
path = $3 AND
server_id = $4 AND
(mode = 'enabled'::TRIGGER_MODE OR mode = 'suspended'::TRIGGER_MODE)
RETURNING 1
"#,
Self::TABLE_NAME
))
.bind(error)
.bind(&listening_trigger.workspace_id)
.bind(&listening_trigger.path)
.bind(&*INSTANCE_NAME)
.fetch_optional(db)
.await;
self.handle_ping_result(updated, db, listening_trigger, "trigger")
.await
}
async fn update_capture_ping(
&self,
db: &DB,
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
error: Option<&str>,
) -> Option<()> {
let updated = sqlx::query_scalar!(
r#"
UPDATE
capture_config
SET
last_server_ping = now(), error = $1
WHERE
workspace_id = $2 AND
path = $3 AND
is_flow = $4 AND
trigger_kind = $5 AND
server_id = $6 AND
last_client_ping > NOW() - INTERVAL '10 seconds'
RETURNING 1
"#,
error,
&listening_trigger.workspace_id,
&listening_trigger.path,
&listening_trigger.is_flow,
Self::TRIGGER_KIND as TriggerKind,
&*INSTANCE_NAME
)
.fetch_optional(db)
.await
.map(|result| result.flatten());
self.handle_ping_result(updated, db, listening_trigger, "capture")
.await
}
async fn handle_ping_result(
&self,
result: sqlx::Result<Option<i32>>,
db: &DB,
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
entity_type: &str,
) -> Option<()> {
match result {
Ok(updated) => {
if updated.is_none() {
self.reset_ping_for_restart(db, listening_trigger).await;
tracing::info!(
"{} {} {} changed, disabled, or deleted, stopping...",
Self::TRIGGER_KIND,
entity_type,
listening_trigger.path
);
return None;
}
}
Err(error) => {
tracing::warn!(
"Error updating ping of {} {} {}: {:?}",
Self::TRIGGER_KIND,
entity_type,
&listening_trigger.path,
error
);
}
}
Some(())
}
async fn reset_ping_for_restart(
&self,
db: &DB,
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
) {
if listening_trigger.trigger_mode {
let _ = sqlx::query(&format!(
r#"
UPDATE
{}
SET
last_server_ping = NULL
WHERE
workspace_id = $1 AND
path = $2 AND
server_id IS NULL
"#,
Self::TABLE_NAME
))
.bind(&listening_trigger.workspace_id)
.bind(&listening_trigger.path)
.execute(db)
.await;
} else {
let _ = sqlx::query!(
r#"
UPDATE
capture_config
SET
last_server_ping = NULL
WHERE
workspace_id = $1 AND
path = $2 AND
is_flow = $3 AND
trigger_kind = $4 AND
server_id IS NULL
"#,
&listening_trigger.workspace_id,
&listening_trigger.path,
&listening_trigger.is_flow,
Self::TRIGGER_KIND as TriggerKind
)
.execute(db)
.await;
}
}
async fn disable_with_error(
&self,
db: &DB,
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
error: String,
) {
if listening_trigger.trigger_mode {
let report_status = sqlx::query(&format!(
r#"
UPDATE
{}
SET
mode = 'disabled'::TRIGGER_MODE,
error = $1,
server_id = NULL,
last_server_ping = NULL
WHERE
workspace_id = $2 AND
path = $3
"#,
Self::TABLE_NAME
))
.bind(&error)
.bind(&listening_trigger.workspace_id)
.bind(&listening_trigger.path)
.execute(db)
.await;
match report_status {
Ok(_) => {
report_critical_error(
format!(
"Disabling {} trigger {} because of error: {}",
Self::TRIGGER_KIND,
listening_trigger.path,
error
),
db.clone(),
Some(&listening_trigger.workspace_id),
None,
)
.await;
}
Err(disable_err) => {
report_critical_error(
format!("Could not disable {} trigger {} with err {}, disabling because of error {}", Self::TRIGGER_KIND, listening_trigger.path, disable_err, error),
db.clone(),
Some(&listening_trigger.workspace_id),
None,
).await;
}
}
return;
}
let report_status = sqlx::query!(
r#"
UPDATE
capture_config
SET
error = $1,
server_id = NULL,
last_server_ping = NULL
WHERE
workspace_id = $2 AND
path = $3 AND
is_flow = $4 AND
trigger_kind = $5
"#,
error,
listening_trigger.workspace_id,
listening_trigger.path,
listening_trigger.is_flow,
Self::TRIGGER_KIND as TriggerKind
)
.execute(db)
.await;
if let Err(disable_err) = report_status {
tracing::error!(
"Could not disable {} capture {} ({}) with err {}, disabling because of error {}",
Self::TRIGGER_KIND,
listening_trigger.path,
listening_trigger.workspace_id,
disable_err,
error
)
}
}
async fn handle_trigger(
&self,
db: &DB,
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
payload: Self::Payload,
trigger_info: HashMap<String, Box<RawValue>>,
_extra: Option<Self::Extra>,
) -> Result<()> {
let args = Self::build_job_args(
&listening_trigger.script_path,
listening_trigger.is_flow,
&listening_trigger.workspace_id,
db,
payload,
trigger_info,
)
.await?;
let authed = listening_trigger
.authed(db, &Self::TRIGGER_KIND.to_string())
.await?;
let (retry, error_handler_path, error_handler_args) =
match listening_trigger.error_handling.as_ref() {
Some(error_handling) => (
error_handling.retry.as_ref(),
error_handling.error_handler_path.as_deref(),
error_handling.error_handler_args.as_ref(),
),
None => (None, None, None),
};
tracing::debug!(
"Triggering job from {} event {} with args {:?}",
Self::TRIGGER_KIND,
listening_trigger.path,
args
);
trigger_runnable(
db,
None,
authed,
&listening_trigger.workspace_id,
&listening_trigger.script_path,
listening_trigger.is_flow,
args,
retry,
error_handler_path.as_deref(),
error_handler_args,
format!("{}_trigger/{}", Self::TRIGGER_KIND, listening_trigger.path),
None,
listening_trigger.suspended_mode,
TriggerMetadata::new(Some(listening_trigger.path.clone()), Self::JOB_TRIGGER_KIND),
)
.await?;
Ok(())
}
async fn handle_event(
&self,
db: &DB,
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
payload: Self::Payload,
trigger_info: HashMap<String, Box<RawValue>>,
extra: Option<Self::Extra>,
) -> Result<()> {
if listening_trigger.trigger_mode {
if let Err(err) = self
.handle_trigger(db, listening_trigger, payload, trigger_info, extra)
.await
{
report_critical_error(
format!(
"Failed to trigger job from {} event {}: {:?}",
Self::TRIGGER_KIND,
listening_trigger.path,
err
),
db.clone(),
Some(&listening_trigger.workspace_id),
None,
)
.await;
return Err(err);
};
return Ok(());
}
let (main_args, preprocessor_args) = Self::build_capture_payloads(&payload, trigger_info);
if let Err(err) = insert_capture_payload(
db,
&listening_trigger.workspace_id,
&listening_trigger.path,
listening_trigger.is_flow,
&Self::TRIGGER_KIND,
main_args,
preprocessor_args,
&listening_trigger.username,
)
.await
{
tracing::error!("Error inserting capture payload: {:?}", err);
return Err(err);
}
Ok(())
}
}
#[allow(unused)]
async fn listening<T: Listener>(
db: DB,
listener: T,
listening_trigger: ListeningTrigger<T::TriggerConfig>,
mut killpill_rx: tokio::sync::broadcast::Receiver<()>,
) {
let killpill_rx_consumer = killpill_rx.resubscribe();
let killpill_rx_get_consumer = killpill_rx.resubscribe();
let loop_ping_status = Arc::new(RwLock::new(None));
let extra_state = listener.get_extra_state().await;
let path = listening_trigger.path.clone();
tokio::select! {
biased;
_ = killpill_rx.recv() => {
let _ = listener.cleanup(&db, &listening_trigger, extra_state.as_ref()).await;
}
_ = listener.loop_ping(&db, &listening_trigger, loop_ping_status.clone(), Some("Connecting...".to_string())) => {
let _ = listener.cleanup(&db, &listening_trigger, extra_state.as_ref()).await;
}
consumer = {
tracing::info!("[{}] Getting consumer for trigger {}", T::TRIGGER_KIND, path);
listener.get_consumer(&db, &listening_trigger, loop_ping_status.clone(), killpill_rx_get_consumer)
} => {
tokio::select! {
biased;
_ = killpill_rx.recv() => {
tracing::info!("[{}] Killing pill received, stopping consumer for trigger {}", T::TRIGGER_KIND, path);
let _ = listener.cleanup(&db, &listening_trigger, extra_state.as_ref()).await;
return;
}
_ = listener.loop_ping(&db, &listening_trigger, loop_ping_status.clone(), None) => {
tracing::info!("[{}] Loop ping exited, stopping consumer for trigger {}", T::TRIGGER_KIND, path);
let _ = listener.cleanup(&db, &listening_trigger, extra_state.as_ref()).await;
return;
}
_ = async {
match consumer {
Ok(Some(consumer)) => {
listener.update_ping_and_loop_ping_status(&db, &listening_trigger, loop_ping_status.clone(), None).await;
tracing::info!("[{}] Starting consumer for trigger {}", T::TRIGGER_KIND, path);
listener.consume(&db, consumer, &listening_trigger, loop_ping_status.clone(), killpill_rx_consumer, extra_state.as_ref()).await;
tracing::info!("[{}] Consumer stopped for trigger {}", T::TRIGGER_KIND, path);
}
Err(error) => {
tracing::error!("[{}] Disabling trigger {} due to consumer error: {}", T::TRIGGER_KIND, path, error);
listener.disable_with_error(&db, &listening_trigger, error.to_string()).await;
}
Ok(None) => {
tracing::error!("[{}] Consumer is None for trigger {}", T::TRIGGER_KIND, path);
}
}
} => {
let _ = listener.cleanup(&db, &listening_trigger, extra_state.as_ref()).await;
return;
}
}
}
}
}
#[allow(unused)]
async fn listen_to_unlistened_events<T: Copy + Listener>(
listener: T,
db: DB,
killpill_rx: &tokio::sync::broadcast::Receiver<()>,
) {
let unlistend_enabled_triggers = listener.fetch_enabled_unlistened_triggers(&db).await;
match unlistend_enabled_triggers {
Ok(mut unlistend_enabled_triggers) => {
unlistend_enabled_triggers.shuffle(&mut rand::rng());
for trigger in unlistend_enabled_triggers {
let has_lock = sqlx::query_scalar(&format!(
r#"
UPDATE
{}
SET
server_id = $1,
last_server_ping = now(),
error = 'Connecting...'
WHERE
(mode = 'enabled'::TRIGGER_MODE OR mode = 'suspended'::TRIGGER_MODE)
AND workspace_id = $2
AND path = $3
AND (last_server_ping IS NULL
OR last_server_ping < now() - INTERVAL '15 seconds'
)
RETURNING true
"#,
T::TABLE_NAME,
))
.bind(&*INSTANCE_NAME)
.bind(&trigger.workspace_id)
.bind(&trigger.path)
.fetch_optional(&db)
.await;
match has_lock {
Ok(has_lock) => {
if has_lock.flatten().unwrap_or(false) {
tracing::info!(
"Spawning new task to listen for {} event",
T::TABLE_NAME
);
tokio::spawn({
let db = db.clone();
let killpill_rx = killpill_rx.resubscribe();
async move { listening(db, listener, trigger, killpill_rx).await }
});
} else {
tracing::info!(
"{} trigger {} already being listened to",
T::TRIGGER_KIND,
trigger.path
);
}
}
Err(err) => {
tracing::error!(
"Error acquiring lock for {} trigger {}: {:?}",
T::TRIGGER_KIND,
trigger.path,
err
);
}
};
}
}
Err(err) => {
tracing::error!("Error fetching {} triggers: {:?}", T::TRIGGER_KIND, err,);
}
}
let unlisted_captures = listener.fetch_unlistened_captures(&db).await;
match unlisted_captures {
Ok(unlistened_captures) => {
for capture in unlistened_captures {
let has_lock = sqlx::query_scalar!(
r#"
UPDATE
capture_config
SET
server_id = $1,
last_server_ping = now(),
error = 'Connecting...'
WHERE
last_client_ping > NOW() - INTERVAL '10 seconds' AND
workspace_id = $2 AND
path = $3 AND
is_flow = $4 AND
trigger_kind = $5 AND
(last_server_ping IS NULL OR last_server_ping < now() - interval '15 seconds')
RETURNING true
"#,
*INSTANCE_NAME,
&capture.workspace_id,
&capture.path,
&capture.is_flow,
T::TRIGGER_KIND as TriggerKind
)
.fetch_optional(&db)
.await;
match has_lock {
Ok(has_lock) => {
if has_lock.flatten().unwrap_or(false) {
tokio::spawn({
let db = db.clone();
let killpill_rx = killpill_rx.resubscribe();
async move { listening(db, listener, capture, killpill_rx).await }
});
} else {
tracing::info!(
"{} capture {} already being listened to",
T::TRIGGER_KIND.to_string(),
capture.path
);
}
}
Err(err) => {
tracing::error!(
"Error acquiring lock for capture {} {}: {:?}",
T::TRIGGER_KIND,
capture.path,
err
);
}
};
}
}
Err(err) => {
tracing::error!(
"Error fetching captures {} triggers: {:?}",
T::TRIGGER_KIND,
err
);
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
struct Capture<T>
where
T: for<'r> FromRow<'r, sqlx::postgres::PgRow>,
{
path: String,
is_flow: bool,
workspace_id: String,
username: String,
email: String,
#[serde(flatten)]
trigger_config: T,
}
impl<T> FromRow<'_, sqlx::postgres::PgRow> for Capture<T>
where
T: for<'r> FromRow<'r, sqlx::postgres::PgRow> + DeserializeOwned,
{
fn from_row(row: &sqlx::postgres::PgRow) -> std::result::Result<Self, sqlx::Error> {
let trigger_config_value = row.try_get("trigger_config")?;
let trigger_config: T = serde_json::from_value(trigger_config_value)
.map_err(|e| sqlx::Error::Decode(Box::new(e)))?;
Ok(Capture {
path: row.try_get("path")?,
is_flow: row.try_get("is_flow")?,
workspace_id: row.try_get("workspace_id")?,
username: row.try_get("username")?,
email: row.try_get("email")?,
trigger_config,
})
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ListeningTrigger<T> {
pub path: String,
pub is_flow: bool,
pub workspace_id: String,
pub username: String,
pub email: String,
pub trigger_config: T,
pub script_path: String,
pub trigger_mode: bool,
pub error_handling: Option<TriggerErrorHandling>,
pub suspended_mode: bool,
}
impl<T> ListeningTrigger<T> {
pub async fn authed(&self, db: &DB, username: &str) -> Result<ApiAuthed> {
fetch_api_authed(
self.username.clone(),
self.email.clone(),
&self.workspace_id,
db,
Some(format!("{}-{}", username, self.path)),
)
.await
}
}
#[allow(unused)]
pub async fn update_rw_lock<T>(lock: std::sync::Arc<tokio::sync::RwLock<T>>, value: T) -> () {
let mut w = lock.write().await;
*w = value;
}
#[allow(unused)]
fn listen_to<T: Copy + Listener>(
trigger: T,
db: DB,
mut killpill_rx: tokio::sync::broadcast::Receiver<()>,
) {
tokio::spawn(async move {
listen_to_unlistened_events(trigger, db.clone(), &killpill_rx).await;
loop {
tokio::select! {
biased;
_ = killpill_rx.recv() => {
return;
}
_ = tokio::time::sleep(tokio::time::Duration::from_secs(15)) => {
listen_to_unlistened_events(trigger, db.clone(), &killpill_rx).await
}
}
}
});
}
#[allow(unused)]
pub fn start_all_listeners(db: DB, killpill_rx: &tokio::sync::broadcast::Receiver<()>) {
tracing::info!("Starting trigger listeners based on available features...");
#[cfg(feature = "postgres_trigger")]
{
let postgres_killpill_rx = killpill_rx.resubscribe();
use crate::triggers::postgres::PostgresTrigger;
listen_to(PostgresTrigger, db.clone(), postgres_killpill_rx)
}
#[cfg(feature = "mqtt_trigger")]
{
let mqtt_killpill_rx = killpill_rx.resubscribe();
use crate::triggers::mqtt::MqttTrigger;
listen_to(MqttTrigger, db.clone(), mqtt_killpill_rx)
}
#[cfg(feature = "websocket")]
{
let mqtt_killpill_rx = killpill_rx.resubscribe();
use crate::triggers::websocket::WebsocketTrigger;
listen_to(WebsocketTrigger, db.clone(), mqtt_killpill_rx)
}
#[cfg(all(feature = "gcp_trigger", feature = "enterprise", feature = "private"))]
{
let gcp_killpill_rx = killpill_rx.resubscribe();
use crate::triggers::gcp::GcpTrigger;
listen_to(GcpTrigger, db.clone(), gcp_killpill_rx);
}
#[cfg(all(feature = "sqs_trigger", feature = "enterprise", feature = "private"))]
{
let gcp_killpill_rx = killpill_rx.resubscribe();
use crate::triggers::sqs::SqsTrigger;
listen_to(SqsTrigger, db.clone(), gcp_killpill_rx);
}
#[cfg(all(feature = "nats", feature = "enterprise", feature = "private"))]
{
let gcp_killpill_rx = killpill_rx.resubscribe();
use crate::triggers::nats::NatsTrigger;
listen_to(NatsTrigger, db.clone(), gcp_killpill_rx);
}
#[cfg(all(feature = "kafka", feature = "enterprise", feature = "private"))]
{
let gcp_killpill_rx = killpill_rx.resubscribe();
use crate::triggers::kafka::KafkaTrigger;
listen_to(KafkaTrigger, db.clone(), gcp_killpill_rx);
}
tracing::info!("All available trigger listeners have been started");
}
-182
View File
@@ -1,182 +0,0 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::{types::Json as SqlxJson, FromRow};
use std::{collections::HashMap, fmt::Debug};
use windmill_common::jobs::JobTriggerKind;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum HandlerAction {
Trigger { path: String, trigger_kind: JobTriggerKind },
// Future variants can be added here (e.g., Script, Flow, etc.)
}
#[cfg(all(feature = "smtp", feature = "private"))]
pub mod email;
#[cfg(all(feature = "gcp_trigger", feature = "enterprise", feature = "private"))]
pub mod gcp;
#[cfg(feature = "http_trigger")]
pub mod http;
#[cfg(all(feature = "kafka", feature = "enterprise", feature = "private"))]
pub mod kafka;
#[cfg(feature = "mqtt_trigger")]
pub mod mqtt;
#[cfg(all(feature = "nats", feature = "enterprise", feature = "private"))]
pub mod nats;
#[cfg(feature = "postgres_trigger")]
pub mod postgres;
#[cfg(all(feature = "sqs_trigger", feature = "enterprise", feature = "private"))]
pub mod sqs;
#[cfg(feature = "websocket")]
pub mod websocket;
pub mod filter;
pub mod global_handler;
mod handler;
mod listener;
pub mod trigger_helpers;
#[allow(unused)]
pub(crate) use handler::TriggerCrud;
pub use handler::{generate_trigger_routers, get_triggers_count_internal, TriggersCount};
pub use listener::start_all_listeners;
#[allow(unused)]
pub(crate) use listener::Listener;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StandardTriggerQuery {
pub page: Option<usize>,
pub per_page: Option<usize>,
pub path: Option<String>,
pub is_flow: Option<bool>,
pub path_start: Option<String>,
}
#[derive(Debug, FromRow, Clone, Serialize, Deserialize)]
pub struct BaseTrigger {
pub workspace_id: String,
pub path: String,
pub script_path: String,
pub mode: TriggerMode,
pub is_flow: bool,
pub edited_by: String,
pub email: String,
pub edited_at: DateTime<Utc>,
pub extra_perms: Option<serde_json::Value>,
}
#[derive(Debug, FromRow, Clone, Serialize, Deserialize)]
pub struct ServerState {
#[serde(skip_serializing_if = "Option::is_none")]
pub server_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_server_ping: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug, FromRow, Clone, Serialize, Deserialize)]
pub struct TriggerErrorHandling {
#[serde(skip_serializing_if = "Option::is_none")]
pub error_handler_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_handler_args: Option<SqlxJson<HashMap<String, serde_json::Value>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub retry: Option<sqlx::types::Json<windmill_common::flows::Retry>>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct Trigger<T>
where
T: for<'r> FromRow<'r, sqlx::postgres::PgRow>,
{
#[serde(flatten)]
pub base: BaseTrigger,
#[serde(flatten)]
pub config: T,
#[serde(flatten)]
pub server_state: Option<ServerState>,
#[serde(flatten)]
pub error_handling: TriggerErrorHandling,
}
impl<T> FromRow<'_, sqlx::postgres::PgRow> for Trigger<T>
where
T: for<'r> FromRow<'r, sqlx::postgres::PgRow>,
{
fn from_row(row: &sqlx::postgres::PgRow) -> std::result::Result<Self, sqlx::Error> {
let base = BaseTrigger::from_row(row)?;
Ok(Trigger {
base,
config: T::from_row(row)?,
server_state: ServerState::from_row(row).ok(),
error_handling: TriggerErrorHandling::from_row(row)?,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BaseTriggerData {
pub path: String,
pub script_path: String,
pub is_flow: bool,
#[deprecated(note = "Use mode instead")]
enabled: Option<bool>, // Kept for backwards compatibility, use mode instead
mode: Option<TriggerMode>,
}
impl BaseTriggerData {
pub fn mode(&self) -> &TriggerMode {
self.mode.as_ref().unwrap_or(
#[allow(deprecated)]
if self.enabled.unwrap_or(true) {
&TriggerMode::Enabled
} else {
&TriggerMode::Disabled
},
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TriggerData<T: Debug> {
#[serde(flatten)]
pub base: BaseTriggerData,
#[serde(flatten)]
pub config: T,
#[serde(flatten)]
pub error_handling: TriggerErrorHandling,
}
impl StandardTriggerQuery {
pub fn offset(&self) -> i64 {
let page = self.page.unwrap_or(0);
let per_page = self.per_page.unwrap_or(100);
(page * per_page) as i64
}
pub fn limit(&self) -> i64 {
self.per_page.unwrap_or(100) as i64
}
}
impl Default for StandardTriggerQuery {
fn default() -> Self {
Self { page: Some(0), per_page: Some(100), path: None, path_start: None, is_flow: None }
}
}
#[derive(sqlx::Type, Serialize, Deserialize, Debug, PartialEq, Clone)]
#[sqlx(type_name = "TRIGGER_MODE", rename_all = "lowercase")]
#[serde(rename_all(serialize = "lowercase", deserialize = "lowercase"))]
pub enum TriggerMode {
Enabled,
Disabled,
Suspended,
}
@@ -1,237 +0,0 @@
use crate::{
db::{ApiAuthed, DB},
resources::try_get_resource_from_db_as,
triggers::{Trigger, TriggerCrud, TriggerData},
};
use axum::async_trait;
use itertools::Itertools;
use sqlx::{types::Json as SqlxJson, PgConnection};
use windmill_common::{
db::UserDB,
error::{Error, Result},
};
use windmill_git_sync::DeployedObject;
use super::{
MqttClientBuilder, MqttClientVersion, MqttConfig, MqttConfigRequest, MqttResource, MqttTrigger,
MqttV3Config, MqttV5Config, SubscribeTopic, TestMqttConfig,
};
#[async_trait]
impl TriggerCrud for MqttTrigger {
type TriggerConfig = MqttConfig;
type Trigger = Trigger<Self::TriggerConfig>;
type TriggerConfigRequest = MqttConfigRequest;
type TestConnectionConfig = TestMqttConfig;
const TABLE_NAME: &'static str = "mqtt_trigger";
const TRIGGER_TYPE: &'static str = "mqtt";
const SUPPORTS_SERVER_STATE: bool = true;
const SUPPORTS_TEST_CONNECTION: bool = true;
const ROUTE_PREFIX: &'static str = "/mqtt_triggers";
const DEPLOYMENT_NAME: &'static str = "MQTT trigger";
const ADDITIONAL_SELECT_FIELDS: &[&'static str] = &[
"mqtt_resource_path",
"subscribe_topics",
"v3_config",
"v5_config",
"client_id",
"client_version",
];
const IS_ALLOWED_ON_CLOUD: bool = false;
fn get_deployed_object(path: String) -> DeployedObject {
DeployedObject::MqttTrigger { path }
}
async fn validate_config(
&self,
_db: &DB,
config: &Self::TriggerConfigRequest,
_workspace_id: &str,
) -> Result<()> {
if config.mqtt_resource_path.trim().is_empty() {
return Err(Error::BadRequest(
"MQTT resource path cannot be empty".to_string(),
));
}
if config.subscribe_topics.is_empty() {
return Err(Error::BadRequest(
"At least one subscribe topic must be specified".to_string(),
));
}
Ok(())
}
async fn create_trigger(
&self,
_db: &DB,
tx: &mut PgConnection,
authed: &ApiAuthed,
w_id: &str,
trigger: TriggerData<Self::TriggerConfigRequest>,
) -> Result<()> {
let subscribe_topics = trigger
.config
.subscribe_topics
.into_iter()
.map(SqlxJson)
.collect_vec();
let v3_config = trigger.config.v3_config.map(SqlxJson);
let v5_config = trigger.config.v5_config.map(SqlxJson);
sqlx::query!(
r#"
INSERT INTO mqtt_trigger (
mqtt_resource_path,
subscribe_topics,
client_version,
client_id,
v3_config,
v5_config,
workspace_id,
path,
script_path,
is_flow,
email,
mode,
edited_by,
error_handler_path,
error_handler_args,
retry
)
VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16
)"#,
trigger.config.mqtt_resource_path,
subscribe_topics.as_slice() as &[SqlxJson<SubscribeTopic>],
trigger.config.client_version as Option<MqttClientVersion>,
trigger.config.client_id,
v3_config as Option<SqlxJson<MqttV3Config>>,
v5_config as Option<SqlxJson<MqttV5Config>>,
w_id,
trigger.base.path,
trigger.base.script_path,
trigger.base.is_flow,
authed.email,
trigger.base.mode() as _,
authed.username,
trigger.error_handling.error_handler_path,
trigger.error_handling.error_handler_args as _,
trigger.error_handling.retry as _
)
.execute(tx)
.await?;
Ok(())
}
async fn update_trigger(
&self,
_db: &DB,
tx: &mut PgConnection,
authed: &ApiAuthed,
workspace_id: &str,
path: &str,
trigger: TriggerData<Self::TriggerConfigRequest>,
) -> Result<()> {
let subscribe_topics = trigger
.config
.subscribe_topics
.into_iter()
.map(SqlxJson)
.collect_vec();
let v3_config = trigger.config.v3_config.map(SqlxJson);
let v5_config = trigger.config.v5_config.map(SqlxJson);
// Important to set server_id to NULL to stop current mqtt listener
sqlx::query!(
r#"
UPDATE
mqtt_trigger
SET
mqtt_resource_path = $1,
subscribe_topics = $2,
client_version = $3,
client_id = $4,
v3_config = $5,
v5_config = $6,
is_flow = $7,
edited_by = $8,
email = $9,
script_path = $10,
path = $11,
edited_at = now(),
error = NULL,
server_id = NULL,
error_handler_path = $14,
error_handler_args = $15,
retry = $16
WHERE
workspace_id = $12 AND
path = $13
"#,
trigger.config.mqtt_resource_path,
subscribe_topics.as_slice() as &[SqlxJson<SubscribeTopic>],
trigger.config.client_version as Option<MqttClientVersion>,
trigger.config.client_id,
v3_config as Option<SqlxJson<MqttV3Config>>,
v5_config as Option<SqlxJson<MqttV5Config>>,
trigger.base.is_flow,
authed.username,
authed.email,
trigger.base.script_path,
trigger.base.path,
workspace_id,
path,
trigger.error_handling.error_handler_path,
trigger.error_handling.error_handler_args as _,
trigger.error_handling.retry as _
)
.execute(tx)
.await?;
Ok(())
}
async fn test_connection(
&self,
db: &DB,
authed: &ApiAuthed,
user_db: &UserDB,
workspace_id: &str,
config: Self::TestConnectionConfig,
) -> Result<()> {
let mqtt_resource = try_get_resource_from_db_as::<MqttResource>(
authed,
Some(user_db.clone()),
db,
&config.mqtt_resource_path,
workspace_id,
)
.await?;
let connect_f = async {
let client_builder = MqttClientBuilder::new(
mqtt_resource,
Some(""),
vec![],
config.v3_config.as_ref(),
config.v5_config.as_ref(),
config.client_version.as_ref(),
);
client_builder.build_client().await.map_err(|err| {
Error::BadConfig(format!(
"Error connecting to mqtt broker: {}",
err.to_string()
))
})
};
connect_f.await?;
Ok(())
}
}
@@ -1,376 +0,0 @@
use std::{collections::HashMap, sync::Arc};
use async_trait::async_trait;
use bytes::Bytes;
use rumqttc::{
v5::{
mqttbytes::v5::PublishProperties, Event as V5Event, EventLoop as V5EventLoop,
Incoming as V5Incoming,
},
Event as V3Event, EventLoop as V3EventLoop, Incoming as V3Incoming,
};
use std::time::Duration;
use tokio::sync::RwLock;
use windmill_common::{
db::UserDB,
error::{to_anyhow, Error, Result},
jobs::JobTriggerKind,
worker::to_raw_value,
DB,
};
use crate::{
resources::try_get_resource_from_db_as,
triggers::{
listener::ListeningTrigger,
mqtt::{
MqttClientBuilder, MqttClientResult, MqttConfig, MqttResource, MqttTrigger,
V3MqttHandler, V5MqttHandler,
},
trigger_helpers::TriggerJobArgs,
Listener,
},
};
#[async_trait]
impl Listener for MqttTrigger {
type Consumer = MqttClientResult;
type Extra = ();
type ExtraState = ();
const JOB_TRIGGER_KIND: JobTriggerKind = JobTriggerKind::Mqtt;
async fn get_consumer(
&self,
db: &DB,
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
_err_message: Arc<RwLock<Option<String>>>,
_killpill_rx: tokio::sync::broadcast::Receiver<()>,
) -> Result<Option<Self::Consumer>> {
let ListeningTrigger::<Self::TriggerConfig> { workspace_id, trigger_config, .. } =
listening_trigger;
let MqttConfig {
mqtt_resource_path,
subscribe_topics,
v3_config,
v5_config,
client_id,
client_version,
..
} = trigger_config;
let authed = listening_trigger
.authed(db, &Self::TRIGGER_KIND.to_string())
.await?;
let mqtt_resource = try_get_resource_from_db_as::<MqttResource>(
&authed,
Some(UserDB::new(db.clone())),
&db,
mqtt_resource_path,
workspace_id,
)
.await?;
let subscribe_topics = subscribe_topics
.iter()
.map(|topic| topic.0.clone())
.collect();
let client_builder = MqttClientBuilder::new(
mqtt_resource,
client_id.as_deref(),
subscribe_topics,
v3_config.as_ref().map(|c| &c.0),
v5_config.as_ref().map(|c| &c.0),
client_version.as_ref(),
);
let client_result = client_builder
.build_client()
.await
.map_err(|e| Error::BadConfig(format!("Failed to build MQTT client: {}", e)))?;
Ok(Some(client_result))
}
async fn consume(
&self,
db: &DB,
consumer: Self::Consumer,
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
_err_message: Arc<RwLock<Option<String>>>,
_killpill_rx: tokio::sync::broadcast::Receiver<()>,
_extra_state: Option<&Self::ExtraState>,
) {
tracing::info!(
"Starting to listen for MQTT trigger {}",
&listening_trigger.path
);
match consumer {
MqttClientResult::V3((v3_handler, event_loop)) => {
handle_event(&db, self, listening_trigger, v3_handler, event_loop).await
}
MqttClientResult::V5((v5_handler, event_loop)) => {
handle_event(&db, self, listening_trigger, v5_handler, event_loop).await
}
}
}
}
const TIMEOUT_DURATION: u64 = 10;
const CONNECTION_TIMEOUT: Duration = Duration::from_secs(TIMEOUT_DURATION);
fn convert_disconnect_packet_into_string(
disconnect: rumqttc::v5::mqttbytes::v5::Disconnect,
) -> String {
let err_message = disconnect
.properties
.map(|properties| properties.reason_string)
.flatten();
let reason_code = disconnect.reason_code as u8;
format!(
"Disconnected by the broker, reason code: {}, {}",
reason_code,
err_message
.map(|err| format!("message: {}", err))
.unwrap_or("".to_string())
)
}
#[async_trait]
pub trait EventLoop {
type Event;
type Error;
async fn poll(&mut self) -> Result<Self::Event>;
async fn verify_connection(&mut self) -> Result<()>;
}
#[async_trait]
impl EventLoop for V5EventLoop {
type Event = V5Event;
type Error = rumqttc::v5::ConnectionError;
async fn poll(&mut self) -> Result<Self::Event> {
self.poll().await.map_err(|err| to_anyhow(err).into())
}
async fn verify_connection(&mut self) -> Result<()> {
let start = std::time::Instant::now();
while start.elapsed() < CONNECTION_TIMEOUT {
match self.poll().await.map_err(to_anyhow)? {
Self::Event::Incoming(V5Incoming::ConnAck(_)) => return Ok(()),
Self::Event::Incoming(V5Incoming::Disconnect(disconnect)) => {
return Err(Error::BadConfig(convert_disconnect_packet_into_string(
disconnect,
)));
}
_ => continue,
}
}
Err(Error::BadConfig(format!(
"Timeout occurred while trying to connect to mqtt broker after {} seconds",
TIMEOUT_DURATION
)))
}
}
#[async_trait]
impl EventLoop for V3EventLoop {
type Event = V3Event;
type Error = rumqttc::ConnectionError;
async fn poll(&mut self) -> Result<Self::Event> {
self.poll().await.map_err(|err| to_anyhow(err).into())
}
async fn verify_connection(&mut self) -> Result<()> {
let start = std::time::Instant::now();
while start.elapsed() < CONNECTION_TIMEOUT {
match self.poll().await.map_err(to_anyhow)? {
Self::Event::Incoming(rumqttc::Packet::ConnAck(_)) => return Ok(()),
_ => continue,
}
}
Err(Error::BadConfig(format!(
"Timeout occurred while trying to connect to mqtt broker after {} seconds",
TIMEOUT_DURATION
)))
}
}
async fn handle_event<T, E, H>(
db: &DB,
listener: &T,
listening_trigger: &ListeningTrigger<T::TriggerConfig>,
handler: H,
mut event_loop: E,
) -> ()
where
T: Listener,
H: MqttEvent,
E: EventLoop<Event = H::Event>,
E::Error: ToString,
<T as TriggerJobArgs>::Payload: From<Bytes>,
{
loop {
let event = event_loop.poll().await;
match event {
Ok(event) => {
let publish_data = handler.handle_event(event);
if let Ok(Some((payload, publish_data))) = publish_data {
let trigger_info = HashMap::from([
("topic".to_string(), to_raw_value(&publish_data.topic)),
("retain".to_string(), to_raw_value(&publish_data.retain)),
("pkid".to_string(), to_raw_value(&publish_data.pkid)),
("qos".to_string(), to_raw_value(&publish_data.qos)),
(
"v5".to_string(),
to_raw_value(&publish_data.v5.map(|properties| {
serde_json::json!({
"payload_format_indicator": properties.payload_format_indicator,
"topic_alias": properties.topic_alias,
"response_topic": properties.response_topic,
"correlation_data": properties.correlation_data.as_deref(),
"user_properties": properties.user_properties,
"subscription_identifiers": properties.subscription_identifiers,
"content_type": properties.content_type,
})
})),
),
]);
let _ = listener
.handle_event(db, listening_trigger, payload.into(), trigger_info, None)
.await;
}
}
Err(err) => {
let error = err.to_string();
tracing::debug!("Error: {}", &err);
listener
.disable_with_error(db, listening_trigger, error)
.await;
return;
}
}
}
}
#[derive(Clone)]
#[allow(unused)]
pub struct PublishData {
topic: String,
retain: bool,
pkid: u16,
v5: Option<PublishProperties>,
qos: u8,
}
impl PublishData {
pub fn new(
topic: String,
retain: bool,
pkid: u16,
v5: Option<PublishProperties>,
qos: u8,
) -> PublishData {
PublishData { topic, retain, pkid, v5, qos }
}
}
trait MqttEvent {
type IncomingPacket;
type PublishPacket;
type Event;
fn handle_publish_packet(publish_packet: Self::PublishPacket) -> PublishData;
fn handle_event(&self, event: Self::Event) -> Result<Option<(Bytes, PublishData)>>;
}
impl MqttEvent for V5MqttHandler {
type IncomingPacket = V5Incoming;
type PublishPacket = rumqttc::v5::mqttbytes::v5::Publish;
type Event = V5Event;
fn handle_publish_packet(publish_packet: Self::PublishPacket) -> PublishData {
PublishData::new(
String::from_utf8(publish_packet.topic.as_ref().to_vec()).unwrap_or("".to_string()),
publish_packet.retain,
publish_packet.pkid,
publish_packet.properties,
publish_packet.qos as u8,
)
}
fn handle_event(&self, event: Self::Event) -> Result<Option<(Bytes, PublishData)>> {
tracing::debug!("Inside V5 event");
match event {
Self::Event::Incoming(packet) => match packet {
Self::IncomingPacket::Publish(publish_packet) => {
return Ok(Some((
publish_packet.payload.clone(),
Self::handle_publish_packet(publish_packet),
)))
}
Self::IncomingPacket::Disconnect(disconnect) => {
return Err(
anyhow::anyhow!(convert_disconnect_packet_into_string(disconnect)).into(),
);
}
packet => {
tracing::debug!("Received = {:#?}", packet);
}
},
Self::Event::Outgoing(packet) => {
tracing::debug!("Outgoing Received = {:#?}", packet);
}
}
Ok(None)
}
}
impl MqttEvent for V3MqttHandler {
type IncomingPacket = V3Incoming;
type PublishPacket = rumqttc::mqttbytes::v4::Publish;
type Event = V3Event;
fn handle_publish_packet(publish_packet: Self::PublishPacket) -> PublishData {
PublishData::new(
publish_packet.topic,
publish_packet.retain,
publish_packet.pkid,
None,
publish_packet.qos as u8,
)
}
fn handle_event(&self, event: Self::Event) -> Result<Option<(Bytes, PublishData)>> {
tracing::debug!("Inside V3 event");
match event {
Self::Event::Incoming(packet) => match packet {
Self::IncomingPacket::Publish(publish_packet) => {
return Ok(Some((
publish_packet.payload.clone(),
Self::handle_publish_packet(publish_packet),
)))
}
packet => {
tracing::debug!("Received = {:?}", packet);
}
},
Self::Event::Outgoing(packet) => {
tracing::debug!("Outgoing Received = {:?}", packet);
}
}
Ok(None)
}
}
@@ -1,346 +0,0 @@
use base64::{engine, prelude::*};
use itertools::Itertools;
use rumqttc::{
v5::{
mqttbytes::{
v5::{ConnectProperties, Filter},
QoS as V5QoS,
},
AsyncClient as V5AsyncClient, EventLoop as V5EventLoop, MqttOptions as V5MqttOptions,
},
AsyncClient as V3AsyncClient, EventLoop as V3EventLoop, MqttOptions as V3MqttOptions,
QoS as V3QoS, SubscribeFilter, TlsConfiguration, Transport,
};
use serde::{Deserialize, Serialize};
use serde_json::value::RawValue;
use sqlx::{types::Json as SqlxJson, FromRow, Type};
use std::{collections::HashMap, time::Duration};
use windmill_common::{
error::{to_anyhow, Error},
triggers::TriggerKind,
worker::to_raw_value,
};
use crate::triggers::{mqtt::listener::EventLoop, trigger_helpers::TriggerJobArgs};
pub mod handler;
pub mod listener;
#[derive(Clone, Copy)]
pub struct MqttTrigger;
impl TriggerJobArgs for MqttTrigger {
type Payload = Vec<u8>;
const TRIGGER_KIND: TriggerKind = TriggerKind::Mqtt;
fn v1_payload_fn(payload: &Self::Payload) -> HashMap<String, Box<RawValue>> {
HashMap::from([("payload".to_string(), to_raw_value(&payload))])
}
fn v2_payload_fn(payload: &Self::Payload) -> HashMap<String, Box<RawValue>> {
let base64_payload = engine::general_purpose::STANDARD.encode(payload);
HashMap::from([("payload".to_string(), to_raw_value(&base64_payload))])
}
}
#[derive(Clone, Debug, Deserialize, Serialize, Type)]
#[serde(rename_all = "lowercase")]
pub enum QualityOfService {
Qos0,
Qos1,
Qos2,
}
impl From<QualityOfService> for V3QoS {
fn from(value: QualityOfService) -> Self {
match value {
QualityOfService::Qos0 => V3QoS::AtMostOnce,
QualityOfService::Qos1 => V3QoS::AtLeastOnce,
QualityOfService::Qos2 => V3QoS::ExactlyOnce,
}
}
}
impl From<QualityOfService> for V5QoS {
fn from(value: QualityOfService) -> Self {
match value {
QualityOfService::Qos0 => V5QoS::AtMostOnce,
QualityOfService::Qos1 => V5QoS::AtLeastOnce,
QualityOfService::Qos2 => V5QoS::ExactlyOnce,
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct MqttV3Config {
clean_session: Option<bool>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct MqttV5Config {
clean_start: Option<bool>,
session_expiry_interval: Option<u32>,
topic_alias_maximum: Option<u16>,
}
#[derive(Clone, Debug, Deserialize, Serialize, Type)]
#[sqlx(type_name = "MQTT_CLIENT_VERSION")]
#[sqlx(rename_all = "lowercase")]
#[serde(rename_all = "lowercase")]
pub enum MqttClientVersion {
V3,
V5,
}
#[derive(Debug, Deserialize)]
pub struct Tls {
enabled: bool,
ca_certificate: String,
pkcs12_client_certificate: Option<String>,
pkcs12_certificate_password: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct Credentials {
username: Option<String>,
password: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct MqttResource {
broker: String,
port: u16,
credentials: Option<Credentials>,
tls: Option<Tls>,
}
#[derive(Clone, Debug, FromRow, Serialize, Deserialize)]
pub struct SubscribeTopic {
qos: QualityOfService,
topic: String,
}
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
pub struct MqttConfig {
pub mqtt_resource_path: String,
pub subscribe_topics: Vec<SqlxJson<SubscribeTopic>>,
pub v3_config: Option<SqlxJson<MqttV3Config>>,
pub v5_config: Option<SqlxJson<MqttV5Config>>,
pub client_id: Option<String>,
pub client_version: Option<MqttClientVersion>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MqttConfigRequest {
pub mqtt_resource_path: String,
pub subscribe_topics: Vec<SubscribeTopic>,
pub v3_config: Option<MqttV3Config>,
pub v5_config: Option<MqttV5Config>,
pub client_id: Option<String>,
pub client_version: Option<MqttClientVersion>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestMqttConfig {
pub mqtt_resource_path: String,
pub client_version: Option<MqttClientVersion>,
pub v3_config: Option<MqttV3Config>,
pub v5_config: Option<MqttV5Config>,
}
// Constants
pub const KEEP_ALIVE: u64 = 60;
pub const CLIENT_CONNECTION_TIMEOUT: u64 = 60;
pub const TOPIC_ALIAS_MAXIMUM: u16 = 65535;
pub const TIMEOUT_DURATION: u64 = 10;
pub const CONNECTION_TIMEOUT: Duration = Duration::from_secs(TIMEOUT_DURATION);
pub struct V3MqttHandler;
pub struct V5MqttHandler;
pub enum MqttClientResult {
V3((V3MqttHandler, V3EventLoop)),
V5((V5MqttHandler, V5EventLoop)),
}
#[derive(Debug, thiserror::Error)]
pub enum MqttError {
#[error("{0}")]
Common(#[from] Error),
#[error("{0}")]
V5RumqttClient(#[from] rumqttc::v5::ClientError),
#[error("{0}")]
V5ConnectionError(#[from] rumqttc::v5::ConnectionError),
#[error("{0}")]
V3RumqttClient(#[from] rumqttc::ClientError),
#[error("{0}")]
V3ConnectionError(#[from] rumqttc::ConnectionError),
#[error("{0}")]
Base64Decode(#[from] base64::DecodeError),
}
pub struct MqttClientBuilder<'client> {
mqtt_resource: MqttResource,
client_id: &'client str,
subscribe_topics: Vec<SubscribeTopic>,
v3_config: Option<&'client MqttV3Config>,
v5_config: Option<&'client MqttV5Config>,
mqtt_client_version: Option<&'client MqttClientVersion>,
}
impl<'client> MqttClientBuilder<'client> {
pub fn new(
mqtt_resource: MqttResource,
client_id: Option<&'client str>,
subscribe_topics: Vec<SubscribeTopic>,
v3_config: Option<&'client MqttV3Config>,
v5_config: Option<&'client MqttV5Config>,
mqtt_client_version: Option<&'client MqttClientVersion>,
) -> Self {
Self {
mqtt_resource,
client_id: client_id.unwrap_or(""),
subscribe_topics,
v3_config,
v5_config,
mqtt_client_version,
}
}
pub async fn build_client(&self) -> Result<MqttClientResult, Error> {
match self.mqtt_client_version {
Some(MqttClientVersion::V5) | None => self.build_v5_client().await,
Some(MqttClientVersion::V3) => self.build_v3_client().await,
}
}
fn get_tls_configuration(&self) -> Result<Option<Transport>, Error> {
let transport = match self.mqtt_resource.tls {
Some(ref tls) if tls.enabled => {
let transport = match tls.ca_certificate.trim().is_empty() {
true => rumqttc::Transport::Tls(TlsConfiguration::Native),
false => rumqttc::Transport::Tls(TlsConfiguration::SimpleNative {
ca: tls.ca_certificate.as_bytes().to_vec(),
client_auth: {
match tls.pkcs12_client_certificate.as_ref() {
Some(client_certificate)
if !client_certificate.trim().is_empty() =>
{
let client_certificate = BASE64_STANDARD
.decode(client_certificate)
.map_err(to_anyhow)?;
let password = tls
.pkcs12_certificate_password
.clone()
.unwrap_or("".to_string());
Some((client_certificate, password))
}
_ => None,
}
},
}),
};
Some(transport)
}
_ => None,
};
Ok(transport)
}
async fn build_v5_client(&self) -> Result<MqttClientResult, Error> {
let mut mqtt_options = V5MqttOptions::new(
self.client_id,
&self.mqtt_resource.broker,
self.mqtt_resource.port,
);
if let Some(credentials) = &self.mqtt_resource.credentials {
let username = credentials.username.as_deref().unwrap_or("");
let password = credentials.password.as_deref().unwrap_or("");
mqtt_options.set_credentials(username, password);
}
if let Some(transport) = self.get_tls_configuration()? {
mqtt_options.set_transport(transport);
}
mqtt_options.set_connection_timeout(CLIENT_CONNECTION_TIMEOUT);
mqtt_options.set_keep_alive(Duration::from_secs(KEEP_ALIVE));
if let Some(v5_config) = self.v5_config {
mqtt_options.set_clean_start(v5_config.clean_start.unwrap_or(true));
mqtt_options.set_connect_properties(ConnectProperties {
session_expiry_interval: v5_config.session_expiry_interval,
receive_maximum: None,
max_packet_size: None,
topic_alias_max: v5_config.topic_alias_maximum.or(Some(TOPIC_ALIAS_MAXIMUM)),
request_response_info: None,
request_problem_info: None,
user_properties: vec![],
authentication_method: None,
authentication_data: None,
});
}
let (async_client, mut event_loop) =
V5AsyncClient::new(mqtt_options, self.subscribe_topics.len());
event_loop.verify_connection().await?;
if !self.subscribe_topics.is_empty() {
let subscribe_filters = self
.subscribe_topics
.iter()
.map(|topic| Filter::new(topic.topic.clone(), topic.qos.clone().into()))
.collect_vec();
async_client
.subscribe_many(subscribe_filters)
.await
.map_err(to_anyhow)?;
}
Ok(MqttClientResult::V5((V5MqttHandler, event_loop)))
}
async fn build_v3_client(&self) -> Result<MqttClientResult, Error> {
let mut mqtt_options = V3MqttOptions::new(
self.client_id,
&self.mqtt_resource.broker,
self.mqtt_resource.port,
);
if let Some(credentials) = &self.mqtt_resource.credentials {
let username = credentials.username.as_deref().unwrap_or("");
let password = credentials.password.as_deref().unwrap_or("");
mqtt_options.set_credentials(username, password);
}
if let Some(transport) = self.get_tls_configuration()? {
mqtt_options.set_transport(transport);
}
mqtt_options.set_keep_alive(Duration::from_secs(KEEP_ALIVE));
if let Some(v3_config) = self.v3_config {
mqtt_options.set_clean_session(v3_config.clean_session.unwrap_or(true));
}
let (async_client, mut event_loop) =
V3AsyncClient::new(mqtt_options, self.subscribe_topics.len());
event_loop.verify_connection().await?;
if !self.subscribe_topics.is_empty() {
let subscribe_filters = self
.subscribe_topics
.iter()
.map(|topic| SubscribeFilter::new(topic.topic.clone(), topic.qos.clone().into()))
.collect_vec();
async_client
.subscribe_many(subscribe_filters)
.await
.map_err(to_anyhow)?;
}
Ok(MqttClientResult::V3((V3MqttHandler, event_loop)))
}
}
@@ -1,64 +0,0 @@
#[allow(unused)]
#[cfg(feature = "private")]
pub use super::handler_ee::*;
#[cfg(not(feature = "private"))]
use {
super::NatsTrigger,
crate::{
db::{ApiAuthed, DB},
triggers::{TriggerCrud, TriggerData},
},
axum::async_trait,
sqlx::PgConnection,
windmill_common::error::{Error, Result},
windmill_git_sync::DeployedObject,
};
#[cfg(not(feature = "private"))]
#[async_trait]
impl TriggerCrud for NatsTrigger {
type Trigger = ();
type TriggerConfig = ();
type TriggerConfigRequest = ();
type TestConnectionConfig = ();
const TABLE_NAME: &'static str = "";
const TRIGGER_TYPE: &'static str = "";
const SUPPORTS_SERVER_STATE: bool = false;
const SUPPORTS_TEST_CONNECTION: bool = false;
const ROUTE_PREFIX: &'static str = "/nats_triggers";
const DEPLOYMENT_NAME: &'static str = "";
const IS_ALLOWED_ON_CLOUD: bool = false;
fn get_deployed_object(path: String) -> DeployedObject {
DeployedObject::NatsTrigger { path }
}
async fn create_trigger(
&self,
_db: &DB,
_executor: &mut PgConnection,
_authed: &ApiAuthed,
_w_id: &str,
_trigger: TriggerData<Self::TriggerConfigRequest>,
) -> Result<()> {
Err(Error::BadRequest(
"NATS triggers are not available in open source version".to_string(),
))
}
async fn update_trigger(
&self,
_db: &DB,
_executor: &mut PgConnection,
_authed: &ApiAuthed,
_workspace_id: &str,
_path: &str,
_trigger: TriggerData<Self::TriggerConfigRequest>,
) -> Result<()> {
Err(Error::BadRequest(
"NATS triggers are not available in open source version".to_string(),
))
}
}
@@ -1,41 +0,0 @@
#[allow(unused)]
#[cfg(feature = "private")]
pub use super::listener_ee::*;
#[cfg(not(feature = "private"))]
use {
super::NatsTrigger,
crate::triggers::{listener::ListeningTrigger, Listener},
std::sync::Arc,
tokio::sync::RwLock,
windmill_common::{error::Result, jobs::JobTriggerKind, DB},
};
#[cfg(not(feature = "private"))]
#[async_trait::async_trait]
impl Listener for NatsTrigger {
type Consumer = ();
type Extra = ();
const JOB_TRIGGER_KIND: JobTriggerKind = JobTriggerKind::Nats;
async fn get_consumer(
&self,
_db: &DB,
_listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
_err_message: Arc<RwLock<Option<String>>>,
_killpill_rx: tokio::sync::broadcast::Receiver<()>,
) -> Result<Option<Self::Consumer>> {
Ok(None)
}
async fn consume(
&self,
_db: &DB,
_consumer: Self::Consumer,
_listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
_err_message: Arc<RwLock<Option<String>>>,
_killpill_rx: tokio::sync::broadcast::Receiver<()>,
) {
()
}
}
@@ -1,15 +0,0 @@
#[cfg(feature = "private")]
mod handler_ee;
pub mod handler_oss;
#[cfg(feature = "private")]
mod listener_ee;
pub mod listener_oss;
#[cfg(feature = "private")]
mod mod_ee;
#[cfg(feature = "private")]
pub use mod_ee::*;
#[derive(Copy, Clone)]
pub struct NatsTrigger;
@@ -1,24 +0,0 @@
use thiserror::Error;
/**
* This implementation is inspired by Postgres replication functionality
* from https://github.com/supabase/pg_replicate
*
* Original implementation:
* - https://github.dev/supabase/pg_replicate/blob/main/pg_replicate/src/conversions/bool.rs
*
*/
#[derive(Debug, Error)]
pub enum ParseBoolError {
#[error("invalid input value: {0}")]
InvalidInput(String),
}
pub fn parse_bool(s: &str) -> Result<bool, ParseBoolError> {
match s {
"t" => Ok(true),
"f" => Ok(false),
_ => Err(ParseBoolError::InvalidInput(s.to_string())),
}
}
@@ -1,255 +0,0 @@
use core::str;
use std::{
num::{ParseFloatError, ParseIntError},
str::FromStr,
};
use super::{
bool::{parse_bool, ParseBoolError},
hex::{from_bytea_hex, ByteaHexParseError},
};
use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, Utc};
use rust_decimal::Decimal;
use rust_postgres::types::Type;
use serde_json::{to_value, Number, Value};
use thiserror::Error;
use uuid::Uuid;
/**
* This implementation is inspired by Postgres replication functionality
* from https://github.com/supabase/pg_replicate
*
* Original implementation:
* - https://github.com/supabase/pg_replicate/blob/main/pg_replicate/src/conversions/text.rs
*
*/
#[derive(Debug, Error)]
pub enum ConverterError {
#[error("invalid bool value")]
InvalidBool(#[from] ParseBoolError),
#[error("invalid int value")]
InvalidInt(#[from] ParseIntError),
#[error("invalid float value")]
InvalidFloat(#[from] ParseFloatError),
#[error("invalid numeric: {0}")]
InvalidNumeric(#[from] rust_decimal::Error),
#[error("invalid bytea: {0}")]
InvalidBytea(#[from] ByteaHexParseError),
#[error("invalid uuid: {0}")]
InvalidUuid(#[from] uuid::Error),
#[error("invalid json: {0}")]
InvalidJson(#[from] serde_json::Error),
#[error("invalid timestamp: {0} ")]
InvalidTimestamp(#[from] chrono::ParseError),
#[error("invalid array: {0}")]
InvalidArray(#[from] ArrayParseError),
#[error("{0}")]
Custom(String),
}
fn convert_into<T>(number: T) -> Number
where
T: Sized,
serde_json::Number: From<T>,
{
serde_json::Number::from(number)
}
pub struct Converter;
#[derive(Debug, Error)]
pub enum ArrayParseError {
#[error("input too short")]
InputTooShort,
#[error("missing braces")]
MissingBraces,
}
fn f64_to_json_number(raw_val: f64) -> Result<Value, ConverterError> {
let temp = serde_json::Number::from_f64(raw_val.into())
.ok_or(ConverterError::Custom("invalid json-float".to_string()))?;
Ok(Value::Number(temp))
}
impl Converter {
pub fn try_from_str(typ: Option<Type>, str: &str) -> Result<Value, ConverterError> {
let value = match typ.unwrap_or(Type::TEXT) {
Type::BOOL => Value::Bool(parse_bool(str)?),
Type::BOOL_ARRAY => {
Converter::parse_array(str, |str| Ok(Value::Bool(parse_bool(str)?)))?
}
Type::CHAR | Type::BPCHAR | Type::VARCHAR | Type::NAME | Type::TEXT => {
Value::String(str.to_string())
}
Type::CHAR_ARRAY
| Type::BPCHAR_ARRAY
| Type::VARCHAR_ARRAY
| Type::NAME_ARRAY
| Type::TEXT_ARRAY => {
Converter::parse_array(str, |str| Ok(Value::String(str.to_string())))?
}
Type::INT2 => Value::Number(convert_into(str.parse::<i16>()?)),
Type::INT2_ARRAY => Converter::parse_array(str, |str| {
Ok(Value::Number(convert_into(str.parse::<i16>()?)))
})?,
Type::INT4 => Value::Number(convert_into(str.parse::<i32>()?)),
Type::INT4_ARRAY => Converter::parse_array(str, |str| {
Ok(Value::Number(convert_into(str.parse::<i32>()?)))
})?,
Type::INT8 => Value::Number(convert_into(str.parse::<i64>()?)),
Type::INT8_ARRAY => Converter::parse_array(str, |str| {
Ok(Value::Number(convert_into(str.parse::<i64>()?)))
})?,
Type::FLOAT4 => f64_to_json_number(str.parse::<f64>()?)?,
Type::FLOAT4_ARRAY => {
Converter::parse_array(str, |str| f64_to_json_number(str.parse::<f64>()?))?
}
Type::FLOAT8 => f64_to_json_number(str.parse::<f64>()?)?,
Type::FLOAT8_ARRAY => {
Converter::parse_array(str, |str| f64_to_json_number(str.parse::<f64>()?))?
}
Type::NUMERIC => serde_json::json!(Decimal::from_str(str)?),
Type::NUMERIC_ARRAY => {
Converter::parse_array(str, |str| Ok(serde_json::json!(Decimal::from_str(str)?)))?
}
Type::BYTEA => to_value(from_bytea_hex(str)?).unwrap(),
Type::BYTEA_ARRAY => {
Converter::parse_array(str, |str| Ok(to_value(from_bytea_hex(str)?).unwrap()))?
}
Type::DATE => {
let date = NaiveDate::parse_from_str(str, "%Y-%m-%d")?;
Value::String(date.to_string())
}
Type::DATE_ARRAY => Converter::parse_array(str, |str| {
let date = NaiveDate::parse_from_str(str, "%Y-%m-%d")?;
Ok(Value::String(date.to_string()))
})?,
Type::TIME => {
let time = NaiveTime::parse_from_str(str, "%H:%M:%S%.f")?;
Value::String(time.to_string())
}
Type::TIME_ARRAY => Converter::parse_array(str, |str| {
let time = NaiveTime::parse_from_str(str, "%H:%M:%S%.f")?;
Ok(Value::String(time.to_string()))
})?,
Type::TIMESTAMP => {
let timestamp = NaiveDateTime::parse_from_str(str, "%Y-%m-%d %H:%M:%S%.f")?;
Value::String(timestamp.to_string())
}
Type::TIMESTAMP_ARRAY => Converter::parse_array(str, |str| {
let timestamp = NaiveDateTime::parse_from_str(str, "%Y-%m-%d %H:%M:%S%.f")?;
Ok(Value::String(timestamp.to_string()))
})?,
Type::TIMESTAMPTZ => {
let val =
match DateTime::<FixedOffset>::parse_from_str(str, "%Y-%m-%d %H:%M:%S%.f%#z") {
Ok(val) => val,
Err(_) => {
DateTime::<FixedOffset>::parse_from_str(str, "%Y-%m-%d %H:%M:%S%.f%:z")?
}
};
let utc: DateTime<Utc> = val.into();
Value::String(utc.to_string())
}
Type::TIMESTAMPTZ_ARRAY => {
match Converter::parse_array(str, |str| {
let utc: DateTime<Utc> =
DateTime::<FixedOffset>::parse_from_str(str, "%Y-%m-%d %H:%M:%S%.f%#z")?
.into();
Ok(Value::String(utc.to_string()))
}) {
Ok(val) => val,
Err(_) => Converter::parse_array(str, |str| {
let utc: DateTime<Utc> = DateTime::<FixedOffset>::parse_from_str(
str,
"%Y-%m-%d %H:%M:%S%.f%#z",
)?
.into();
Ok(Value::String(utc.to_string()))
})?,
}
}
Type::UUID => Value::String(Uuid::parse_str(str)?.to_string()),
Type::UUID_ARRAY => Converter::parse_array(str, |str| {
Ok(Value::String(Uuid::parse_str(str)?.to_string()))
})?,
Type::JSON | Type::JSONB => serde_json::from_str::<serde_json::Value>(str)?,
Type::JSON_ARRAY | Type::JSONB_ARRAY => Converter::parse_array(str, |str| {
Ok(serde_json::from_str::<serde_json::Value>(str)?)
})?,
Type::OID => Value::Number(convert_into(str.parse::<u32>()?)),
Type::OID_ARRAY => Converter::parse_array(str, |str| {
Ok(Value::Number(convert_into(str.parse::<u32>()?)))
})?,
_ => Value::String(str.to_string()),
};
Ok(value)
}
fn parse_array<P>(str: &str, mut parse: P) -> Result<Value, ConverterError>
where
P: FnMut(&str) -> Result<Value, ConverterError>,
{
if str.len() < 2 {
return Err(ArrayParseError::InputTooShort.into());
}
if !str.starts_with('{') || !str.ends_with('}') {
return Err(ArrayParseError::MissingBraces.into());
}
let mut res = vec![];
let str = &str[1..(str.len() - 1)];
let mut val_str = String::with_capacity(10);
let mut in_quotes = false;
let mut in_escape = false;
let mut chars = str.chars();
let mut done = str.is_empty();
while !done {
loop {
match chars.next() {
Some(c) => match c {
c if in_escape => {
val_str.push(c);
in_escape = false;
}
'"' => in_quotes = !in_quotes,
'\\' => in_escape = true,
',' if !in_quotes => {
break;
}
c => {
val_str.push(c);
}
},
None => {
done = true;
break;
}
}
}
let val = if val_str.to_lowercase() == "null" {
Value::Null
} else {
parse(&val_str)?
};
res.push(val);
val_str.clear();
}
let arr = Value::Array(res);
Ok(arr)
}
}
File diff suppressed because it is too large Load Diff
@@ -1,45 +0,0 @@
use std::num::ParseIntError;
/**
* This implementation is inspired by Postgres replication functionality
* from https://github.com/supabase/pg_replicate
*
* Original implementation:
* - https://github.dev/supabase/pg_replicate/blob/main/pg_replicate/src/conversions/hex.rs
*
*/
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ByteaHexParseError {
#[error("missing prefix '\\x'")]
InvalidPrefix,
#[error("invalid byte")]
OddNumerOfDigits,
#[error("parse int result: {0}")]
ParseInt(#[from] ParseIntError),
}
pub fn from_bytea_hex(s: &str) -> Result<Vec<u8>, ByteaHexParseError> {
if s.len() < 2 || &s[..2] != "\\x" {
return Err(ByteaHexParseError::InvalidPrefix);
}
let mut result = Vec::with_capacity((s.len() - 2) / 2);
let s = &s[2..];
if s.len() % 2 != 0 {
return Err(ByteaHexParseError::OddNumerOfDigits);
}
for i in (0..s.len()).step_by(2) {
let val = u8::from_str_radix(&s[i..i + 2], 16)?;
result.push(val);
}
Ok(result)
}
@@ -1,441 +0,0 @@
use std::{collections::HashMap, pin::Pin, sync::Arc};
use bytes::{BufMut, Bytes, BytesMut};
use chrono::TimeZone;
use futures::{pin_mut, SinkExt};
use pg_escape::{quote_identifier, quote_literal};
use rust_postgres::{Client, CopyBothDuplex, SimpleQueryMessage};
use tokio::sync::RwLock;
use tokio_stream::StreamExt;
use windmill_common::{
db::UserDB,
error::{to_anyhow, Error, Result},
jobs::JobTriggerKind,
worker::to_raw_value,
DB,
};
use crate::{
resources::try_get_resource_from_db_as,
triggers::{
listener::ListeningTrigger,
postgres::{
drop_publication, get_default_pg_connection, get_raw_postgres_connection,
handler::drop_logical_replication_slot,
relation::RelationConverter,
replication_message::{
LogicalReplicationMessage::{
Begin, Commit, Delete, Insert, Relation, Type, Update,
},
PrimaryKeepAliveBody, ReplicationMessage,
},
Postgres, PostgresConfig, PostgresTrigger, ERROR_PUBLICATION_NAME_NOT_EXISTS,
},
trigger_helpers::TriggerJobArgs,
Listener,
},
};
const ERROR_REPLICATION_SLOT_NOT_EXISTS: &str = r#"The replication slot associated with this trigger no longer exists. Recreate a new replication slot or select an existing one in the advanced tab, or delete and recreate a new trigger"#;
pub struct LogicalReplicationSettings {
pub streaming: bool,
}
impl LogicalReplicationSettings {
pub fn new(streaming: bool) -> Self {
Self { streaming }
}
}
pub struct PostgresSimpleClient(Client);
trait RowExist {
fn row_exist(&self) -> bool;
}
impl RowExist for Vec<SimpleQueryMessage> {
fn row_exist(&self) -> bool {
self.iter()
.find_map(|element| {
if let SimpleQueryMessage::CommandComplete(value) = element {
Some(*value)
} else {
None
}
})
.is_some_and(|value| value > 0)
}
}
impl PostgresSimpleClient {
async fn new(database: &Postgres) -> Result<Self> {
let client = get_raw_postgres_connection(database, true).await?;
Ok(PostgresSimpleClient(client))
}
async fn execute_query(
&self,
query: &str,
) -> std::result::Result<Vec<SimpleQueryMessage>, rust_postgres::Error> {
self.0.simple_query(query).await
}
async fn get_logical_replication_stream(
&self,
publication_name: &str,
logical_replication_slot_name: &str,
) -> Result<(CopyBothDuplex<Bytes>, LogicalReplicationSettings)> {
let options = format!(
r#"("proto_version" '2', "publication_names" {})"#,
quote_literal(publication_name),
);
let query = format!(
r#"START_REPLICATION SLOT {} LOGICAL 0/0 {}"#,
quote_identifier(logical_replication_slot_name),
options
);
Ok((
self.0
.copy_both_simple::<bytes::Bytes>(query.as_str())
.await
.map_err(to_anyhow)?,
LogicalReplicationSettings::new(false),
))
}
async fn send_status_update(
primary_keep_alive: PrimaryKeepAliveBody,
copy_both_stream: &mut Pin<&mut CopyBothDuplex<Bytes>>,
) {
let mut buf = BytesMut::new();
let ts = chrono::Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap();
let ts = chrono::Utc::now()
.signed_duration_since(ts)
.num_microseconds()
.unwrap_or(0);
buf.put_u8(b'r');
buf.put_u64(primary_keep_alive.wal_end);
buf.put_u64(primary_keep_alive.wal_end);
buf.put_u64(primary_keep_alive.wal_end);
buf.put_i64(ts);
buf.put_u8(0);
copy_both_stream.send(buf.freeze()).await.unwrap();
tracing::debug!("Send update status message");
}
}
#[async_trait::async_trait]
impl Listener for PostgresTrigger {
type Consumer = (CopyBothDuplex<Bytes>, LogicalReplicationSettings);
type Extra = ();
type ExtraState = ();
const JOB_TRIGGER_KIND: JobTriggerKind = JobTriggerKind::Postgres;
async fn get_consumer(
&self,
db: &DB,
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
_err_message: Arc<RwLock<Option<String>>>,
_killpill_rx: tokio::sync::broadcast::Receiver<()>,
) -> Result<Option<Self::Consumer>> {
let ListeningTrigger::<Self::TriggerConfig> { workspace_id, trigger_config, .. } =
listening_trigger;
let PostgresConfig {
postgres_resource_path, publication_name, replication_slot_name, ..
} = trigger_config;
let authed = listening_trigger
.authed(db, &Self::TRIGGER_KIND.to_string())
.await?;
let database = try_get_resource_from_db_as::<Postgres>(
&authed,
Some(UserDB::new(db.clone())),
&db,
postgres_resource_path,
workspace_id,
)
.await?;
let client = PostgresSimpleClient::new(&database).await?;
let publication = client
.execute_query(&format!(
"SELECT pubname FROM pg_publication WHERE pubname = {}",
quote_literal(&publication_name)
))
.await
.map_err(to_anyhow)?;
if !publication.row_exist() {
return Err(Error::BadConfig(
ERROR_PUBLICATION_NAME_NOT_EXISTS.to_string(),
));
}
let replication_slot = client
.execute_query(&format!(
"SELECT slot_name FROM pg_replication_slots WHERE slot_name = {}",
quote_literal(&replication_slot_name)
))
.await
.map_err(to_anyhow)?;
if !replication_slot.row_exist() {
return Err(Error::BadConfig(
ERROR_REPLICATION_SLOT_NOT_EXISTS.to_string(),
));
}
let (logical_replication_stream, logical_replication_settings) = client
.get_logical_replication_stream(&publication_name, &replication_slot_name)
.await
.map_err(to_anyhow)?;
Ok(Some((
logical_replication_stream,
logical_replication_settings,
)))
}
async fn consume(
&self,
db: &DB,
consumer: Self::Consumer,
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
err_message: Arc<RwLock<Option<String>>>,
_killpill_rx: tokio::sync::broadcast::Receiver<()>,
_extra_state: Option<&Self::ExtraState>,
) {
let (logical_replication_stream, logical_replication_settings) = consumer;
pin_mut!(logical_replication_stream);
let mut relations = RelationConverter::new();
tracing::info!(
"Starting to listen for postgres trigger {}",
&listening_trigger.path
);
loop {
let message = logical_replication_stream.next().await;
let message = match message {
Some(message) => message,
None => {
tracing::error!(
"Stream for postgres trigger {} closed",
&listening_trigger.path
);
if let None = self
.update_ping_and_loop_ping_status(
db,
listening_trigger,
err_message.clone(),
Some("Stream closed".to_string()),
)
.await
{
return;
}
return;
}
};
let message = match message {
Ok(message) => message,
Err(err) => {
let err = format!(
"Postgres trigger named {} had an error while receiving a message : {}",
&listening_trigger.path,
err.to_string()
);
self.disable_with_error(db, listening_trigger, err).await;
return;
}
};
let logical_message = match ReplicationMessage::parse(message) {
Ok(logical_message) => logical_message,
Err(err) => {
let err = format!(
"Postgres trigger named: {} had an error while parsing message: {}",
&listening_trigger.path,
err.to_string()
);
self.disable_with_error(db, listening_trigger, err).await;
return;
}
};
match logical_message {
ReplicationMessage::PrimaryKeepAlive(primary_keep_alive) => {
if primary_keep_alive.reply {
PostgresSimpleClient::send_status_update(
primary_keep_alive,
&mut logical_replication_stream,
)
.await;
}
}
ReplicationMessage::XLogData(x_log_data) => {
let logical_replication_message = match x_log_data
.parse(&logical_replication_settings)
{
Ok(logical_replication_message) => logical_replication_message,
Err(err) => {
tracing::error!("Postgres trigger named: {} had an error while trying to parse incomming stream message: {}", &listening_trigger.path, err.to_string());
continue;
}
};
let json = match logical_replication_message {
Relation(relation_body) => {
relations.add_relation(relation_body);
None
}
Begin | Type | Commit => None,
Insert(insert) => Some((
insert.o_id,
Ok(None),
relations.row_to_json((insert.o_id, insert.tuple)),
"insert",
)),
Update(update) => {
let old_row = update
.old_tuple
.map(|old_tuple| relations.row_to_json((update.o_id, old_tuple)))
.transpose();
let row = relations.row_to_json((update.o_id, update.new_tuple));
Some((update.o_id, old_row, row, "update"))
}
Delete(delete) => {
let row = delete
.old_tuple
.unwrap_or_else(|| delete.key_tuple.unwrap());
Some((
delete.o_id,
Ok(None),
relations.row_to_json((delete.o_id, row)),
"delete",
))
}
};
match json {
Some((o_id, Ok(old_row), Ok(row), transaction_type)) => {
let relation = match relations.get_relation(o_id) {
Ok(relation) => relation,
Err(err) => {
tracing::error!(
"Postgres trigger named: {}, error: {}",
&listening_trigger.path,
err.to_string()
);
continue;
}
};
let database_info = HashMap::from([
("schema_name".to_string(), to_raw_value(&relation.namespace)),
("table_name".to_string(), to_raw_value(&relation.name)),
(
"transaction_type".to_string(),
to_raw_value(&transaction_type),
),
("old_row".to_string(), to_raw_value(&old_row)),
("row".to_string(), to_raw_value(&row)),
]);
let _ = self
.handle_event(
db,
listening_trigger,
database_info,
HashMap::new(),
None,
)
.await;
}
Some((o_id, old_row, row, transaction_type)) => {
let relation = match relations.get_relation(o_id) {
Ok(relation) => relation,
Err(err) => {
tracing::error!(
"Postgres trigger named: {}, error: {}",
&listening_trigger.path,
err.to_string()
);
continue;
}
};
if let Err(err) = old_row {
tracing::error!(
transaction_type = ?transaction_type,
schema = %relation.namespace,
table = %relation.name,
error = %err,
"Failed to decode OLD row for {} transaction on {}.{}",
transaction_type,
relation.namespace,
relation.name,
);
}
if let Err(err) = row {
tracing::error!(
transaction_type = ?transaction_type,
schema = %relation.namespace,
table = %relation.name,
error = %err,
"Failed to decode NEW row for {} transaction on {}.{}",
transaction_type,
relation.namespace,
relation.name,
);
}
}
_ => {}
}
}
}
}
}
async fn cleanup(
&self,
db: &DB,
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
_extra_state: Option<&Self::ExtraState>,
) -> Result<()> {
let authed = listening_trigger
.authed(db, &Self::TRIGGER_KIND.to_string())
.await?;
let user_db = UserDB::new(db.clone());
let mut pg_connection = get_default_pg_connection(
authed,
Some(user_db),
&db,
&listening_trigger.trigger_config.postgres_resource_path,
&listening_trigger.workspace_id,
)
.await?;
if listening_trigger.trigger_config.basic_mode.unwrap_or(false) {
drop_logical_replication_slot(
&mut pg_connection,
&listening_trigger.trigger_config.replication_slot_name,
)
.await?;
drop_publication(
&mut pg_connection,
&listening_trigger.trigger_config.publication_name,
)
.await?;
}
Ok(())
}
}
@@ -1,136 +0,0 @@
use std::collections::HashMap;
use rust_postgres::types::Type;
use super::Language;
fn postgres_to_typescript_type(postgres_type: Option<Type>) -> String {
let data_type = match postgres_type {
Some(postgres_type) => match postgres_type {
Type::BOOL => "boolean",
Type::BOOL_ARRAY => "Array<boolean>",
Type::CHAR | Type::BPCHAR | Type::VARCHAR | Type::NAME | Type::TEXT => "string",
Type::CHAR_ARRAY
| Type::BPCHAR_ARRAY
| Type::VARCHAR_ARRAY
| Type::NAME_ARRAY
| Type::TEXT_ARRAY => "Array<string>",
Type::INT2 | Type::INT4 | Type::INT8 | Type::NUMERIC => "number",
Type::INT2_ARRAY | Type::INT4_ARRAY | Type::INT8_ARRAY => "Array<number>",
Type::FLOAT4 | Type::FLOAT8 => "number",
Type::FLOAT8_ARRAY | Type::FLOAT4_ARRAY => "Array<number>",
Type::NUMERIC_ARRAY => "Array<number>",
Type::BYTEA => "Array<number>",
Type::BYTEA_ARRAY => "Array<Array<number>>",
Type::DATE => "string",
Type::DATE_ARRAY => "Array<string>",
Type::TIME => "string",
Type::TIME_ARRAY => "Array<string>",
Type::TIMESTAMPTZ | Type::TIMESTAMP => "string",
Type::TIMESTAMPTZ_ARRAY | Type::TIMESTAMP_ARRAY => "Array<string>",
Type::UUID => "string",
Type::UUID_ARRAY => "Array<string>",
Type::JSON | Type::JSONB | Type::JSON_ARRAY | Type::JSONB_ARRAY => "unknown",
Type::OID => "number",
Type::OID_ARRAY => "Array<number>",
_ => "string",
},
None => "string",
};
data_type.to_string()
}
fn into_body_struct(language: Language, mapped_info: Vec<MappingInfo>) -> String {
let mut block = String::new();
match language {
Language::Typescript => {
block.push_str("{\r\n");
for field in mapped_info {
let typescript_type = postgres_to_typescript_type(field.data_type);
let mut key = field.column_name;
if field.is_nullable {
key.push('?');
}
let full_field = format!("\t\t{}: {},\r\n", key, typescript_type);
block.push_str(&full_field);
}
block.push_str("\t}");
}
}
block
}
#[derive(Debug)]
pub struct MappingInfo {
data_type: Option<Type>,
is_nullable: bool,
column_name: String,
}
impl MappingInfo {
pub fn new(column_name: String, data_type: Option<Type>, is_nullable: bool) -> Self {
Self { column_name, data_type, is_nullable }
}
}
pub struct Mapper {
to_template: HashMap<String, HashMap<String, Vec<MappingInfo>>>,
language: Language,
}
impl Mapper {
pub fn new(
to_template: HashMap<String, HashMap<String, Vec<MappingInfo>>>,
language: Language,
) -> Self {
Self { to_template, language }
}
fn into_typescript_template(self) -> Vec<String> {
let mut struct_definitions = Vec::new();
for (_, mapping_info) in self.to_template {
let last_elem = mapping_info.len() - 1;
for (i, (_, mapped_info)) in mapping_info.into_iter().enumerate() {
let mut struct_body = into_body_struct(Language::Typescript, mapped_info);
let struct_body = if i != last_elem {
struct_body.push_str("\r\n");
struct_body
} else {
struct_body
};
struct_definitions.push(struct_body);
}
}
struct_definitions
}
pub fn get_template(self) -> String {
let struct_definition = match self.language {
Language::Typescript => self.into_typescript_template(),
};
let struct_definition = if struct_definition.is_empty() {
"any".to_string()
} else {
struct_definition.join("\t| ")
};
format!(
r#"
export async function main(
transaction_type: "insert" | "update" | "delete",
schema_name: string,
table_name: string,
row: {},
old_row?: {}
) {{
}}
"#,
&struct_definition,
&struct_definition
)
}
}
@@ -1,553 +0,0 @@
use std::collections::HashMap;
use crate::{
db::{ApiAuthed, DB},
resources::try_get_resource_from_db_as,
triggers::trigger_helpers::TriggerJobArgs,
};
use chrono::Utc;
use itertools::Itertools;
use native_tls::{Certificate, TlsConnector};
use pg_escape::quote_identifier;
use rand::Rng;
use rust_postgres::{config::SslMode, Client, Config, NoTls};
use rust_postgres_native_tls::MakeTlsConnector;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::value::RawValue;
use sqlx::FromRow;
use windmill_common::{
db::UserDB,
error::{to_anyhow, Error, Result},
triggers::TriggerKind,
utils::empty_as_none,
};
mod bool;
mod converter;
pub mod handler;
mod hex;
pub mod listener;
mod mapper;
mod relation;
mod replication_message;
#[derive(Clone, Copy)]
pub struct PostgresTrigger;
impl TriggerJobArgs for PostgresTrigger {
type Payload = HashMap<String, Box<RawValue>>;
const TRIGGER_KIND: TriggerKind = TriggerKind::Postgres;
fn v1_payload_fn(payload: &HashMap<String, Box<RawValue>>) -> HashMap<String, Box<RawValue>> {
payload.to_owned()
}
}
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
pub struct PostgresConfig {
pub postgres_resource_path: String,
pub replication_slot_name: String,
pub publication_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub basic_mode: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PostgresConfigRequest {
postgres_resource_path: String,
#[serde(default)]
replication_slot_name: String,
#[serde(default)]
publication_name: String,
publication: Option<PublicationData>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestPostgresConfig {
pub postgres_resource_path: String,
}
fn check_if_valid_relation<'de, D>(
relations: D,
) -> std::result::Result<Option<Vec<Relations>>, D::Error>
where
D: Deserializer<'de>,
{
let relations: Option<Vec<Relations>> = Option::deserialize(relations)?;
let mut track_all_table_in_schema = false;
let mut track_specific_columns_in_table = false;
match relations {
Some(relations) => {
for relation in relations.iter() {
if relation.schema_name.is_empty() {
return Err(serde::de::Error::custom(
"Schema Name must not be empty".to_string(),
));
}
if !track_all_table_in_schema && relation.table_to_track.is_empty() {
track_all_table_in_schema = true;
continue;
}
for table_to_track in relation.table_to_track.iter() {
if table_to_track.table_name.trim().is_empty() {
return Err(serde::de::Error::custom(
"Table name must not be empty".to_string(),
));
}
if !track_specific_columns_in_table && table_to_track.columns_name.is_some() {
track_specific_columns_in_table = true;
}
}
if track_all_table_in_schema && track_specific_columns_in_table {
return Err(serde::de::Error::custom("Incompatible tracking options. Schema-level tracking and specific table tracking with column selection cannot be used together. Refer to the documentation for valid configurations."));
}
}
if !relations
.iter()
.map(|relation| relation.schema_name.as_str())
.all_unique()
{
return Err(serde::de::Error::custom(
"You cannot choose a schema more than one time".to_string(),
));
}
Ok(Some(relations))
}
None => Ok(None),
}
}
fn check_if_valid_transaction_type<'de, D>(
transaction_type: D,
) -> std::result::Result<Vec<String>, D::Error>
where
D: Deserializer<'de>,
{
let mut transaction_type: Vec<String> = Vec::deserialize(transaction_type)?;
if transaction_type.len() > 3 {
return Err(serde::de::Error::custom(
"More than 3 transaction type which is not authorized, you are only allowed to those 3 transaction types: Insert, Update and Delete"
.to_string(),
));
}
transaction_type.sort_unstable();
transaction_type.dedup();
for transaction in transaction_type.iter() {
match transaction.to_lowercase().as_ref() {
"insert" => {},
"update" => {},
"delete" => {},
_ => {
return Err(serde::de::Error::custom(
"Only the following transaction types are allowed: Insert, Update and Delete (case insensitive)"
.to_string(),
))
}
}
}
Ok(transaction_type)
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PublicationData {
#[serde(default, deserialize_with = "check_if_valid_relation")]
pub table_to_track: Option<Vec<Relations>>,
#[serde(deserialize_with = "check_if_valid_transaction_type")]
pub transaction_to_track: Vec<String>,
}
impl PublicationData {
pub fn new(
table_to_track: Option<Vec<Relations>>,
transaction_to_track: Vec<String>,
) -> PublicationData {
PublicationData { table_to_track, transaction_to_track }
}
}
// Slot list struct
#[derive(FromRow, Debug, Serialize)]
pub struct SlotList {
pub slot_name: Option<String>,
pub active: Option<bool>,
}
// Slot struct
#[derive(Debug, Serialize, Deserialize)]
pub struct Slot {
pub name: String,
}
// Template script struct
#[derive(Debug, Deserialize)]
pub struct TemplateScript {
pub postgres_resource_path: String,
#[serde(deserialize_with = "check_if_valid_relation")]
pub relations: Option<Vec<Relations>>,
pub language: Language,
}
// Language enum
#[derive(Deserialize, Debug)]
pub enum Language {
#[serde(rename = "typescript", alias = "Typescript")]
Typescript,
}
// Test postgres struct
#[derive(Serialize, Deserialize)]
pub struct TestPostgres {
pub postgres_resource_path: String,
}
// PostgreSQL publication replication struct
#[derive(Serialize, Deserialize)]
pub struct PostgresPublicationReplication {
pub publication_name: String,
pub replication_slot_name: String,
}
impl PostgresPublicationReplication {
pub fn new(
publication_name: String,
replication_slot_name: String,
) -> PostgresPublicationReplication {
PostgresPublicationReplication { publication_name, replication_slot_name }
}
}
pub const ERROR_PUBLICATION_NAME_NOT_EXISTS: &str = r#"The publication associated with this trigger no longer exists. Recreate a new publication or select an existing one in the advanced tab, or delete and recreate a new trigger"#;
#[derive(FromRow, Serialize, Deserialize, Debug)]
pub struct Postgres {
pub user: String,
pub password: String,
pub host: String,
pub port: Option<u16>,
pub dbname: String,
#[serde(default)]
pub sslmode: String,
#[serde(default, deserialize_with = "empty_as_none")]
pub root_certificate_pem: Option<String>,
}
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
pub struct TableToTrack {
pub table_name: String,
#[serde(default, deserialize_with = "empty_as_none")]
pub where_clause: Option<String>,
#[serde(default, deserialize_with = "empty_as_none")]
pub columns_name: Option<Vec<String>>,
}
impl TableToTrack {
pub fn new(
table_name: String,
where_clause: Option<String>,
columns_name: Option<Vec<String>>,
) -> TableToTrack {
TableToTrack { table_name, where_clause, columns_name }
}
}
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
pub struct Relations {
pub schema_name: String,
pub table_to_track: Vec<TableToTrack>,
}
impl Relations {
pub fn new(schema_name: String, table_to_track: Vec<TableToTrack>) -> Relations {
Relations { schema_name, table_to_track }
}
pub fn add_new_table(&mut self, table_to_track: TableToTrack) {
self.table_to_track.push(table_to_track);
}
}
fn build_tls_connector(
ssl_mode: SslMode,
root_certificate_pem: Option<&String>,
) -> Result<Option<MakeTlsConnector>> {
let get_tls_builder_for_verify = |root_certificate: Option<&String>| {
let mut builder = TlsConnector::builder();
if let Some(root_certificate) = root_certificate {
let root_certificate_pem =
Certificate::from_pem(root_certificate.as_bytes()).map_err(to_anyhow)?;
builder.add_root_certificate(root_certificate_pem);
}
Ok::<_, Error>(builder)
};
let connector = match ssl_mode {
SslMode::Disable => return Ok(None),
SslMode::Require | SslMode::Prefer => {
let mut builder = TlsConnector::builder();
builder.danger_accept_invalid_certs(true);
builder.danger_accept_invalid_hostnames(true);
builder
}
SslMode::VerifyCa => {
let mut builder = get_tls_builder_for_verify(root_certificate_pem)?;
builder.danger_accept_invalid_hostnames(true);
builder
}
SslMode::VerifyFull => {
let builder = get_tls_builder_for_verify(root_certificate_pem)?;
builder
}
_ => unreachable!(),
};
Ok(Some(MakeTlsConnector::new(
connector.build().map_err(to_anyhow)?,
)))
}
pub async fn get_raw_postgres_connection(
database: &Postgres,
logical_mode: bool,
) -> Result<Client> {
let ssl_mode = match database.sslmode.as_ref() {
"disable" => SslMode::Disable,
"" | "prefer" | "allow" => SslMode::Prefer,
"require" => SslMode::Require,
"verify-ca" => SslMode::VerifyCa,
"verify-full" => SslMode::VerifyFull,
ssl_mode => {
return Err(Error::BadRequest(
format!("Invalid ssl mode for postgres: {}, please put a valid ssl_mode among the following available ssl mode: ['disable', 'allow', 'prefer', 'verify-ca', 'verify-full']", ssl_mode),
))
}
};
let mut config = Config::new();
config
.dbname(&database.dbname)
.host(&database.host)
.user(&database.user)
.ssl_mode(ssl_mode);
if logical_mode {
config.replication_mode(rust_postgres::config::ReplicationMode::Logical);
}
if let Some(port) = database.port {
config.port(port);
};
if !database.password.is_empty() {
config.password(&database.password);
}
let connector = build_tls_connector(ssl_mode, database.root_certificate_pem.as_ref())?;
let client = if let Some(connector) = connector {
let (client, connection) = config.connect(connector).await.map_err(to_anyhow)?;
tokio::spawn(async move {
tracing::info!("Successfully connected to PostgreSQL database for trigger execution");
if let Err(e) = connection.await {
tracing::debug!("Error during PostgreSQL trigger connection: {:#?}", e);
};
tracing::info!("PostgreSQL trigger connection closed");
});
client
} else {
let (client, connection) = config.connect(NoTls).await.map_err(to_anyhow)?;
tokio::spawn(async move {
if let Err(e) = connection.await {
tracing::debug!("{:#?}", e);
};
tracing::info!("Successfully Connected into database");
});
client
};
Ok(client)
}
pub async fn get_pg_connection(
authed: ApiAuthed,
user_db: Option<UserDB>,
db: &DB,
postgres_resource_path: &str,
w_id: &str,
logical_mode: bool,
) -> Result<Client> {
let database =
try_get_resource_from_db_as::<Postgres>(&authed, user_db, db, postgres_resource_path, w_id)
.await?;
Ok(get_raw_postgres_connection(&database, logical_mode).await?)
}
pub async fn get_default_pg_connection(
authed: ApiAuthed,
user_db: Option<UserDB>,
db: &DB,
postgres_resource_path: &str,
w_id: &str,
) -> Result<Client> {
get_pg_connection(authed, user_db, db, postgres_resource_path, w_id, false).await
}
pub async fn create_logical_replication_slot(tx: &Client, slot_name: &str) -> Result<()> {
tx.execute(
&format!("SELECT * FROM pg_create_logical_replication_slot($1, 'pgoutput')"),
&[&slot_name],
)
.await
.map_err(to_anyhow)?;
Ok(())
}
pub async fn check_if_valid_publication_for_postgres_version(
pg_connection: &Client,
table_to_track: Option<&[Relations]>,
) -> Result<bool> {
use crate::triggers::postgres::handler::get_postgres_version_internal;
let postgres_version = get_postgres_version_internal(pg_connection).await?;
let pg_14 = postgres_version.starts_with("14");
if pg_14 {
let unsupported_publication = table_to_track
.and_then(|relations| {
relations.iter().find(|relation| {
let invalid_relation = relation.table_to_track.iter().find(|table_to_track| {
table_to_track.where_clause.is_some()
|| table_to_track.columns_name.is_some()
});
relation.table_to_track.is_empty() || invalid_relation.is_some()
})
})
.is_some();
if unsupported_publication {
return Err(Error::BadRequest(
"Your PostgreSQL database is running version 14, which does not support the following publication features: \
- WHERE clause filtering, \
- selective column tracking, and \
- tracking all tables within a schema.\n\
These features are only available in PostgreSQL 15 and above.".to_string(),
));
}
}
Ok(pg_14)
}
pub async fn create_pg_publication(
pg_connection: &Client,
publication_name: &str,
table_to_track: Option<&[Relations]>,
transaction_to_track: &[String],
) -> Result<()> {
let pg_14 =
check_if_valid_publication_for_postgres_version(pg_connection, table_to_track).await?;
let mut query = String::from("CREATE PUBLICATION ");
query.push_str(&quote_identifier(publication_name));
match table_to_track {
Some(database_component) if !database_component.is_empty() => {
query.push_str(" FOR");
let mut first = true;
for (i, schema) in database_component.iter().enumerate() {
if schema.table_to_track.is_empty() {
query.push_str(" TABLES IN SCHEMA ");
query.push_str(&quote_identifier(&schema.schema_name));
} else {
if pg_14 && first {
query.push_str(" TABLE ONLY ");
first = false
} else if !pg_14 {
query.push_str(" TABLE ONLY ");
}
for (j, table) in schema.table_to_track.iter().enumerate() {
let table_name = quote_identifier(&table.table_name);
let schema_name = quote_identifier(&schema.schema_name);
let full_name = format!("{}.{}", &schema_name, &table_name);
query.push_str(&full_name);
if let Some(columns) = table.columns_name.as_ref() {
query.push_str(" (");
let columns = columns
.iter()
.map(|column| quote_identifier(column))
.join(", ");
query.push_str(&columns);
query.push_str(")");
}
if let Some(where_clause) = &table.where_clause {
query.push_str(" WHERE (");
query.push_str(where_clause);
query.push(')');
}
if j + 1 != schema.table_to_track.len() {
query.push_str(", ");
}
}
}
if i < database_component.len() - 1 {
query.push_str(", ");
}
}
}
_ => {
query.push_str(" FOR ALL TABLES ");
}
};
if !transaction_to_track.is_empty() {
let transactions = || transaction_to_track.iter().join(", ");
query.push_str(" WITH (publish = '");
query.push_str(&transactions());
query.push_str("');");
}
pg_connection
.execute(&query, &[])
.await
.map_err(to_anyhow)?;
Ok(())
}
pub async fn drop_publication(pg_connection: &Client, publication_name: &str) -> Result<()> {
let mut query = String::from("DROP PUBLICATION IF EXISTS ");
let quoted_publication_name = quote_identifier(publication_name);
query.push_str(&quoted_publication_name);
pg_connection
.execute(&query, &[])
.await
.map_err(to_anyhow)?;
Ok(())
}
pub fn generate_random_string() -> String {
let timestamp = Utc::now().timestamp_millis().to_string();
let mut rng = rand::rng();
let charset = "abcdefghijklmnopqrstuvwxyz0123456789";
let random_part = (0..10)
.map(|_| {
charset
.chars()
.nth(rng.random_range(0..charset.len()))
.unwrap()
})
.collect::<String>();
format!("{}_{}", timestamp, random_part)
}
@@ -1,74 +0,0 @@
use core::str;
use serde_json::{Map, Value};
use std::{collections::HashMap, str::Utf8Error};
use super::{
converter::{Converter, ConverterError},
replication_message::{Columns, RelationBody, TupleData},
};
use rust_postgres::types::Oid;
#[derive(Debug, thiserror::Error)]
pub enum RelationConversionError {
#[error("Could not find matching table")]
FailToFindMatchingTable,
#[error("Binary data not supported")]
BinaryFormatNotSupported,
#[error("decode error: {0}")]
FromBytes(#[from] ConverterError),
#[error("invalid string value")]
InvalidStr(#[from] Utf8Error),
}
pub struct RelationConverter(HashMap<Oid, RelationBody>);
impl RelationConverter {
pub fn new() -> Self {
Self(HashMap::new())
}
pub fn add_relation(&mut self, relation: RelationBody) {
self.0.insert(relation.o_id, relation);
}
pub fn get_columns(&self, o_id: Oid) -> Result<&Columns, RelationConversionError> {
self.0
.get(&o_id)
.map(|relation_body| &relation_body.columns)
.ok_or(RelationConversionError::FailToFindMatchingTable)
}
pub fn get_relation(&self, o_id: Oid) -> Result<&RelationBody, RelationConversionError> {
self.0
.get(&o_id)
.ok_or(RelationConversionError::FailToFindMatchingTable)
}
pub fn row_to_json(
&self,
to_decode: (Oid, Vec<TupleData>),
) -> Result<Map<String, Value>, RelationConversionError> {
let (o_id, tuple_data) = to_decode;
let mut object: Map<String, Value> = Map::new();
let columns = self.get_columns(o_id)?;
for (i, column) in columns.iter().enumerate() {
let value = match &tuple_data[i] {
TupleData::Null | TupleData::UnchangedToast => Value::Null,
TupleData::Binary(_) => {
return Err(RelationConversionError::BinaryFormatNotSupported)
}
TupleData::Text(bytes) => {
let str = str::from_utf8(&bytes[..])?;
Converter::try_from_str(column.type_o_id.clone(), str)?
}
};
object.insert(column.name.clone(), value);
}
Ok(object)
}
}
@@ -1,510 +0,0 @@
#![allow(unused)]
use core::str;
use std::{
cmp,
io::{self, Cursor, Read},
str::Utf8Error,
};
use byteorder::{BigEndian, ReadBytesExt};
use bytes::Bytes;
use rust_postgres::types::{Oid, Type};
use thiserror::Error;
use super::listener::LogicalReplicationSettings;
const PRIMARY_KEEPALIVE_BYTE: u8 = b'k';
const X_LOG_DATA_BYTE: u8 = b'w';
/**
* This implementation is inspired by Postgres replication functionality
* from https://github.com/supabase/pg_replicate
*
* Original implementation:
* - https://github.com/supabase/pg_replicate/blob/main/pg_replicate/src/conversions/cdc_event.rs
*
*/
#[derive(Debug)]
pub struct PrimaryKeepAliveBody {
pub wal_end: u64,
pub timestamp: i64,
pub reply: bool,
}
impl PrimaryKeepAliveBody {
pub fn new(wal_end: u64, timestamp: i64, reply: bool) -> PrimaryKeepAliveBody {
PrimaryKeepAliveBody { wal_end, timestamp, reply }
}
}
const BEGIN_BYTE: u8 = b'B';
const COMMIT_BYTE: u8 = b'C';
const ORIGIN_BYTE: u8 = b'O';
const RELATION_BYTE: u8 = b'R';
const TYPE_BYTE: u8 = b'Y';
const INSERT_BYTE: u8 = b'I';
const UPDATE_BYTE: u8 = b'U';
const DELETE_BYTE: u8 = b'D';
const TUPLE_NEW_BYTE: u8 = b'N';
const TUPLE_KEY_BYTE: u8 = b'K';
const TUPLE_OLD_BYTE: u8 = b'O';
const TUPLE_DATA_NULL_BYTE: u8 = b'n';
const TUPLE_DATA_TOAST_BYTE: u8 = b'u';
const TUPLE_DATA_TEXT_BYTE: u8 = b't';
const TUPLE_DATA_BINARY_BYTE: u8 = b'b';
const REPLICA_IDENTITY_DEFAULT_BYTE: i8 = 0x64;
const REPLICA_IDENTITY_NOTHING_BYTE: i8 = 0x6E;
const REPLICA_IDENTITY_FULL_BYTE: i8 = 0x66;
const REPLICA_IDENTITY_INDEX_BYTE: i8 = 0x69;
#[derive(Debug)]
pub enum ReplicaIdentity {
Default,
Nothing,
Full,
Index,
}
#[derive(Debug)]
pub struct Column {
pub flags: i8,
pub name: String,
pub type_o_id: Option<Type>,
pub type_modifier: i32,
}
impl Column {
pub fn new(flags: i8, name: String, type_o_id: Option<Type>, type_modifier: i32) -> Self {
Self { flags, name, type_o_id, type_modifier }
}
}
pub type Columns = Vec<Column>;
#[derive(Debug)]
pub struct RelationBody {
pub transaction_id: Option<i32>,
pub o_id: Oid,
pub namespace: String,
pub name: String,
pub replica_identity: ReplicaIdentity,
pub columns: Columns,
}
impl RelationBody {
pub fn new(
transaction_id: Option<i32>,
o_id: Oid,
namespace: String,
name: String,
replica_identity: ReplicaIdentity,
columns: Columns,
) -> Self {
Self { transaction_id, o_id, namespace, name, replica_identity, columns }
}
}
#[derive(Debug)]
pub struct InsertBody {
pub transaction_id: Option<i32>,
pub o_id: Oid,
pub tuple: Vec<TupleData>,
}
impl InsertBody {
pub fn new(transaction_id: Option<i32>, o_id: Oid, tuple: Vec<TupleData>) -> Self {
Self { transaction_id, o_id, tuple }
}
}
#[derive(Debug)]
pub struct UpdateBody {
transaction_id: Option<i32>,
pub o_id: Oid,
pub old_tuple: Option<Vec<TupleData>>,
pub key_tuple: Option<Vec<TupleData>>,
pub new_tuple: Vec<TupleData>,
}
impl UpdateBody {
pub fn new(
transaction_id: Option<i32>,
o_id: Oid,
old_tuple: Option<Vec<TupleData>>,
key_tuple: Option<Vec<TupleData>>,
new_tuple: Vec<TupleData>,
) -> Self {
Self { transaction_id, o_id, old_tuple, key_tuple, new_tuple }
}
}
#[derive(Debug)]
pub struct DeleteBody {
transaction_id: Option<i32>,
pub o_id: Oid,
pub old_tuple: Option<Vec<TupleData>>,
pub key_tuple: Option<Vec<TupleData>>,
}
impl DeleteBody {
pub fn new(
transaction_id: Option<i32>,
o_id: Oid,
old_tuple: Option<Vec<TupleData>>,
key_tuple: Option<Vec<TupleData>>,
) -> Self {
Self { transaction_id, o_id, old_tuple, key_tuple }
}
}
#[derive(Debug)]
pub enum TupleData {
Null,
UnchangedToast,
Text(Bytes),
Binary(Bytes),
}
impl TupleData {
fn parse(buf: &mut Buffer) -> Result<Vec<TupleData>, ConversionError> {
let number_of_columns = buf.read_i16::<BigEndian>()?;
let mut tuples = Vec::with_capacity(number_of_columns as usize);
for _ in 0..number_of_columns {
let byte = buf.read_u8()?;
let tuple_data = match byte {
TUPLE_DATA_NULL_BYTE => TupleData::Null,
TUPLE_DATA_TOAST_BYTE => TupleData::UnchangedToast,
TUPLE_DATA_TEXT_BYTE => {
let len = buf.read_i32::<BigEndian>()?;
let mut data = vec![0; len as usize];
buf.read_exact(&mut data)?;
TupleData::Text(data.into())
}
TUPLE_DATA_BINARY_BYTE => {
let len = buf.read_i32::<BigEndian>()?;
let mut data = vec![0; len as usize];
buf.read_exact(&mut data)?;
TupleData::Binary(data.into())
}
byte => {
return Err(ConversionError::Io(io::Error::new(
io::ErrorKind::InvalidInput,
format!("unknown replication message byte `{}`", byte),
)));
}
};
tuples.push(tuple_data);
}
Ok(tuples)
}
}
#[derive(Debug)]
pub enum TransactionBody {
Insert(InsertBody),
Update(UpdateBody),
Delete(DeleteBody),
}
#[non_exhaustive]
#[derive(Debug)]
pub enum LogicalReplicationMessage {
Begin,
Commit,
Relation(RelationBody),
Type,
Insert(InsertBody),
Update(UpdateBody),
Delete(DeleteBody),
}
#[derive(Debug)]
pub struct XLogDataBody {
pub wal_start: u64,
pub wal_end: u64,
pub timestamp: i64,
pub data: Bytes,
}
#[derive(Error, Debug)]
pub enum ConversionError {
#[error("Error: {0}")]
Io(#[from] io::Error),
#[error("Utf8Error conversion: {0}")]
Utf8(#[from] Utf8Error),
}
struct Buffer {
bytes: Bytes,
idx: usize,
}
impl Buffer {
pub fn new(bytes: Bytes, idx: usize) -> Buffer {
Buffer { bytes, idx }
}
fn slice(&self) -> &[u8] {
&self.bytes[self.idx..]
}
fn read_cstr(&mut self) -> Result<String, ConversionError> {
match self.slice().iter().position(|&x| x == 0) {
Some(pos) => {
let start = self.idx;
let end = start + pos;
let cstr = str::from_utf8(&self.bytes[start..end])?.to_owned();
self.idx = end + 1;
Ok(cstr)
}
None => Err(ConversionError::Io(io::Error::new(
io::ErrorKind::UnexpectedEof,
"unexpected EOF",
))),
}
}
}
impl Read for Buffer {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let len = {
let slice = self.slice();
let len = cmp::min(slice.len(), buf.len());
buf[..len].copy_from_slice(&slice[..len]);
len
};
self.idx += len;
Ok(len)
}
}
impl XLogDataBody {
pub fn new(wal_start: u64, wal_end: u64, timestamp: i64, data: Bytes) -> XLogDataBody {
XLogDataBody { wal_start, wal_end, timestamp, data }
}
pub fn parse(
self,
logical_replication_settings: &LogicalReplicationSettings,
) -> Result<LogicalReplicationMessage, ConversionError> {
let mut buf = Buffer::new(self.data.clone(), 0);
let byte = buf.read_u8()?;
let logical_replication_message = match byte {
BEGIN_BYTE => {
buf.read_i64::<BigEndian>()?;
buf.read_i64::<BigEndian>()?;
buf.read_i32::<BigEndian>()?;
LogicalReplicationMessage::Begin
}
COMMIT_BYTE => {
buf.read_i8()?;
buf.read_u64::<BigEndian>()?;
buf.read_u64::<BigEndian>()?;
buf.read_i64::<BigEndian>()?;
LogicalReplicationMessage::Commit
}
RELATION_BYTE => {
let transaction_id = match logical_replication_settings.streaming {
true => Some(buf.read_i32::<BigEndian>()?),
false => None,
};
let o_id = buf.read_u32::<BigEndian>()?;
let namespace = buf.read_cstr()?;
let name = buf.read_cstr()?;
let replica_identity = match buf.read_i8()? {
REPLICA_IDENTITY_DEFAULT_BYTE => ReplicaIdentity::Default,
REPLICA_IDENTITY_NOTHING_BYTE => ReplicaIdentity::Nothing,
REPLICA_IDENTITY_FULL_BYTE => ReplicaIdentity::Full,
REPLICA_IDENTITY_INDEX_BYTE => ReplicaIdentity::Index,
byte => {
return Err(ConversionError::Io(io::Error::new(
io::ErrorKind::InvalidInput,
format!("unknown replica identity byte `{}`", byte),
)));
}
};
let num_of_column = buf.read_i16::<BigEndian>()?;
let mut columns = Vec::with_capacity(num_of_column as usize);
for _ in 0..num_of_column {
let flags = buf.read_i8()?;
let name = buf.read_cstr()?;
let o_id = buf.read_u32::<BigEndian>()?;
let type_modifier = buf.read_i32::<BigEndian>()?;
let type_o_id = Type::from_oid(o_id);
let column = Column::new(flags, name, type_o_id, type_modifier);
columns.push(column);
}
LogicalReplicationMessage::Relation(RelationBody::new(
transaction_id,
o_id,
namespace,
name,
replica_identity,
columns,
))
}
TYPE_BYTE => {
buf.read_u32::<BigEndian>()?;
buf.read_cstr()?;
buf.read_cstr()?;
LogicalReplicationMessage::Type
}
INSERT_BYTE => {
let transaction_id = match logical_replication_settings.streaming {
true => Some(buf.read_i32::<BigEndian>()?),
false => None,
};
let o_id = buf.read_u32::<BigEndian>()?;
let byte = buf.read_u8()?;
let tuple = match byte {
TUPLE_NEW_BYTE => TupleData::parse(&mut buf)?,
byte => {
return Err(ConversionError::Io(io::Error::new(
io::ErrorKind::InvalidInput,
format!("unexpected tuple byte `{}`", byte),
)));
}
};
LogicalReplicationMessage::Insert(InsertBody::new(transaction_id, o_id, tuple))
}
UPDATE_BYTE => {
let transaction_id = match logical_replication_settings.streaming {
true => Some(buf.read_i32::<BigEndian>()?),
false => None,
};
let o_id = buf.read_u32::<BigEndian>()?;
let byte = buf.read_u8()?;
let mut key_tuple = None;
let mut old_tuple = None;
let new_tuple = match byte {
TUPLE_NEW_BYTE => TupleData::parse(&mut buf)?,
TUPLE_OLD_BYTE | TUPLE_KEY_BYTE => {
if byte == TUPLE_OLD_BYTE {
old_tuple = Some(TupleData::parse(&mut buf)?);
} else {
key_tuple = Some(TupleData::parse(&mut buf)?);
}
match buf.read_u8()? {
TUPLE_NEW_BYTE => TupleData::parse(&mut buf)?,
byte => {
return Err(ConversionError::Io(io::Error::new(
io::ErrorKind::InvalidInput,
format!("unexpected tuple byte `{}`", byte),
)));
}
}
}
byte => {
return Err(ConversionError::Io(io::Error::new(
io::ErrorKind::InvalidInput,
format!("unknown tuple byte `{}`", byte),
)));
}
};
LogicalReplicationMessage::Update(UpdateBody::new(
transaction_id,
o_id,
old_tuple,
key_tuple,
new_tuple,
))
}
DELETE_BYTE => {
let transaction_id = match logical_replication_settings.streaming {
true => Some(buf.read_i32::<BigEndian>()?),
false => None,
};
let o_id = buf.read_u32::<BigEndian>()?;
let tag = buf.read_u8()?;
let mut key_tuple = None;
let mut old_tuple = None;
match tag {
TUPLE_OLD_BYTE => old_tuple = Some(TupleData::parse(&mut buf)?),
TUPLE_KEY_BYTE => key_tuple = Some(TupleData::parse(&mut buf)?),
tag => {
return Err(ConversionError::Io(io::Error::new(
io::ErrorKind::InvalidInput,
format!("unknown tuple tag `{}`", tag),
)));
}
}
LogicalReplicationMessage::Delete(DeleteBody::new(
transaction_id,
o_id,
old_tuple,
key_tuple,
))
}
byte => {
return Err(ConversionError::Io(io::Error::new(
io::ErrorKind::InvalidInput,
format!("unknown replication message tag `{}`", byte),
)));
}
};
Ok(logical_replication_message)
}
}
#[non_exhaustive]
#[derive(Debug)]
pub enum ReplicationMessage {
XLogData(XLogDataBody),
PrimaryKeepAlive(PrimaryKeepAliveBody),
}
impl ReplicationMessage {
pub fn parse(buf: Bytes) -> io::Result<Self> {
let (byte, mut message) = buf.split_first().unwrap();
let replication_message = match *byte {
X_LOG_DATA_BYTE => {
let len = buf.len();
let wal_start = message.read_u64::<BigEndian>()?;
let wal_end = message.read_u64::<BigEndian>()?;
let timestamp = message.read_i64::<BigEndian>()?;
let len = len - message.len();
let data = buf.slice(len..);
ReplicationMessage::XLogData(XLogDataBody::new(wal_start, wal_end, timestamp, data))
}
PRIMARY_KEEPALIVE_BYTE => {
let wal_end = message.read_u64::<BigEndian>()?;
let timestamp = message.read_i64::<BigEndian>()?;
let reply = message.read_u8()?;
ReplicationMessage::PrimaryKeepAlive(PrimaryKeepAliveBody::new(
wal_end,
timestamp,
reply == 1,
))
}
byte => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("unknown replication message byte `{}`", byte),
));
}
};
Ok(replication_message)
}
}
@@ -1,65 +0,0 @@
#[cfg(feature = "private")]
#[allow(unused)]
pub use super::handler_ee::*;
#[cfg(not(feature = "private"))]
use {
super::SqsTrigger,
crate::{
db::{ApiAuthed, DB},
triggers::{Trigger, TriggerCrud, TriggerData},
},
axum::async_trait,
sqlx::PgConnection,
windmill_common::error::{Error, Result},
windmill_git_sync::DeployedObject,
};
#[cfg(not(feature = "private"))]
#[async_trait]
#[cfg(not(feature = "private"))]
impl TriggerCrud for SqsTrigger {
type Trigger = Trigger<Self::TriggerConfig>;
type TriggerConfig = ();
type TriggerConfigRequest = ();
type TestConnectionConfig = ();
const TABLE_NAME: &'static str = "";
const TRIGGER_TYPE: &'static str = "";
const SUPPORTS_SERVER_STATE: bool = false;
const SUPPORTS_TEST_CONNECTION: bool = false;
const ROUTE_PREFIX: &'static str = "/sqs_triggers";
const DEPLOYMENT_NAME: &'static str = "";
const IS_ALLOWED_ON_CLOUD: bool = false;
fn get_deployed_object(path: String) -> DeployedObject {
DeployedObject::SqsTrigger { path }
}
async fn create_trigger(
&self,
_db: &DB,
_tx: &mut PgConnection,
_authed: &ApiAuthed,
_w_id: &str,
_trigger: TriggerData<Self::TriggerConfigRequest>,
) -> Result<()> {
Err(Error::BadRequest(
"SQS triggers are not available in open source version".to_string(),
))
}
async fn update_trigger(
&self,
_db: &DB,
_tx: &mut PgConnection,
_authed: &ApiAuthed,
_workspace_id: &str,
_path: &str,
_trigger: TriggerData<Self::TriggerConfigRequest>,
) -> Result<()> {
Err(Error::BadRequest(
"SQS triggers are not available in open source version".to_string(),
))
}
}
@@ -1,41 +0,0 @@
#[allow(unused)]
#[cfg(feature = "private")]
pub use super::listener_ee::*;
#[cfg(not(feature = "private"))]
use {
super::SqsTrigger,
crate::triggers::{listener::ListeningTrigger, Listener},
std::sync::Arc,
tokio::sync::RwLock,
windmill_common::{error::Result, jobs::JobTriggerKind, DB},
};
#[cfg(not(feature = "private"))]
#[async_trait::async_trait]
impl Listener for SqsTrigger {
type Consumer = ();
type Extra = ();
const JOB_TRIGGER_KIND: JobTriggerKind = JobTriggerKind::Sqs;
async fn get_consumer(
&self,
_db: &DB,
_listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
_err_message: Arc<RwLock<Option<String>>>,
_killpill_rx: tokio::sync::broadcast::Receiver<()>,
) -> Result<Option<Self::Consumer>> {
Ok(None)
}
async fn consume(
&self,
_db: &DB,
_consumer: Self::Consumer,
_listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
_err_message: Arc<RwLock<Option<String>>>,
_killpill_rx: tokio::sync::broadcast::Receiver<()>,
) {
()
}
}
@@ -1,15 +0,0 @@
#[cfg(feature = "private")]
mod handler_ee;
pub mod handler_oss;
#[cfg(feature = "private")]
mod listener_ee;
pub mod listener_oss;
#[cfg(feature = "private")]
mod mod_ee;
#[cfg(feature = "private")]
pub use mod_ee::*;
#[derive(Copy, Clone)]
pub struct SqsTrigger;
@@ -1,959 +0,0 @@
use anyhow::Context;
use axum::response::IntoResponse;
use http::StatusCode;
use serde::Deserialize;
use serde_json::value::RawValue;
use sqlx::types::Json;
use std::collections::HashMap;
use std::future::Future;
use uuid::Uuid;
use windmill_common::{
db::{UserDB, UserDbWithAuthed},
error::Result,
flows::{FlowModuleValue, Retry},
get_latest_deployed_hash_for_path, get_latest_flow_version_info_for_path,
jobs::{get_has_preprocessor_from_content_and_lang, script_path_to_payload, JobPayload},
scripts::{get_full_hub_script_by_path, ScriptHash, ScriptLang},
triggers::{
HubOrWorkspaceId, RunnableFormat, RunnableFormatVersion, TriggerKind, TriggerMetadata,
RUNNABLE_FORMAT_VERSION_CACHE,
},
users::username_to_permissioned_as,
utils::StripPath,
worker::to_raw_value,
};
use windmill_queue::{push, PushArgs, PushArgsOwned, PushIsolationLevel};
#[cfg(feature = "enterprise")]
use crate::jobs::check_license_key_valid;
use crate::{
db::{ApiAuthed, DB},
jobs::{
check_tag_available_for_workspace, delete_job_metadata_after_use,
push_flow_job_by_path_into_queue, push_script_job_by_path_into_queue, result_to_response,
run_wait_result_internal, RunJobQuery,
},
utils::check_scopes,
HTTP_CLIENT,
};
struct ScriptInfo {
has_preprocessor: Option<bool>,
language: ScriptLang,
content: String,
schema: Option<sqlx::types::Json<PartialSchema>>,
}
#[derive(Debug, Deserialize)]
struct PropertyDefinition {
r#type: Option<Box<RawValue>>,
}
#[derive(Debug, Deserialize)]
struct PartialSchema {
properties: Option<HashMap<String, PropertyDefinition>>,
}
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub enum RunnableId {
FlowId(FlowId),
ScriptId(ScriptId),
HubScript(String),
}
impl RunnableId {
pub fn from_script_hash(hash: ScriptHash) -> Self {
Self::ScriptId(ScriptId::ScriptHash(hash))
}
pub fn from_script_path(path: &str) -> Self {
if path.starts_with("hub/") {
Self::HubScript(path.to_string())
} else {
Self::ScriptId(ScriptId::ScriptPath(path.to_string()))
}
}
pub fn from_flow_path(path: &str) -> Self {
Self::FlowId(FlowId::FlowPath(path.to_string()))
}
pub fn from_flow_version(version: i64) -> Self {
Self::FlowId(FlowId::FlowVersion(version))
}
}
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub enum FlowId {
FlowPath(String),
FlowVersion(i64),
}
impl FlowId {
async fn get_flow_version_id(self, workspace_id: &str, db: &DB) -> Result<i64> {
let version_id = match self {
FlowId::FlowPath(path) => {
let info =
get_latest_flow_version_info_for_path(None, db, workspace_id, &path, true)
.await?;
info.version
}
FlowId::FlowVersion(version) => version,
};
Ok(version_id)
}
}
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub enum ScriptId {
ScriptPath(String),
ScriptHash(ScriptHash),
}
impl ScriptId {
async fn get_script_hash(self, workspace_id: &str, db: &DB) -> Result<i64> {
let hash = match self {
ScriptId::ScriptPath(path) => {
let info = get_latest_deployed_hash_for_path(None, db.clone(), workspace_id, &path)
.await?;
info.hash
}
ScriptId::ScriptHash(hash) => hash.0,
};
Ok(hash)
}
}
async fn get_script_info(
db: &DB,
workspace_id: &str,
hash: i64,
) -> std::result::Result<ScriptInfo, sqlx::Error> {
sqlx::query_as!(ScriptInfo, "SELECT has_preprocessor, language as \"language: _\", content, schema as \"schema: _\" FROM script WHERE workspace_id = $1 AND hash = $2", workspace_id, hash)
.fetch_one(db)
.await
}
fn runnable_format_from_schema_without_preprocessor(
trigger_kind: &TriggerKind,
schema: Option<sqlx::types::Json<PartialSchema>>,
) -> RunnableFormat {
match trigger_kind {
TriggerKind::Mqtt
if schema.as_ref().is_some_and(|schema| {
schema.properties.as_ref().is_some_and(|properties| {
properties.iter().any(|(key, def)| {
key == "payload"
&& def.r#type.as_ref().is_some_and(|t| {
let typ = t.get().trim();
typ == "array" || (typ.starts_with('[') && typ.ends_with(']'))
})
})
})
}) =>
{
RunnableFormat { version: RunnableFormatVersion::V1, has_preprocessor: false }
}
TriggerKind::Kafka | TriggerKind::Nats
if schema.as_ref().is_some_and(|schema| {
schema
.properties
.as_ref()
.is_some_and(|properties| properties.keys().any(|key| key == "msg"))
}) =>
{
RunnableFormat { version: RunnableFormatVersion::V1, has_preprocessor: false }
}
_ => RunnableFormat { version: RunnableFormatVersion::V2, has_preprocessor: false },
}
}
fn runnable_format_from_preprocessor_args(
args: Option<Vec<windmill_parser::Arg>>,
) -> RunnableFormat {
if let Some(args) = args {
if args.iter().any(|arg| arg.name == "wm_trigger")
|| (args.len() > 0 && args.iter().all(|arg| arg.name != "event"))
{
RunnableFormat { version: RunnableFormatVersion::V1, has_preprocessor: true }
} else {
RunnableFormat { version: RunnableFormatVersion::V2, has_preprocessor: true }
}
} else {
RunnableFormat { version: RunnableFormatVersion::V2, has_preprocessor: true }
}
}
enum PreprocessorInfo {
Preprocessor { content: String, language: ScriptLang },
NoPreprocessor { schema: Option<sqlx::types::Json<PartialSchema>> },
}
#[derive(Debug, Deserialize)]
struct FlowInfo {
preprocessor_module: Option<sqlx::types::Json<FlowModuleValue>>,
schema: Option<sqlx::types::Json<PartialSchema>>,
}
fn get_preprocessor_args_from_content_and_language(
content: &str,
language: &ScriptLang,
) -> Result<Option<Vec<windmill_parser::Arg>>> {
let args = match language {
ScriptLang::Bun | ScriptLang::Bunnative | ScriptLang::Deno | ScriptLang::Nativets => {
let args = windmill_parser_ts::parse_deno_signature(
&content,
true,
false,
Some("preprocessor".to_string()),
)?;
Some(args.args)
}
ScriptLang::Python3 => {
let args = windmill_parser_py::parse_python_signature(
&content,
Some("preprocessor".to_string()),
false,
)?;
Some(args.args)
}
_ => None,
};
Ok(args)
}
pub async fn get_runnable_format(
runnable_id: RunnableId,
workspace_id: &str,
db: &DB,
trigger_kind: &TriggerKind,
) -> Result<RunnableFormat> {
let (key, preprocessor_info) = match runnable_id {
RunnableId::HubScript(path) => {
let Some(version) = path.split("/").nth(1) else {
return Err(windmill_common::error::Error::internal_err(
"Invalid hub script path".to_string(),
));
};
let version = match version.parse::<i64>() {
Ok(version) => version,
Err(_) => {
return Err(windmill_common::error::Error::internal_err(
"Invalid hub script version".to_string(),
));
}
};
let key = (HubOrWorkspaceId::Hub, version, trigger_kind.clone());
let runnable_format = RUNNABLE_FORMAT_VERSION_CACHE.get(&key);
if let Some(runnable_format) = runnable_format {
tracing::debug!("Using cached runnable format for hub script {path}");
return Ok(runnable_format);
}
let hub_script =
get_full_hub_script_by_path(StripPath(path.to_string()), &HTTP_CLIENT, Some(db))
.await?;
let has_preprocessor = get_has_preprocessor_from_content_and_lang(
&hub_script.content,
&hub_script.language,
)?;
let partial_schema = serde_json::from_str(hub_script.schema.get())?;
(
key,
if has_preprocessor {
PreprocessorInfo::Preprocessor {
content: hub_script.content,
language: hub_script.language,
}
} else {
PreprocessorInfo::NoPreprocessor {
schema: Some(sqlx::types::Json(partial_schema)),
}
},
)
}
RunnableId::FlowId(flow_id) => {
let version = flow_id.get_flow_version_id(workspace_id, db).await?;
let key = (
HubOrWorkspaceId::WorkspaceId(workspace_id.to_string()),
version,
trigger_kind.clone(),
);
let runnable_format = RUNNABLE_FORMAT_VERSION_CACHE.get(&key);
if let Some(runnable_format) = runnable_format {
tracing::debug!("Using cached runnable format for flow version {version}");
return Ok(runnable_format);
}
let flow_info = sqlx::query_as!(
FlowInfo,
"SELECT
value->'preprocessor_module'->'value' as \"preprocessor_module: _\",
schema as \"schema: _\"
FROM flow_version
WHERE
id = $1
AND workspace_id = $2",
version,
workspace_id,
)
.fetch_one(db)
.await?;
if let Some(preprocessor_module) = flow_info.preprocessor_module {
match preprocessor_module.0 {
FlowModuleValue::RawScript { content, language, .. } => {
(key, PreprocessorInfo::Preprocessor { content, language })
}
FlowModuleValue::Script { path, hash, .. } => {
let hash = if let Some(hash) = hash {
hash.0
} else {
let script_hash = get_latest_deployed_hash_for_path(
None,
db.clone(),
workspace_id,
&path,
)
.await?;
script_hash.hash
};
let script_info = get_script_info(db, workspace_id, hash).await?;
(
key,
PreprocessorInfo::Preprocessor {
content: script_info.content,
language: script_info.language,
},
)
}
_ => {
return Err(windmill_common::error::Error::internal_err(
"Unsupported preprocessor module".to_string(),
));
}
}
} else {
(
key,
PreprocessorInfo::NoPreprocessor { schema: flow_info.schema },
)
}
}
RunnableId::ScriptId(script_id) => {
let hash = script_id.get_script_hash(workspace_id, db).await?;
let key = (
HubOrWorkspaceId::WorkspaceId(workspace_id.to_string()),
hash,
trigger_kind.clone(),
);
let runnable_format = RUNNABLE_FORMAT_VERSION_CACHE.get(&key);
if let Some(runnable_format) = runnable_format {
tracing::debug!("Using cached runnable format for script {hash}");
return Ok(runnable_format);
}
let script_info = get_script_info(db, workspace_id, hash).await?;
if script_info.has_preprocessor.unwrap_or(false) {
(
key,
PreprocessorInfo::Preprocessor {
content: script_info.content,
language: script_info.language,
},
)
} else {
(
key,
PreprocessorInfo::NoPreprocessor { schema: script_info.schema },
)
}
}
};
let runnable_format = match preprocessor_info {
PreprocessorInfo::Preprocessor { content, language } => {
let args = get_preprocessor_args_from_content_and_language(&content, &language)?;
runnable_format_from_preprocessor_args(args)
}
PreprocessorInfo::NoPreprocessor { schema } => {
runnable_format_from_schema_without_preprocessor(trigger_kind, schema)
}
};
RUNNABLE_FORMAT_VERSION_CACHE.insert(key, runnable_format);
Ok(runnable_format)
}
#[allow(dead_code)]
pub trait TriggerJobArgs {
type Payload: Send + Sync;
const TRIGGER_KIND: TriggerKind;
fn v1_payload_fn(payload: &Self::Payload) -> HashMap<String, Box<RawValue>>;
fn v2_payload_fn(payload: &Self::Payload) -> HashMap<String, Box<RawValue>> {
Self::v1_payload_fn(payload)
}
fn build_job_args_v2(
has_preprocessor: bool,
payload: &Self::Payload,
info: HashMap<String, Box<RawValue>>,
) -> PushArgsOwned {
let mut args = Self::v2_payload_fn(payload);
if has_preprocessor {
args.insert(
"kind".to_string(),
to_raw_value(&Self::TRIGGER_KIND.to_key()),
);
args.extend(info);
let args = HashMap::from([("event".to_string(), to_raw_value(&args))]);
PushArgsOwned { args, extra: None }
} else {
PushArgsOwned { args, extra: None }
}
}
fn build_job_args_v1(
has_preprocessor: bool,
payload: &Self::Payload,
info: HashMap<String, Box<RawValue>>,
) -> PushArgsOwned {
let trigger_key = Self::TRIGGER_KIND.to_key();
let args = Self::v1_payload_fn(payload);
let extra = if has_preprocessor {
Some(HashMap::from([(
"wm_trigger".to_string(),
to_raw_value(&serde_json::json!({
"kind": trigger_key,
trigger_key: info
})),
)]))
} else {
None
};
PushArgsOwned { args, extra }
}
fn build_job_args(
runnable_path: &str,
is_flow: bool,
w_id: &str,
db: &DB,
payload: Self::Payload,
info: HashMap<String, Box<RawValue>>,
) -> impl Future<Output = Result<PushArgsOwned>> + Send {
async move {
let runnable_id = if is_flow {
RunnableId::from_flow_path(runnable_path)
} else {
RunnableId::from_script_path(runnable_path)
};
Self::build_job_args_from_runnable_id(runnable_id, w_id, db, payload, info).await
}
}
fn build_job_args_from_runnable_id(
runnable_id: RunnableId,
w_id: &str,
db: &DB,
payload: Self::Payload,
trigger_info: HashMap<String, Box<RawValue>>,
) -> impl Future<Output = Result<PushArgsOwned>> + Send {
async move {
tracing::debug!("Building job args for {runnable_id:?}");
let runnable_format =
get_runnable_format(runnable_id, w_id, db, &Self::TRIGGER_KIND).await?;
let job_args = match runnable_format {
RunnableFormat { version: RunnableFormatVersion::V1, has_preprocessor } => {
Self::build_job_args_v1(has_preprocessor, &payload, trigger_info)
}
RunnableFormat { version: RunnableFormatVersion::V2, has_preprocessor } => {
Self::build_job_args_v2(has_preprocessor, &payload, trigger_info)
}
};
Ok(job_args)
}
}
fn build_capture_payloads(
payload: &Self::Payload,
info: HashMap<String, Box<RawValue>>,
) -> (PushArgsOwned, PushArgsOwned) {
let main_args = Self::build_job_args_v2(false, payload, info.clone());
let preprocessor_args = Self::build_job_args_v2(true, payload, info);
(main_args, preprocessor_args)
}
}
#[allow(dead_code)]
pub async fn trigger_runnable_inner<'c>(
db: &DB,
tx_o: Option<sqlx::Transaction<'c, sqlx::Postgres>>,
user_db: Option<UserDB>,
authed: ApiAuthed,
workspace_id: &str,
runnable_path: &str,
is_flow: bool,
args: PushArgsOwned,
retry: Option<&sqlx::types::Json<Retry>>,
error_handler_path: Option<&str>,
error_handler_args: Option<&sqlx::types::Json<HashMap<String, serde_json::Value>>>,
trigger_path: String,
job_id: Option<Uuid>,
trigger: TriggerMetadata,
suspended_mode: Option<bool>,
) -> Result<(
Uuid,
Option<bool>,
Option<String>,
Option<sqlx::Transaction<'c, sqlx::Postgres>>,
)> {
let error_handler_args = error_handler_args.map(|args| {
let args = args
.0
.iter()
.map(|(key, value)| (key.to_owned(), to_raw_value(&value)))
.collect::<HashMap<String, Box<RawValue>>>();
Json(args)
});
let user_db = user_db.unwrap_or_else(|| UserDB::new(db.clone()));
let (uuid, delete_after_use, early_return, tx_out) = if is_flow {
let run_query = RunJobQuery { job_id, suspended_mode, ..Default::default() };
let path = StripPath(runnable_path.to_string());
let (uuid, early_return, tx_out) = push_flow_job_by_path_into_queue(
authed,
db.clone(),
tx_o,
user_db,
workspace_id.to_string(),
path,
run_query,
args,
Some(trigger),
)
.await?;
(uuid, None, early_return, tx_out)
} else {
let (uuid, delete_after_use, tx_out) = trigger_script_internal(
db,
tx_o,
user_db,
authed,
workspace_id,
runnable_path,
args,
retry,
error_handler_path,
error_handler_args.as_ref(),
trigger_path,
job_id,
trigger,
suspended_mode,
)
.await?;
(uuid, delete_after_use, None, tx_out)
};
Ok((uuid, delete_after_use, early_return, tx_out))
}
#[allow(dead_code)]
pub async fn trigger_runnable(
db: &DB,
user_db: Option<UserDB>,
authed: ApiAuthed,
workspace_id: &str,
runnable_path: &str,
is_flow: bool,
args: PushArgsOwned,
retry: Option<&sqlx::types::Json<Retry>>,
error_handler_path: Option<&str>,
error_handler_args: Option<&sqlx::types::Json<HashMap<String, serde_json::Value>>>,
trigger_path: String,
job_id: Option<Uuid>,
suspended_mode: bool,
trigger: TriggerMetadata,
) -> Result<axum::response::Response> {
let uuid = trigger_runnable_inner(
db,
None,
user_db,
authed,
workspace_id,
runnable_path,
is_flow,
args,
retry,
error_handler_path,
error_handler_args,
trigger_path,
job_id,
trigger,
Some(suspended_mode),
)
.await?
.0;
Ok((StatusCode::CREATED, uuid.to_string()).into_response())
}
#[allow(dead_code)]
pub async fn trigger_runnable_and_wait_for_result(
db: &DB,
user_db: Option<UserDB>,
authed: ApiAuthed,
workspace_id: &str,
runnable_path: &str,
is_flow: bool,
args: PushArgsOwned,
retry: Option<&sqlx::types::Json<Retry>>,
error_handler_path: Option<&str>,
error_handler_args: Option<&sqlx::types::Json<HashMap<String, serde_json::Value>>>,
trigger_path: String,
trigger: TriggerMetadata,
) -> Result<axum::response::Response> {
let username = authed.username.clone();
let (uuid, delete_after_use, early_return, _) = trigger_runnable_inner(
db,
None,
user_db,
authed,
workspace_id,
runnable_path,
is_flow,
args,
retry,
error_handler_path,
error_handler_args,
trigger_path,
None,
trigger,
None,
)
.await?;
let (result, success) =
run_wait_result_internal(db, uuid, &workspace_id, early_return, &username).await?;
if delete_after_use.unwrap_or(false) {
delete_job_metadata_after_use(&db, uuid).await?;
}
result_to_response(result, success)
}
#[allow(dead_code)]
pub async fn trigger_runnable_and_wait_for_raw_result(
db: &DB,
user_db: Option<UserDB>,
authed: ApiAuthed,
workspace_id: &str,
runnable_path: &str,
is_flow: bool,
args: PushArgsOwned,
retry: Option<&sqlx::types::Json<Retry>>,
error_handler_path: Option<&str>,
error_handler_args: Option<&sqlx::types::Json<HashMap<String, serde_json::Value>>>,
trigger_path: String,
trigger: TriggerMetadata,
) -> Result<(Box<RawValue>, bool)> {
let username = authed.username.clone();
let (uuid, delete_after_use, early_return, _) = trigger_runnable_inner(
db,
None,
user_db,
authed,
workspace_id,
runnable_path,
is_flow,
args,
retry,
error_handler_path,
error_handler_args,
trigger_path,
None,
trigger,
None,
)
.await?;
let (result, success) =
run_wait_result_internal(db, uuid, &workspace_id, early_return, &username)
.await
.with_context(|| {
format!(
"Error fetching job result for {} {}",
if is_flow { "flow" } else { "script" },
runnable_path
)
})?;
if delete_after_use.unwrap_or(false) {
delete_job_metadata_after_use(&db, uuid).await?;
}
Ok((result, success))
}
pub async fn trigger_runnable_and_wait_for_raw_result_with_error_ctx(
db: &DB,
user_db: Option<UserDB>,
authed: ApiAuthed,
workspace_id: &str,
runnable_path: &str,
is_flow: bool,
args: PushArgsOwned,
retry: Option<&sqlx::types::Json<Retry>>,
error_handler_path: Option<&str>,
error_handler_args: Option<&sqlx::types::Json<HashMap<String, serde_json::Value>>>,
trigger_path: String,
trigger: TriggerMetadata,
) -> Result<Box<RawValue>> {
let (result, success) = trigger_runnable_and_wait_for_raw_result(
db,
user_db,
authed,
workspace_id,
runnable_path,
is_flow,
args,
retry,
error_handler_path,
error_handler_args,
trigger_path,
trigger,
)
.await?;
if !success {
Err(windmill_common::error::Error::internal_err(format!(
"{} {runnable_path} failed: {:?}",
if is_flow { "Flow" } else { "Script" },
result
)))
} else {
Ok(result)
}
}
async fn trigger_script_internal<'c>(
db: &DB,
tx_o: Option<sqlx::Transaction<'c, sqlx::Postgres>>,
user_db: UserDB,
authed: ApiAuthed,
workspace_id: &str,
script_path: &str,
args: PushArgsOwned,
retry: Option<&sqlx::types::Json<Retry>>,
error_handler_path: Option<&str>,
error_handler_args: Option<&sqlx::types::Json<HashMap<String, Box<RawValue>>>>,
trigger_path: String,
job_id: Option<Uuid>,
trigger: TriggerMetadata,
suspended_mode: Option<bool>,
) -> Result<(
Uuid,
Option<bool>,
Option<sqlx::Transaction<'c, sqlx::Postgres>>,
)> {
if retry.is_none() && error_handler_path.is_none() {
let run_query = RunJobQuery { job_id, suspended_mode, ..Default::default() };
let path = StripPath(script_path.to_string());
let (uuid, delete_after_use, tx_out) = push_script_job_by_path_into_queue(
authed,
db.clone(),
tx_o,
user_db,
workspace_id.to_string(),
path,
run_query,
args,
Some(trigger),
)
.await?;
Ok((uuid, delete_after_use, tx_out))
} else {
let (uuid, delete_after_use, tx_out) = trigger_script_with_retry_and_error_handler(
db,
tx_o,
user_db,
authed,
workspace_id,
script_path,
args,
retry,
error_handler_path,
error_handler_args,
trigger_path,
job_id,
trigger,
suspended_mode,
)
.await?;
Ok((uuid, delete_after_use, tx_out))
}
}
async fn trigger_script_with_retry_and_error_handler<'c>(
db: &DB,
tx_o: Option<sqlx::Transaction<'c, sqlx::Postgres>>,
user_db: UserDB,
authed: ApiAuthed,
workspace_id: &str,
script_path: &str,
args: PushArgsOwned,
retry: Option<&sqlx::types::Json<Retry>>,
error_handler_path: Option<&str>,
error_handler_args: Option<&sqlx::types::Json<HashMap<String, Box<RawValue>>>>,
trigger_path: String,
job_id: Option<Uuid>,
trigger: TriggerMetadata,
suspended_mode: Option<bool>,
) -> Result<(
Uuid,
Option<bool>,
Option<sqlx::Transaction<'c, sqlx::Postgres>>,
)> {
#[cfg(feature = "enterprise")]
check_license_key_valid().await?;
check_scopes(&authed, || format!("jobs:run:scripts:{script_path}"))?;
let retry = retry.map(|r| r.0.clone());
let error_handler_path = error_handler_path.map(|p| p.to_string());
let error_handler_args = error_handler_args.map(|args| args.0.clone());
let (job_payload, tag, delete_after_use, timeout, on_behalf_of) = {
let db_authed = UserDbWithAuthed { db: user_db.clone(), authed: &authed.to_authed_ref() };
script_path_to_payload(
script_path,
Some(db_authed),
db.clone(),
&workspace_id,
Some(false),
)
.await?
};
check_tag_available_for_workspace(&db, &workspace_id, &tag, &authed).await?;
let return_tx = tx_o.is_some();
let (email, permissioned_as, push_authed, tx) = if let Some(tx) = tx_o {
(
authed.email.as_str(),
username_to_permissioned_as(&authed.username),
Some(authed.clone().into()),
PushIsolationLevel::Transaction(tx),
)
} else if let Some(on_behalf_of) = on_behalf_of.as_ref() {
(
on_behalf_of.email.as_str(),
on_behalf_of.permissioned_as.clone(),
None,
PushIsolationLevel::IsolatedRoot(db.clone()),
)
} else {
(
authed.email.as_str(),
username_to_permissioned_as(&authed.username),
Some(authed.clone().into()),
PushIsolationLevel::Isolated(user_db, authed.clone().into()),
)
};
let push_args = PushArgs { args: &args.args, extra: args.extra };
let retryable_job_payload = match job_payload {
JobPayload::ScriptHash {
hash,
path,
concurrency_settings,
debouncing_settings,
cache_ttl,
cache_ignore_s3_path,
priority,
apply_preprocessor,
..
} => JobPayload::SingleStepFlow {
path,
hash: Some(hash),
flow_version: None,
args: HashMap::from(&push_args),
retry,
error_handler_path,
error_handler_args,
skip_handler: None,
cache_ttl,
cache_ignore_s3_path,
priority,
tag_override: tag.clone(),
apply_preprocessor,
trigger_path: Some(trigger_path.clone()),
concurrency_settings,
debouncing_settings,
},
_ => {
return Err(windmill_common::error::Error::internal_err(format!(
"Unsupported job payload: {:?}",
job_payload
)))
}
};
let (uuid, tx) = push(
&db,
tx,
&workspace_id,
retryable_job_payload,
push_args,
authed.display_username(),
email,
permissioned_as,
authed.token_prefix.as_deref(),
None,
None,
None,
None,
None,
job_id,
false,
false,
None,
true,
tag,
timeout,
None,
None,
push_authed.as_ref(),
false,
None,
Some(trigger),
suspended_mode,
)
.await?;
// If we were given a transaction, return it; otherwise commit it
if return_tx {
Ok((uuid, delete_after_use, Some(tx)))
} else {
tx.commit().await?;
Ok((uuid, delete_after_use, None))
}
}
@@ -1,263 +0,0 @@
use std::borrow::Cow;
use crate::{
db::{ApiAuthed, DB},
triggers::{Trigger, TriggerCrud, TriggerData},
};
use axum::async_trait;
use itertools::Itertools;
use serde_json::value::RawValue;
use sqlx::{types::Json as SqlxJson, PgConnection};
use tokio_tungstenite::connect_async;
use windmill_common::{
db::UserDB,
error::{Error, Result},
worker::to_raw_value,
};
use windmill_git_sync::DeployedObject;
use super::{
get_url_from_runnable_value, TestWebsocketConfig, WebsocketConfig, WebsocketConfigRequest,
WebsocketTrigger,
};
#[async_trait]
impl TriggerCrud for WebsocketTrigger {
type TriggerConfig = WebsocketConfig;
type Trigger = Trigger<Self::TriggerConfig>;
type TriggerConfigRequest = WebsocketConfigRequest;
type TestConnectionConfig = TestWebsocketConfig;
const TABLE_NAME: &'static str = "websocket_trigger";
const TRIGGER_TYPE: &'static str = "websocket";
const SUPPORTS_SERVER_STATE: bool = true;
const SUPPORTS_TEST_CONNECTION: bool = true;
const ROUTE_PREFIX: &'static str = "/websocket_triggers";
const DEPLOYMENT_NAME: &'static str = "WebSocket trigger";
const ADDITIONAL_SELECT_FIELDS: &[&'static str] = &[
"url",
"filters",
"initial_messages",
"url_runnable_args",
"can_return_message",
"can_return_error_result",
];
const IS_ALLOWED_ON_CLOUD: bool = false;
fn get_deployed_object(path: String) -> DeployedObject {
DeployedObject::WebsocketTrigger { path }
}
async fn validate_config(
&self,
_db: &DB,
config: &Self::TriggerConfigRequest,
_workspace_id: &str,
) -> Result<()> {
if config.url.trim().is_empty() {
return Err(Error::BadRequest(
"WebSocket URL cannot be empty".to_string(),
));
}
if let Some(args) = &config.url_runnable_args {
if !args.is_object() {
return Err(Error::BadRequest(
"url_runnable_args must be an object".to_string(),
));
}
}
Ok(())
}
async fn create_trigger(
&self,
_db: &DB,
tx: &mut PgConnection,
authed: &ApiAuthed,
w_id: &str,
trigger: TriggerData<Self::TriggerConfigRequest>,
) -> Result<()> {
let filters = trigger
.config
.filters
.into_iter()
.map(|v| SqlxJson(serde_json::value::to_raw_value(&v).unwrap()))
.collect_vec();
let initial_messages = trigger
.config
.initial_messages
.unwrap_or_default()
.into_iter()
.map(|v| SqlxJson(serde_json::value::to_raw_value(&v).unwrap()))
.collect_vec();
sqlx::query!(
r#"
INSERT INTO websocket_trigger (
workspace_id,
path,
url,
script_path,
is_flow,
mode,
filters,
initial_messages,
url_runnable_args,
edited_by,
can_return_message,
can_return_error_result,
email,
edited_at,
error_handler_path,
error_handler_args,
retry
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, now(), $14, $15, $16
)
"#,
w_id,
trigger.base.path,
trigger.config.url,
trigger.base.script_path,
trigger.base.is_flow,
trigger.base.mode() as _,
&filters as _,
&initial_messages as _,
trigger
.config
.url_runnable_args
.map(|v| SqlxJson(serde_json::value::to_raw_value(&v).unwrap())) as _,
authed.username,
trigger.config.can_return_message,
trigger.config.can_return_error_result,
authed.email,
trigger.error_handling.error_handler_path,
trigger.error_handling.error_handler_args as _,
trigger.error_handling.retry as _
)
.execute(&mut *tx)
.await?;
Ok(())
}
async fn update_trigger(
&self,
_db: &DB,
tx: &mut PgConnection,
authed: &ApiAuthed,
w_id: &str,
path: &str,
trigger: TriggerData<Self::TriggerConfigRequest>,
) -> Result<()> {
let filters = trigger
.config
.filters
.into_iter()
.map(|v| SqlxJson(serde_json::value::to_raw_value(&v).unwrap()))
.collect_vec();
let initial_messages = trigger
.config
.initial_messages
.unwrap_or_default()
.into_iter()
.map(|v| SqlxJson(serde_json::value::to_raw_value(&v).unwrap()))
.collect_vec();
// important to update server_id to NULL to stop current websocket listener
sqlx::query!(
"
UPDATE
websocket_trigger
SET
url = $1,
script_path = $2,
path = $3,
is_flow = $4,
filters = $5,
initial_messages = $6,
url_runnable_args = $7,
edited_by = $8,
email = $9,
can_return_message = $10,
can_return_error_result = $11,
edited_at = now(),
server_id = NULL,
error = NULL,
error_handler_path = $14,
error_handler_args = $15,
retry = $16
WHERE
workspace_id = $12 AND path = $13
",
trigger.config.url,
trigger.base.script_path,
trigger.base.path,
trigger.base.is_flow,
filters.as_slice() as &[SqlxJson<Box<RawValue>>],
initial_messages.as_slice() as &[SqlxJson<Box<RawValue>>],
trigger
.config
.url_runnable_args
.map(|v| SqlxJson(serde_json::value::to_raw_value(&v).unwrap()))
as Option<SqlxJson<Box<RawValue>>>,
&authed.username,
&authed.email,
trigger.config.can_return_message,
trigger.config.can_return_error_result,
w_id,
path,
trigger.error_handling.error_handler_path,
trigger.error_handling.error_handler_args as _,
trigger.error_handling.retry as _
)
.execute(&mut *tx)
.await?;
Ok(())
}
async fn test_connection(
&self,
db: &DB,
authed: &ApiAuthed,
_user_db: &UserDB,
workspace_id: &str,
config: Self::TestConnectionConfig,
) -> Result<()> {
let url = &config.url;
let connect_url: Cow<str> = if url.starts_with("$") {
if url.starts_with("$flow:") || url.starts_with("$script:") {
let path = url.splitn(2, ':').nth(1).unwrap();
Cow::Owned(
get_url_from_runnable_value(
path,
url.starts_with("$flow:"),
&db,
authed.clone(),
config.url_runnable_args.as_ref().map(to_raw_value).as_ref(),
&workspace_id,
)
.await?,
)
} else {
return Err(Error::BadConfig(format!(
"Invalid WebSocket runnable path: {}",
url
)));
}
} else {
Cow::Borrowed(&url)
};
connect_async(&*connect_url).await.map_err(|err| {
Error::BadConfig(format!(
"Error connecting to WebSocket: {}",
err.to_string()
))
})?;
Ok(())
}
}
@@ -1,477 +0,0 @@
use super::WebsocketTrigger;
use crate::triggers::{
filter::{is_value_superset, Filter, JsonFilter},
listener::ListeningTrigger,
trigger_helpers::{
trigger_runnable, trigger_runnable_and_wait_for_raw_result,
trigger_runnable_and_wait_for_raw_result_with_error_ctx, TriggerJobArgs,
},
websocket::{get_url_from_runnable_value, WebsocketConfig},
Listener,
};
use anyhow::Context;
use async_trait::async_trait;
use futures::{stream::SplitSink, SinkExt, StreamExt};
use http::Response;
use itertools::Itertools;
use serde::Deserialize;
use serde_json::value::RawValue;
use std::{borrow::Cow, collections::HashMap, sync::Arc};
use tokio::{net::TcpStream, sync::RwLock};
use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream};
use windmill_common::{
error::{to_anyhow, Error, Result},
jobs::JobTriggerKind,
triggers::TriggerMetadata,
utils::report_critical_error,
worker::to_raw_value,
DB,
};
use windmill_queue::PushArgsOwned;
impl ListeningTrigger<WebsocketConfig> {
async fn send_initial_messages(
&self,
writer: &mut SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>,
db: &DB,
) -> Result<()> {
let initial_messages: Vec<InitialMessage> = self
.trigger_config
.initial_messages
.as_deref()
.unwrap_or_default()
.iter()
.filter_map(|m| serde_json::from_str(m.get()).ok())
.collect_vec();
let WebsocketConfig { ref url, .. } = self.trigger_config;
let runnable_kind = if self.is_flow { "flow" } else { "script" };
let mut authed_o = None;
for start_message in initial_messages {
match start_message {
InitialMessage::RawMessage(msg) => {
let msg = if msg.starts_with("\"") && msg.ends_with("\"") {
msg[1..msg.len() - 1].to_string()
} else {
msg
};
tracing::info!(
"Sending raw message initial message to WebSocket {}: {}",
url,
msg
);
writer
.send(tokio_tungstenite::tungstenite::Message::Text(msg))
.await
.map_err(to_anyhow)
.with_context(|| "failed to send raw message")?;
}
InitialMessage::RunnableResult { path, is_flow, args } => {
tracing::info!(
"Running {} {} for initial message to WebSocket {}",
runnable_kind,
path,
url,
);
let args = raw_value_to_args_hashmap(Some(&args))?;
if authed_o.is_none() {
authed_o = Some(self.authed(db, "ws").await?);
}
let authed = authed_o.clone().unwrap();
let result = trigger_runnable_and_wait_for_raw_result_with_error_ctx(
db,
None,
authed.clone(),
&self.workspace_id,
&path,
is_flow,
PushArgsOwned { args, extra: None },
None,
None,
None,
"".to_string(), // doesn't matter as no retry/error handler
TriggerMetadata::new(Some(self.path.to_owned()), JobTriggerKind::Websocket),
)
.await
.map(|r| r.get().to_owned())?;
tracing::info!(
"Sending {} {} result to WebSocket {}",
runnable_kind,
path,
url
);
// if the `result` was just a single string, the below removes the surrounding quotes by parsing it as a string.
// it falls back to the original serialized JSON if it doesn't work.
let result = serde_json::from_str::<String>(result.as_str()).unwrap_or(result);
writer
.send(tokio_tungstenite::tungstenite::Message::Text(result))
.await
.map_err(to_anyhow)
.with_context(|| {
format!("Failed to send {} {} result", runnable_kind, path)
})?;
}
}
}
Ok(())
}
}
#[async_trait]
impl Listener for WebsocketTrigger {
type Consumer = (
WebSocketStream<MaybeTlsStream<TcpStream>>,
Response<Option<Vec<u8>>>,
);
type Extra = ReturnMessageChannels;
type ExtraState = ();
const JOB_TRIGGER_KIND: JobTriggerKind = JobTriggerKind::Websocket;
async fn get_consumer(
&self,
db: &DB,
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
err_message: Arc<RwLock<Option<String>>>,
mut killpill_rx: tokio::sync::broadcast::Receiver<()>,
) -> Result<Option<Self::Consumer>> {
let url = &listening_trigger.trigger_config.url;
let connect_url: Cow<str> = if url.starts_with("$") {
if url.starts_with("$flow:") || url.starts_with("$script:") {
tokio::select! {
biased;
_ = killpill_rx.recv() => {
return Ok(None);
},
_ = self.loop_ping(&db, listening_trigger, err_message.clone(), Some(
"Waiting on runnable to return WebSocket URL...".to_string()
)) => {
return Ok(None);
},
url_result = {
let authed = listening_trigger.authed(db, "ws").await?;
let args = listening_trigger.trigger_config.url_runnable_args.as_ref().map(|r| &r.0);
let path = url.splitn(2, ':').nth(1).unwrap();
get_url_from_runnable_value(path, url.starts_with("$flow:"), db, authed, args, &listening_trigger.workspace_id)
} => match url_result {
Ok(url) => Cow::Owned(url),
Err(err) => {
return Err(anyhow::anyhow!("Error getting WebSocket URL from runnable after 5 tries: {:?}", err).into());
}
},
}
} else {
return Err(anyhow::anyhow!("Invalid WebSocket runnable path: {}", url).into());
}
} else {
Cow::Borrowed(&url)
};
let connection = connect_async(&*connect_url)
.await
.map(|conn| Some(conn))
.map_err(|err| to_anyhow(err).into());
connection
}
async fn consume(
&self,
db: &DB,
consumer: Self::Consumer,
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
err_message: Arc<RwLock<Option<String>>>,
mut killpill_rx: tokio::sync::broadcast::Receiver<()>,
_extra_state: Option<&Self::ExtraState>,
) {
let WebsocketConfig { ref url, .. } = listening_trigger.trigger_config;
tracing::info!("Connected to WebSocket {}", url);
let (ws_stream, _) = consumer;
let (mut writer, mut reader) = ws_stream.split();
// send initial messages
if listening_trigger.trigger_mode {
tokio::select! {
biased;
_ = killpill_rx.recv() => {
return;
},
_ = self.loop_ping(db, listening_trigger, err_message.clone(), Some("Sending initial messages...".to_string())) => {
return;
},
result = listening_trigger.send_initial_messages(&mut writer, &db) => {
if let Err(err) = result {
self.disable_with_error(&db, listening_trigger, format!("Error sending initial messages: {:?}", err)).await;
return
} else {
tracing::debug!("Initial messages sent successfully to WebSocket {}", url);
}
}
}
}
let (return_message_channels, message_sender_handle) = if listening_trigger.trigger_mode
&& listening_trigger.trigger_config.can_return_message
{
let (send_message_tx, mut rx) = tokio::sync::mpsc::channel::<String>(100);
let w_id = listening_trigger.workspace_id.clone();
let url = url.clone();
let db = db.clone();
let handle = tokio::spawn(async move {
while let Some(message) = rx.recv().await {
if let Err(err) = writer
.send(tokio_tungstenite::tungstenite::Message::Text(message))
.await
{
report_critical_error(format!("Could not send runnable result to WebSocket {} because of error: {}", url, err), db.clone(), Some(&w_id), None).await;
}
}
});
let killpill_rx = killpill_rx.resubscribe();
let return_message_channels = ReturnMessageChannels { send_message_tx, killpill_rx };
(Some(return_message_channels), Some(handle))
} else {
(None, None)
};
tokio::select! {
biased;
_ = killpill_rx.recv() => {
},
_ = self.loop_ping(db, listening_trigger, err_message.clone(), None) => {
},
_ = async {
let filters: Vec<Filter> = if listening_trigger.trigger_mode {
listening_trigger
.trigger_config
.filters
.iter()
.filter_map(|m| serde_json::from_str(m.get()).ok())
.collect_vec()
} else {
vec![]
};
loop {
if let Some(msg) = reader.next().await {
match msg {
Ok(msg) => {
match msg {
tokio_tungstenite::tungstenite::Message::Text(text) => {
tracing::debug!("Received text message from WebSocket {}: {}", url, text);
let mut should_handle = true;
for filter in &filters {
match filter {
Filter::JsonFilter(JsonFilter { key, value }) => {
let mut deserializer = serde_json::Deserializer::from_str(text.as_str());
should_handle = match is_value_superset(&mut deserializer, key, &value) {
Ok(filter_match) => {
filter_match
},
Err(err) => {
tracing::warn!("Error deserializing filter for WebSocket {}: {:?}", url, err);
false
}
};
}
}
if !should_handle {
break;
}
}
if should_handle {
let trigger_info = HashMap::from([
("url".to_string(), to_raw_value(&listening_trigger.trigger_config.url)),
]);
let _ = self.handle_event(db, listening_trigger, text, trigger_info, return_message_channels.clone()).await;
}
},
a @ _ => {
tracing::debug!("Received non text-message from WebSocket {}: {:?}", url, a);
}
}
},
Err(err) => {
tracing::error!("Error reading from WebSocket {}: {:?}", url, err);
}
}
} else {
tracing::error!("WebSocket {} closed", url);
self.update_ping_and_loop_ping_status(db, listening_trigger, err_message.clone(), Some("WebSocket closed".to_string())).await;
break;
}
}
} => {}
}
// make sure to stop return message handler
if let Some(message_sender_handle) = message_sender_handle {
message_sender_handle.abort();
}
}
async fn handle_trigger(
&self,
db: &DB,
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
payload: Self::Payload,
trigger_info: HashMap<String, Box<RawValue>>,
extra: Option<Self::Extra>,
) -> Result<()> {
let ListeningTrigger {
path,
is_flow,
workspace_id,
trigger_config,
script_path,
error_handling,
suspended_mode,
..
} = listening_trigger;
let WebsocketConfig { url, .. } = trigger_config;
let args = WebsocketTrigger::build_job_args(
&script_path,
*is_flow,
workspace_id,
db,
payload,
trigger_info,
)
.await;
let args = match args {
Ok(args) => args,
Err(err) => {
return Err(err);
}
};
let authed = listening_trigger.authed(db, "ws").await?;
let (retry, error_handler_path, error_handler_args) = match error_handling.as_ref() {
Some(error_handling) => (
error_handling.retry.as_ref(),
error_handling.error_handler_path.as_deref(),
error_handling.error_handler_args.as_ref(),
),
None => (None, None, None),
};
let trigger = TriggerMetadata::new(Some(path.to_owned()), Self::JOB_TRIGGER_KIND);
if *suspended_mode || extra.is_none() {
trigger_runnable(
db,
None,
authed,
&workspace_id,
&script_path,
*is_flow,
args,
retry,
error_handler_path,
error_handler_args,
format!("websocket_trigger/{}", listening_trigger.path),
None,
*suspended_mode,
trigger,
)
.await?;
} else if let Some(ReturnMessageChannels { send_message_tx, mut killpill_rx }) = extra {
let db_ = db.clone();
let url = url.to_owned();
let script_path = script_path.to_owned();
let is_flow = *is_flow;
let w_id = workspace_id.to_owned();
let retry = retry.cloned();
let error_handler_path = error_handler_path.map(|s| s.to_string());
let error_handler_args = error_handler_args.cloned();
let trigger_path = path.clone();
let can_return_error_result = trigger_config.can_return_error_result;
let handle_response_f = async move {
tokio::select! {
_ = killpill_rx.recv() => {
return;
},
result = trigger_runnable_and_wait_for_raw_result(
&db_,
None,
authed,
&w_id,
&script_path,
is_flow,
args,
retry.as_ref(),
error_handler_path.as_deref(),
error_handler_args.as_ref(),
format!("websocket_trigger/{}", trigger_path),
trigger,
) => {
if let Ok((result, success)) = result {
if !success && !can_return_error_result {
return;
}
let result = result.get().to_owned();
// only send the result if it's not null
if result != "null" {
tracing::info!("Sending job result to WebSocket {}", url);
// if the `result` was just a single string, the below removes the surrounding quotes by parsing it as a string.
// it falls back to the original serialized JSON if it doesn't work.
let result = serde_json::from_str::<String>(result.as_str()).unwrap_or(result);
if let Err(err) = send_message_tx.send(result).await {
report_critical_error(format!("Could not send runnable result to WebSocket {} because of error: {}", url, err), db_.clone(), Some(&w_id), None).await;
}
}
}
}
};
};
tokio::spawn(handle_response_f);
}
Ok(())
}
}
pub struct ReturnMessageChannels {
send_message_tx: tokio::sync::mpsc::Sender<String>,
killpill_rx: tokio::sync::broadcast::Receiver<()>,
}
impl Clone for ReturnMessageChannels {
fn clone(&self) -> Self {
Self {
send_message_tx: self.send_message_tx.clone(),
killpill_rx: self.killpill_rx.resubscribe(),
}
}
}
#[derive(Debug, Deserialize)]
enum InitialMessage {
#[serde(rename = "raw_message")]
RawMessage(String),
#[serde(rename = "runnable_result")]
RunnableResult { path: String, args: Box<RawValue>, is_flow: bool },
}
fn raw_value_to_args_hashmap(
args: Option<&Box<RawValue>>,
) -> Result<HashMap<String, Box<RawValue>>> {
let args = if let Some(args) = args {
serde_json::from_str::<Option<HashMap<String, Box<RawValue>>>>(args.get())
.map_err(|e| Error::BadRequest(format!("invalid json: {}", e)))?
.unwrap_or_else(HashMap::new)
} else {
HashMap::new()
};
Ok(args)
}
@@ -1,128 +0,0 @@
use std::collections::HashMap;
use crate::{
db::ApiAuthed,
triggers::trigger_helpers::{
trigger_runnable_and_wait_for_raw_result_with_error_ctx, TriggerJobArgs,
},
};
use serde::{Deserialize, Serialize};
use serde_json::value::RawValue;
use sqlx::{types::Json as SqlxJson, FromRow};
use windmill_common::{
error::{Error, Result},
jobs::JobTriggerKind,
triggers::{TriggerMetadata, TriggerKind},
worker::to_raw_value,
DB,
};
use windmill_queue::PushArgsOwned;
mod handler;
mod listener;
#[derive(Copy, Clone)]
pub struct WebsocketTrigger;
impl TriggerJobArgs for WebsocketTrigger {
type Payload = String;
const TRIGGER_KIND: TriggerKind = TriggerKind::Websocket;
fn v1_payload_fn(payload: &Self::Payload) -> HashMap<String, Box<RawValue>> {
HashMap::from([("msg".to_string(), to_raw_value(&payload))])
}
}
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
pub struct WebsocketConfig {
pub url: String,
#[serde(default)]
pub filters: Vec<SqlxJson<Box<RawValue>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub initial_messages: Option<Vec<SqlxJson<Box<RawValue>>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub url_runnable_args: Option<SqlxJson<Box<RawValue>>>,
#[serde(default)]
pub can_return_message: bool,
#[serde(default)]
pub can_return_error_result: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebsocketConfigRequest {
url: String,
filters: Vec<serde_json::Value>,
initial_messages: Option<Vec<serde_json::Value>>,
url_runnable_args: Option<serde_json::Value>,
can_return_message: bool,
can_return_error_result: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestWebsocketConfig {
url: String,
url_runnable_args: Option<serde_json::Value>,
}
pub fn value_to_args_hashmap(
args: Option<&Box<RawValue>>,
) -> Result<HashMap<String, Box<RawValue>>> {
let args = if let Some(args) = args {
let args_map: Option<HashMap<String, serde_json::Value>> = serde_json::from_str(args.get())
.map_err(|e| Error::BadRequest(format!("invalid json: {}", e)))?;
args_map
.unwrap_or_else(HashMap::new)
.into_iter()
.map(|(k, v)| {
let raw_value = serde_json::value::to_raw_value(&v).map_err(|e| {
Error::BadRequest(format!("failed to convert to raw value: {}", e))
})?;
Ok((k, raw_value))
})
.collect::<Result<HashMap<String, Box<RawValue>>>>()
} else {
Ok(HashMap::new())
}?;
Ok(args)
}
pub async fn get_url_from_runnable_value(
path: &str,
is_flow: bool,
db: &DB,
authed: ApiAuthed,
args: Option<&Box<RawValue>>,
workspace_id: &str,
) -> Result<String> {
tracing::info!(
"Running {} {} to get WebSocket URL",
if is_flow { "flow" } else { "script" },
path
);
let args = value_to_args_hashmap(args)?;
let result = trigger_runnable_and_wait_for_raw_result_with_error_ctx(
db,
None,
authed,
workspace_id,
path,
is_flow,
PushArgsOwned { args, extra: None },
None,
None,
None,
"".to_string(), // doesn't matter as no retry/error handler
TriggerMetadata::new(Some(path.to_owned()), JobTriggerKind::Websocket),
)
.await?;
serde_json::from_str::<String>(result.get()).map_err(|_| {
Error::BadConfig(format!(
"{} {} did not return a string",
if is_flow { "Flow" } else { "Script" },
path,
))
})
}