Files
windmill/backend/windmill-api/src/http_triggers.rs
dieriba e8f54924d3 feat: windmill http triggers and webhooks to openapi spec (#5918)
* add sum and description to http routes

* add to openapi spec

* add subsection

* add collaspable and action button

* added path rendering,

* migrate logic to backend

* remover server and handle different format generation

* add filter for http route and document generated

* remove print

* handled webhook and integrated server component

* done

* add download and copy

* nits

* update .sqlx

* remove .vscode

* fix npm check

* add summary description and fix toggle

* added security handling and nits

* update .sqlx

* nits and rename key

* remove unused code

* update ref

* nits rename var

* nits

* nits

* nits

* nits

* nits

* add token generation for cURL command

* create token

* update label and remove section

* update repo ref

* clean

* let brower start download before cleaning up resource

* handle sync/async for webhook

* format fix

* nits

* nits

* reset sum and description
2025-06-17 19:49:00 +02:00

1447 lines
44 KiB
Rust

#[cfg(feature = "http_trigger")]
use crate::http_trigger_args::{HttpMethod, RawHttpTriggerArgs};
#[cfg(feature = "parquet")]
use crate::job_helpers_oss::get_workspace_s3_resource;
use crate::resources::try_get_resource_from_db_as;
use crate::trigger_helpers::{get_runnable_format, RunnableId};
use crate::utils::{non_empty_str, ExpiringCacheEntry};
use crate::{
auth::{AuthCache, OptTokened},
db::{ApiAuthed, DB},
jobs::{
run_flow_by_path_inner, run_script_by_path_inner, run_wait_result_flow_by_path_internal,
run_wait_result_script_by_path_internal, RunJobQuery,
},
users::fetch_api_authed,
};
use anyhow::anyhow;
use axum::response::Response;
use axum::{
extract::{Path, Query},
response::IntoResponse,
routing::{delete, get, post},
Extension, Json, Router,
};
#[cfg(feature = "parquet")]
use http::header::IF_NONE_MATCH;
use http::{HeaderMap, StatusCode};
use quick_cache::sync::Cache;
use serde::{Deserialize, Serialize};
use sql_builder::{bind::Bind, SqlBuilder};
use sqlx::prelude::FromRow;
use sqlx::PgConnection;
use std::borrow::Cow;
use std::collections::HashSet;
use std::{collections::HashMap, sync::Arc};
use tokio::sync::{RwLock, RwLockReadGuard};
use tower_http::cors::CorsLayer;
use windmill_audit::{audit_oss::audit_log, ActionKind};
use windmill_common::error::{Error, Result as WindmillResult};
#[cfg(feature = "parquet")]
use windmill_common::s3_helpers::build_object_store_client;
use windmill_common::{
db::UserDB,
error::{self, JsonResult},
s3_helpers::S3Object,
triggers::TriggerKind,
utils::{empty_as_none, not_found_if_none, paginate, require_admin, Pagination, StripPath},
worker::CLOUD_HOSTED,
};
use windmill_git_sync::handle_deployment_metadata;
lazy_static::lazy_static! {
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]+)*$").unwrap();
}
pub fn routes_global_service() -> Router {
let cors = CorsLayer::new()
.allow_methods([
http::Method::GET,
http::Method::POST,
http::Method::DELETE,
http::Method::PUT,
http::Method::PATCH,
])
.allow_headers([http::header::CONTENT_TYPE, http::header::AUTHORIZATION])
.allow_origin(tower_http::cors::Any);
Router::new()
.route(
"/*path",
get(route_job)
.post(route_job)
.delete(route_job)
.put(route_job)
.patch(route_job)
.head(|| async { "" }),
)
.layer(cors)
}
pub fn workspaced_service() -> Router {
Router::new()
.route("/create", post(create_trigger))
.route("/create_many", post(create_many_http_trigger))
.route("/list", get(list_triggers))
.route("/get/*path", get(get_trigger))
.route("/update/*path", post(update_trigger))
.route("/delete/*path", delete(delete_trigger))
.route("/exists/*path", get(exists_trigger))
.route("/route_exists", post(exists_route))
}
#[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, Deserialize)]
struct NewTrigger {
path: String,
route_path: String,
script_path: String,
is_flow: bool,
is_async: bool,
authentication_resource_path: Option<String>,
authentication_method: AuthenticationMethod,
static_asset_config: Option<sqlx::types::Json<S3Object>>,
http_method: HttpMethod,
workspaced_route: Option<bool>,
summary: Option<String>,
description: Option<String>,
is_static_website: bool,
wrap_body: Option<bool>,
raw_string: Option<bool>,
}
#[derive(FromRow, Serialize)]
pub struct HttpTrigger {
pub workspace_id: String,
pub path: String,
pub route_path: String,
pub route_path_key: String,
pub script_path: String,
pub is_flow: bool,
pub edited_by: String,
pub email: String,
pub edited_at: chrono::DateTime<chrono::Utc>,
pub extra_perms: serde_json::Value,
pub is_async: bool,
pub authentication_method: AuthenticationMethod,
pub http_method: HttpMethod,
pub summary: Option<String>,
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub static_asset_config: Option<sqlx::types::Json<S3Object>>,
pub is_static_website: bool,
pub authentication_resource_path: Option<String>,
pub workspaced_route: bool,
pub wrap_body: bool,
pub raw_string: bool,
}
#[derive(Deserialize)]
struct EditTrigger {
path: String,
route_path: Option<String>,
script_path: String,
is_flow: bool,
is_async: bool,
authentication_method: AuthenticationMethod,
#[serde(deserialize_with = "non_empty_str")]
authentication_resource_path: Option<String>,
summary: Option<String>,
description: Option<String>,
http_method: HttpMethod,
static_asset_config: Option<sqlx::types::Json<S3Object>>,
workspaced_route: Option<bool>,
is_static_website: bool,
wrap_body: Option<bool>,
raw_string: Option<bool>,
}
#[derive(Deserialize)]
pub struct ListTriggerQuery {
pub page: Option<usize>,
pub per_page: Option<usize>,
pub path: Option<String>,
pub is_flow: Option<bool>,
#[serde(default, deserialize_with = "empty_as_none")]
pub path_start: Option<String>,
}
async fn list_triggers(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Query(lst): Query<ListTriggerQuery>,
) -> error::JsonResult<Vec<HttpTrigger>> {
let mut tx = user_db.begin(&authed).await?;
let (per_page, offset) = paginate(Pagination { per_page: lst.per_page, page: lst.page });
let mut sqlb = SqlBuilder::select_from("http_trigger")
.fields(&[
"workspace_id",
"path",
"route_path",
"route_path_key",
"workspaced_route",
"wrap_body",
"raw_string",
"script_path",
"summary",
"description",
"is_flow",
"http_method",
"edited_by",
"email",
"edited_at",
"extra_perms",
"is_async",
"authentication_method",
"static_asset_config",
"is_static_website",
"authentication_resource_path",
])
.order_by("edited_at", true)
.and_where("workspace_id = ?".bind(&w_id))
.offset(offset)
.limit(per_page)
.clone();
if let Some(path) = lst.path {
sqlb.and_where_eq("script_path", "?".bind(&path));
}
if let Some(is_flow) = lst.is_flow {
sqlb.and_where_eq("is_flow", "?".bind(&is_flow));
}
if let Some(path_start) = &lst.path_start {
sqlb.and_where_like_left("path", path_start);
}
let sql = sqlb
.sql()
.map_err(|e| error::Error::internal_err(e.to_string()))?;
let rows = sqlx::query_as::<_, HttpTrigger>(&sql)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(rows))
}
async fn get_trigger(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> error::JsonResult<HttpTrigger> {
let mut tx = user_db.begin(&authed).await?;
let path = path.to_path();
let trigger = sqlx::query_as!(
HttpTrigger,
r#"
SELECT
workspace_id,
path,
route_path,
route_path_key,
workspaced_route,
script_path,
summary,
description,
is_flow,
http_method as "http_method: _",
edited_by,
email,
edited_at,
extra_perms,
is_async,
authentication_method as "authentication_method: _",
static_asset_config as "static_asset_config: _",
is_static_website,
authentication_resource_path,
wrap_body,
raw_string
FROM
http_trigger
WHERE
workspace_id = $1 AND
path = $2
"#,
w_id,
path,
)
.fetch_optional(&mut *tx)
.await?;
tx.commit().await?;
let trigger = not_found_if_none(trigger, "Trigger", path)?;
Ok(Json(trigger))
}
fn validate_authentication_method(
authentication_method: AuthenticationMethod,
raw_string: Option<bool>,
) -> WindmillResult<()> {
match (authentication_method, raw_string) {
(AuthenticationMethod::CustomScript, raw) if !raw.unwrap_or(false) == true => {
return Err(Error::BadRequest(
"To use custom script authentication, please enable the raw body option."
.to_string(),
));
}
_ => {}
}
Ok(())
}
async fn increase_trigger_version(tx: &mut PgConnection) -> WindmillResult<()> {
sqlx::query!("SELECT nextval('http_trigger_version_seq')",)
.fetch_one(tx)
.await?;
Ok(())
}
async fn create_trigger_inner(
tx: &mut PgConnection,
w_id: &str,
authed: &ApiAuthed,
new_http_trigger: &NewTrigger,
route_path_key: &str,
) -> WindmillResult<()> {
sqlx::query!(
r#"
INSERT INTO http_trigger (
workspace_id,
path,
route_path,
route_path_key,
workspaced_route,
authentication_resource_path,
wrap_body,
raw_string,
script_path,
summary,
description,
is_flow,
is_async,
authentication_method,
http_method,
static_asset_config,
edited_by,
email,
edited_at,
is_static_website
)
VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, now(), $19
)
"#,
w_id,
new_http_trigger.path,
new_http_trigger.route_path,
&route_path_key,
new_http_trigger.workspaced_route,
new_http_trigger.authentication_resource_path,
new_http_trigger.wrap_body.unwrap_or(false),
new_http_trigger.raw_string.unwrap_or(false),
new_http_trigger.script_path,
new_http_trigger.summary,
new_http_trigger.description,
new_http_trigger.is_flow,
new_http_trigger.is_async,
new_http_trigger.authentication_method as _,
new_http_trigger.http_method as _,
new_http_trigger.static_asset_config as _,
&authed.username,
&authed.email,
new_http_trigger.is_static_website
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
authed,
"http_triggers.create",
ActionKind::Create,
&w_id,
Some(new_http_trigger.path.as_str()),
None,
)
.await?;
increase_trigger_version(tx).await?;
Ok(())
}
fn check_no_duplicates<'trigger>(
new_http_triggers: &[NewTrigger],
route_path_key: &[Cow<'trigger, str>],
) -> Result<(), Error> {
let mut seen = HashSet::with_capacity(new_http_triggers.len());
for (i, trigger) in new_http_triggers.iter().enumerate() {
if !seen.insert((
&route_path_key[i],
trigger.http_method,
trigger.workspaced_route,
)) {
return Err(Error::BadRequest(format!(
"Duplicate HTTP route detected: '{}'. Each HTTP route must have a unique 'route_path'.",
&trigger.route_path
)));
}
}
Ok(())
}
async fn create_many_http_trigger(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Json(new_http_triggers): Json<Vec<NewTrigger>>,
) -> WindmillResult<(StatusCode, String)> {
require_admin(authed.is_admin, &authed.username)?;
let error_wrapper = |path: &str, error: Error| -> Error {
anyhow!(
"Error occurred for HTTP route at route path: {}, error: {}",
path,
error
)
.into()
};
let mut route_path_keys = Vec::with_capacity(new_http_triggers.len());
for new_http_trigger in new_http_triggers.iter() {
let route_path_key = validate_http_trigger(&db, &w_id, new_http_trigger)
.await
.map_err(|err| error_wrapper(&new_http_trigger.route_path, err))?;
route_path_keys.push(route_path_key);
}
check_no_duplicates(&new_http_triggers, &route_path_keys)?;
let mut tx = user_db.begin(&authed).await?;
for (i, new_http_trigger) in new_http_triggers.iter().enumerate() {
create_trigger_inner(
&mut tx,
&w_id,
&authed,
new_http_trigger,
&route_path_keys[i],
)
.await
.map_err(|err| error_wrapper(&new_http_trigger.route_path, err))?;
}
tx.commit().await?;
for http_trigger in new_http_triggers.into_iter() {
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
&w_id,
windmill_git_sync::DeployedObject::HttpTrigger { path: http_trigger.path.clone() },
Some(format!("HTTP route '{}' created", http_trigger.path)),
true,
)
.await?;
}
Ok((StatusCode::CREATED, format!("Created all HTTP routes")))
}
async fn validate_http_trigger<'trigger>(
db: &DB,
w_id: &str,
new_http_trigger: &'trigger NewTrigger,
) -> WindmillResult<Cow<'trigger, str>> {
if !VALID_ROUTE_PATH_RE.is_match(&new_http_trigger.route_path) {
return Err(error::Error::BadRequest("Invalid route path".to_string()));
}
validate_authentication_method(
new_http_trigger.authentication_method,
new_http_trigger.raw_string,
)?;
// route path key is extracted from the route path to check for uniqueness
// it replaces /?:{key} with :key
// it will also remove the leading / if present, not an issue as we only allow : after slashes
let route_path_key = ROUTE_PATH_KEY_RE.replace_all(&new_http_trigger.route_path, ":key");
let exists = route_path_key_exists(
&route_path_key,
&new_http_trigger.http_method,
&w_id,
None,
new_http_trigger.workspaced_route,
db,
)
.await?;
if exists {
return Err(error::Error::BadRequest(
"A route already exists with this path".to_string(),
));
}
if *CLOUD_HOSTED
&& (new_http_trigger.is_static_website || new_http_trigger.static_asset_config.is_some())
{
return Err(error::Error::BadRequest(
"Static website and static asset are not supported on cloud".to_string(),
));
}
Ok(route_path_key)
}
async fn create_trigger(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Json(new_http_trigger): Json<NewTrigger>,
) -> WindmillResult<(StatusCode, String)> {
require_admin(authed.is_admin, &authed.username)?;
let route_path_key = validate_http_trigger(&db, &w_id, &new_http_trigger).await?;
let mut tx = user_db.begin(&authed).await?;
let http_trigger_path = new_http_trigger.path.clone();
create_trigger_inner(&mut tx, &w_id, &authed, &new_http_trigger, &route_path_key).await?;
tx.commit().await?;
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
&w_id,
windmill_git_sync::DeployedObject::HttpTrigger { path: new_http_trigger.path.clone() },
Some(format!("HTTP route '{}' created", new_http_trigger.path)),
true,
)
.await?;
Ok((StatusCode::CREATED, format!("{}", http_trigger_path)))
}
async fn update_trigger(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
Json(ct): Json<EditTrigger>,
) -> WindmillResult<String> {
let path = path.to_path();
if *CLOUD_HOSTED && (ct.is_static_website || ct.static_asset_config.is_some()) {
return Err(error::Error::BadRequest(
"Static website and static asset are not supported on cloud".to_string(),
));
}
validate_authentication_method(ct.authentication_method, ct.raw_string)?;
let mut tx;
if authed.is_admin {
let Some(route_path) = ct.route_path else {
return Err(error::Error::BadRequest(
"route_path is required".to_string(),
));
};
if !VALID_ROUTE_PATH_RE.is_match(&route_path) {
return Err(error::Error::BadRequest("Invalid route path".to_string()));
}
let route_path_key = ROUTE_PATH_KEY_RE.replace_all(&route_path, ":key");
let exists = route_path_key_exists(
&route_path_key,
&ct.http_method,
&w_id,
Some(&path),
ct.workspaced_route,
&db,
)
.await?;
if exists {
return Err(error::Error::BadRequest(
"A route already exists with this path".to_string(),
));
}
tx = user_db.begin(&authed).await?;
sqlx::query!(
r#"
UPDATE
http_trigger
SET
route_path = $1,
route_path_key = $2,
workspaced_route = $3,
wrap_body = $4,
raw_string = $5,
authentication_resource_path = $6,
script_path = $7,
path = $8,
is_flow = $9,
http_method = $10,
static_asset_config = $11,
edited_by = $12,
email = $13,
is_async = $14,
authentication_method = $15,
summary = $16,
description = $17,
edited_at = now(),
is_static_website = $18
WHERE
workspace_id = $19 AND
path = $20
"#,
route_path,
&route_path_key,
ct.workspaced_route,
ct.wrap_body,
ct.raw_string,
ct.authentication_resource_path,
ct.script_path,
ct.path,
ct.is_flow,
ct.http_method as _,
ct.static_asset_config as _,
&authed.username,
&authed.email,
ct.is_async,
ct.authentication_method as _,
ct.summary,
ct.description,
ct.is_static_website,
w_id,
path,
)
.execute(&mut *tx)
.await?;
} else {
tx = user_db.begin(&authed).await?;
sqlx::query!(
r#"
UPDATE
http_trigger
SET
workspaced_route = $1,
wrap_body = $2,
raw_string = $3,
authentication_resource_path = $4,
script_path = $5,
path = $6,
is_flow = $7,
http_method = $8,
static_asset_config = $9,
edited_by = $10,
email = $11,
is_async = $12,
authentication_method = $13,
edited_at = now(),
is_static_website = $14
WHERE
workspace_id = $15 AND
path = $16
"#,
ct.workspaced_route,
ct.wrap_body,
ct.raw_string,
ct.authentication_resource_path,
ct.script_path,
ct.path,
ct.is_flow,
ct.http_method as _,
ct.static_asset_config as _,
&authed.username,
&authed.email,
ct.is_async,
ct.authentication_method as _,
ct.is_static_website,
w_id,
path,
)
.execute(&mut *tx)
.await?;
}
audit_log(
&mut *tx,
&authed,
"http_triggers.update",
ActionKind::Update,
&w_id,
Some(&ct.path),
None,
)
.await?;
increase_trigger_version(&mut tx).await?;
tx.commit().await?;
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
&w_id,
windmill_git_sync::DeployedObject::HttpTrigger { path: ct.path.clone() },
Some(format!("HTTP route '{}' updated", ct.path)),
true,
)
.await?;
Ok(ct.path.to_string())
}
async fn delete_trigger(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> WindmillResult<String> {
require_admin(authed.is_admin, &authed.username)?;
let path = path.to_path();
let mut tx = user_db.begin(&authed).await?;
sqlx::query!(
"DELETE FROM http_trigger
WHERE workspace_id = $1
AND path = $2",
w_id,
path
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&authed,
"http_triggers.delete",
ActionKind::Delete,
&w_id,
Some(path),
None,
)
.await?;
increase_trigger_version(&mut tx).await?;
tx.commit().await?;
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
&w_id,
windmill_git_sync::DeployedObject::HttpTrigger { path: path.to_string() },
Some(format!("HTTP route '{}' deleted", path)),
true,
)
.await?;
Ok(format!("HTTP route {path} deleted"))
}
async fn exists_trigger(
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> JsonResult<bool> {
let path = path.to_path();
let exists = sqlx::query_scalar!(
"SELECT EXISTS(
SELECT 1 FROM http_trigger
WHERE path = $1 AND workspace_id = $2
)",
path,
w_id
)
.fetch_one(&db)
.await?
.unwrap_or(false);
Ok(Json(exists))
}
#[derive(Deserialize)]
struct RouteExists {
route_path: String,
http_method: HttpMethod,
trigger_path: Option<String>,
workspaced_route: Option<bool>,
}
async fn route_path_key_exists(
route_path_key: &str,
http_method: &HttpMethod,
w_id: &str,
trigger_path: Option<&str>,
workspaced_route: Option<bool>,
db: &DB,
) -> WindmillResult<bool> {
let exists = if *CLOUD_HOSTED {
sqlx::query_scalar!(
r#"
SELECT EXISTS(
SELECT 1
FROM http_trigger
WHERE
route_path_key = $1
AND workspace_id = $2
AND http_method = $3
AND ($4::TEXT IS NULL OR path != $4)
)
"#,
&route_path_key,
w_id,
http_method as &HttpMethod,
trigger_path
)
.fetch_one(db)
.await?
.unwrap_or(false)
} else {
let route_path_key = match workspaced_route {
Some(true) => Cow::Owned(format!("{}/{}", w_id, route_path_key.trim_matches('/'))),
_ => Cow::Borrowed(route_path_key),
};
sqlx::query_scalar!(
r#"
SELECT EXISTS(
SELECT 1
FROM http_trigger
WHERE
((workspaced_route IS TRUE AND workspace_id || '/' || route_path_key = $1)
OR (workspaced_route IS FALSE AND route_path_key = $1))
AND http_method = $2
AND ($3::TEXT IS NULL OR path != $3)
)
"#,
&route_path_key,
http_method as &HttpMethod,
trigger_path
)
.fetch_one(db)
.await?
.unwrap_or(false)
};
Ok(exists)
}
async fn exists_route(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Json(RouteExists { route_path, http_method, trigger_path, workspaced_route }): Json<
RouteExists,
>,
) -> JsonResult<bool> {
let route_path_key = ROUTE_PATH_KEY_RE.replace_all(route_path.as_str(), ":key");
let exists = route_path_key_exists(
&route_path_key,
&http_method,
&w_id,
trigger_path.as_deref(),
workspaced_route,
&db,
)
.await?;
Ok(Json(exists))
}
#[derive(Debug, Deserialize, Clone)]
pub struct TriggerRoute {
path: String,
script_path: String,
is_flow: bool,
route_path: String,
workspace_id: String,
is_async: bool,
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,
}
pub struct RoutersCache {
routers: HashMap<HttpMethod, matchit::Router<TriggerRoute>>,
version: i64,
}
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<crate::http_trigger_auth::AuthenticationMethod>> = Cache::new(100);
static ref HTTP_ROUTERS_CACHE: RwLock<RoutersCache> = RwLock::new(RoutersCache {
routers: HashMap::new(),
version: 0,
});
}
pub async fn refresh_routers_loop(
db: &DB,
mut killpill_rx: tokio::sync::broadcast::Receiver<()>,
) -> () {
match refresh_routers(db).await {
Ok(_) => {
tracing::info!("Loaded HTTP routers");
}
Err(err) => {
tracing::error!("Error loading HTTP routers: {err:#}");
}
};
let db = db.clone();
tokio::spawn(async move {
loop {
tokio::select! {
_ = killpill_rx.recv() => {
break;
}
_ = tokio::time::sleep(std::time::Duration::from_secs(60)) => {
match refresh_routers(&db).await {
Ok((true, _)) => {
tracing::info!("Refreshed HTTP routers");
}
Err(err) => {
tracing::error!("Error refreshing HTTP routers: {err:#}");
}
_ => {}
}
}
}
}
});
}
pub async fn refresh_routers(db: &DB) -> Result<(bool, RwLockReadGuard<'_, RoutersCache>), Error> {
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,
is_async,
authentication_method AS "authentication_method: _",
edited_by,
email,
static_asset_config AS "static_asset_config: _",
wrap_body,
raw_string,
workspaced_route,
is_static_website
FROM
http_trigger
WHERE
http_method = $1
"#,
&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))
}
}
async fn get_http_route_trigger(
route_path: &str,
auth_cache: &Arc<AuthCache>,
token: Option<&String>,
db: &DB,
user_db: UserDB,
method: &http::Method,
) -> WindmillResult<(TriggerRoute, String, HashMap<String, String>, ApiAuthed)> {
let http_method: HttpMethod = method.try_into()?;
let requested_path = format!("/{}", route_path);
let routers_cache = HTTP_ROUTERS_CACHE.read().await;
let routers_cache = if routers_cache.routers.is_empty() {
tracing::warn!("HTTP routers are not loaded, loading from db");
let (_, routers_cache) = refresh_routers(db).await?;
routers_cache
} else {
routers_cache
};
let router = routers_cache
.routers
.get(&http_method)
.ok_or(error::Error::internal_err(
"HTTP routers could not be loaded".to_string(),
))?;
let trigger_match = router.at(requested_path.as_str()).ok();
let matchit::Match { value: trigger, params } =
not_found_if_none(trigger_match, "Trigger", requested_path.as_str())?;
let params: HashMap<String, String> = params
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
let username_override = if let AuthenticationMethod::Windmill = trigger.authentication_method {
let opt_authed = if let Some(token) = token {
auth_cache
.get_authed(Some(trigger.workspace_id.clone()), token)
.await
} else {
None
};
if let Some(authed) = opt_authed {
// check that the user has access to the trigger
let cache_key = (
trigger.workspace_id.clone(),
trigger.path.clone(),
authed.clone(),
);
let exists = match HTTP_ACCESS_CACHE.get(&cache_key) {
Some(cache_entry) if cache_entry.expiry > std::time::Instant::now() => {
tracing::debug!("HTTP access cache hit for route {}", trigger.path);
true
}
_ => {
tracing::debug!("HTTP access cache miss for route {}", trigger.path);
let mut tx = user_db.begin(&authed).await?;
let exists = sqlx::query_scalar!(
r#"
SELECT EXISTS(
SELECT 1
FROM
http_trigger
WHERE
workspace_id = $1 AND
path = $2
)
"#,
trigger.workspace_id,
trigger.path
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(false);
if exists {
HTTP_ACCESS_CACHE.insert(
cache_key,
ExpiringCacheEntry {
value: (),
expiry: std::time::Instant::now()
+ std::time::Duration::from_secs(10),
},
);
}
exists
}
};
if exists {
Some(authed.display_username().to_owned())
} else {
return Err(error::Error::NotAuthorized("Unauthorized".to_string()));
}
} else {
return Err(error::Error::NotAuthorized(
"Requires authentication".to_string(),
));
}
} else {
None
};
let authed = fetch_api_authed(
trigger.edited_by.clone(),
trigger.email.clone(),
&trigger.workspace_id,
&db,
Some(username_override.unwrap_or(format!("HTTP-{}", trigger.path))),
)
.await?;
Ok((trigger.clone(), route_path.to_string(), params, authed))
}
async fn route_job(
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Extension(auth_cache): Extension<Arc<AuthCache>>,
OptTokened { token }: OptTokened,
Path(route_path): Path<StripPath>,
headers: HeaderMap,
args: RawHttpTriggerArgs,
) -> Result<impl IntoResponse, Response> {
let route_path = route_path.to_path().trim_end_matches("/");
let (trigger, called_path, params, authed) = get_http_route_trigger(
route_path,
&auth_cache,
token.as_ref(),
&db,
user_db.clone(),
&args.0.metadata.method,
)
.await
.map_err(|e| e.into_response())?;
if trigger.script_path.is_empty() && trigger.static_asset_config.is_none() {
return Err(Error::NotFound(format!(
"Runnable path of HTTP route at path: {}",
trigger.path
))
.into_response());
}
let args = args
.process_args(
&authed,
&db,
&trigger.workspace_id,
match trigger.authentication_method {
AuthenticationMethod::CustomScript | AuthenticationMethod::Signature => true,
_ => trigger.raw_string,
},
)
.await
.map_err(|e| e.into_response())?;
match trigger.authentication_method {
AuthenticationMethod::None
| AuthenticationMethod::Windmill
| AuthenticationMethod::CustomScript => {}
_ => {
let resource_path = match trigger.authentication_resource_path {
Some(resource_path) => resource_path,
None => {
return Err(Error::BadRequest(
"Missing authentication resource path".to_string(),
)
.into_response())
}
};
let cache_key = (
trigger.workspace_id.clone(),
resource_path.clone(),
authed.clone(),
);
let authentication_method = match HTTP_AUTH_CACHE.get(&cache_key) {
Some(cache_entry) if cache_entry.expiry > std::time::Instant::now() => {
tracing::debug!("HTTP auth method cache hit for route {}", trigger.path);
cache_entry.value
}
_ => {
tracing::debug!("HTTP auth method cache miss for route {}", trigger.path);
let auth_method = try_get_resource_from_db_as::<
crate::http_trigger_auth::AuthenticationMethod,
>(
&authed,
Some(user_db.clone()),
&db,
&resource_path,
&trigger.workspace_id,
)
.await
.map_err(|e| e.into_response())?;
HTTP_AUTH_CACHE.insert(
cache_key,
ExpiringCacheEntry {
value: auth_method.clone(),
expiry: std::time::Instant::now() + std::time::Duration::from_secs(60),
},
);
auth_method
}
};
let raw_payload = args.0.metadata.raw_string.as_ref();
let response = authentication_method
.authenticate_http_request(&headers, raw_payload)
.map_err(|e| e.into_response())?;
if let Some(response) = response {
return Ok(response);
}
}
}
#[cfg(not(feature = "parquet"))]
if trigger.static_asset_config.is_some() {
return Err(error::Error::internal_err(
"Static asset configuration is not supported in this build".to_string(),
)
.into_response());
}
#[cfg(feature = "parquet")]
if let Some(sqlx::types::Json(config)) = trigger.static_asset_config {
let build_static_response_f = async {
let (_, s3_resource_opt) = get_workspace_s3_resource(
&authed,
&db,
None,
&"NO_TOKEN".to_string(), // no token is provided in this case
&trigger.workspace_id,
config.storage,
)
.await?;
let s3_resource = s3_resource_opt.ok_or(error::Error::internal_err(
"No files storage resource defined at the workspace level".to_string(),
))?;
let s3_client = build_object_store_client(&s3_resource).await?;
let path = if trigger.is_static_website {
let subpath = params
.get("wm_subpath")
.cloned()
.unwrap_or("index.html".to_string());
tracing::info!("subpath: {}", subpath);
format!("{}/{}", config.s3.trim_end_matches('/'), subpath)
} else {
config.s3.clone()
};
let path = object_store::path::Path::from(path);
let s3_object = s3_client.get(&path).await;
let s3_object = match s3_object {
Err(object_store::Error::NotFound { .. }) if trigger.is_static_website => {
// fallback to index.html if the file is not found
let path = object_store::path::Path::from(format!(
"{}/index.html",
config.s3.trim_end_matches('/')
));
s3_client.get(&path).await
}
r => r,
};
let s3_object = s3_object.map_err(|err| {
tracing::warn!("Error retrieving file from S3: {:?}", err);
error::Error::internal_err(format!("Error retrieving file: {}", err.to_string()))
})?;
let mut response_headers = http::HeaderMap::new();
if let Some(ref e_tag) = s3_object.meta.e_tag {
if let Some(if_none_match) = headers.get(IF_NONE_MATCH) {
if if_none_match == e_tag {
return Ok::<_, error::Error>((
StatusCode::NOT_MODIFIED,
response_headers,
axum::body::Body::empty(),
));
}
}
if let Ok(e_tag) = e_tag.parse() {
response_headers.insert("etag", e_tag);
}
}
response_headers.insert(
"content-type",
s3_object
.attributes
.get(&object_store::Attribute::ContentType)
.map(|s| s.parse().ok())
.flatten()
.unwrap_or("application/octet-stream".parse().unwrap()),
);
if !trigger.is_static_website {
response_headers.insert(
"content-disposition",
config.filename.as_ref().map_or_else(
|| {
s3_object
.attributes
.get(&object_store::Attribute::ContentDisposition)
.map(|s| s.parse().ok())
.flatten()
.unwrap_or("inline".parse().unwrap())
},
|filename| {
format!("inline; filename=\"{}\"", filename)
.parse()
.unwrap_or("inline".parse().unwrap())
},
),
);
}
let body_stream = axum::body::Body::from_stream(s3_object.into_stream());
Ok::<_, error::Error>((StatusCode::OK, response_headers, body_stream))
};
match build_static_response_f.await {
Ok((status, headers, body_stream)) => {
return Ok((status, headers, body_stream).into_response())
}
Err(e) => return Err(e.into_response()),
}
}
let runnable_format = get_runnable_format(
if trigger.is_flow {
RunnableId::from_flow_path(&trigger.script_path)
} else {
RunnableId::from_script_path(&trigger.script_path)
},
&trigger.workspace_id,
&db,
&TriggerKind::Http,
)
.await
.map_err(|e| e.into_response())?;
let args = args
.to_args_from_format(
&trigger.route_path,
&called_path,
&params,
runnable_format,
trigger.wrap_body,
)
.map_err(|e| e.into_response())?;
let run_query = RunJobQuery::default();
let response = if trigger.is_flow {
if trigger.is_async {
run_flow_by_path_inner(
authed,
db,
user_db,
trigger.workspace_id.clone(),
StripPath(trigger.script_path.to_owned()),
run_query,
args,
)
.await
.into_response()
} else {
run_wait_result_flow_by_path_internal(
db,
run_query,
StripPath(trigger.script_path.to_owned()),
authed,
user_db,
args,
trigger.workspace_id.clone(),
)
.await
.into_response()
}
} else {
if trigger.is_async {
run_script_by_path_inner(
authed,
db,
user_db,
trigger.workspace_id.clone(),
StripPath(trigger.script_path.to_owned()),
run_query,
args,
)
.await
.into_response()
} else {
run_wait_result_script_by_path_internal(
db,
run_query,
StripPath(trigger.script_path.to_owned()),
authed,
user_db,
trigger.workspace_id.clone(),
args,
)
.await
.into_response()
}
};
Ok(response)
}