Compare commits
43 Commits
fix/requir
...
glm/select
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa22ec69d8 | ||
|
|
da460fa6b4 | ||
|
|
f03bd04cac | ||
|
|
e11a333998 | ||
|
|
2ff72e34dd | ||
|
|
0940d70a2b | ||
|
|
e7b0b00f56 | ||
|
|
4587cd5f3e | ||
|
|
7a5719217b | ||
|
|
ec17b29ae2 | ||
|
|
732aff1d5c | ||
|
|
713c39040b | ||
|
|
5db6a405ad | ||
|
|
6f24f1939d | ||
|
|
13ff7f418e | ||
|
|
d89d36728b | ||
|
|
5b8ec502fe | ||
|
|
47c027716b | ||
|
|
9347a04ed5 | ||
|
|
360b43caba | ||
|
|
1dd2b0a1b2 | ||
|
|
19f30ee6d5 | ||
|
|
b2128a0109 | ||
|
|
90139c7754 | ||
|
|
4aa1f01210 | ||
|
|
bfb65fc8b4 | ||
|
|
b54bea7192 | ||
|
|
793bb7b405 | ||
|
|
fb0f0031d9 | ||
|
|
ba80570357 | ||
|
|
714f713a04 | ||
|
|
68f766e1ae | ||
|
|
d9a5cb64b8 | ||
|
|
2ca3c8e409 | ||
|
|
0cc4e2650c | ||
|
|
bdffba53ed | ||
|
|
64532a1d12 | ||
|
|
8b8e33e2dc | ||
|
|
c5d870f480 | ||
|
|
6cf3f5f4a3 | ||
|
|
423e07376b | ||
|
|
dbe576406e | ||
|
|
077eb91c0f |
756
.claude/skills/native-trigger/SKILL.md
Normal file
756
.claude/skills/native-trigger/SKILL.md
Normal file
@@ -0,0 +1,756 @@
|
||||
# Skill: Adding Native Trigger Services
|
||||
|
||||
This skill provides comprehensive guidance for adding new native trigger services to Windmill. Native triggers allow external services (like Nextcloud, Google Drive, etc.) to trigger Windmill scripts/flows via webhooks or push notifications.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The native trigger system consists of:
|
||||
|
||||
1. **Database Layer** - PostgreSQL tables and enum types
|
||||
2. **Backend Rust Implementation** - Core trait, handlers, and service modules in the `windmill-native-triggers` crate
|
||||
3. **Frontend Svelte Components** - Configuration forms and UI components
|
||||
|
||||
### Key Files
|
||||
|
||||
| Component | Path |
|
||||
|-----------|------|
|
||||
| Core module with `External` trait | `backend/windmill-native-triggers/src/lib.rs` |
|
||||
| Generic CRUD handlers | `backend/windmill-native-triggers/src/handler.rs` |
|
||||
| Background sync logic | `backend/windmill-native-triggers/src/sync.rs` |
|
||||
| OAuth/workspace integration | `backend/windmill-native-triggers/src/workspace_integrations.rs` |
|
||||
| Re-export shim (windmill-api) | `backend/windmill-api/src/native_triggers/mod.rs` |
|
||||
| TriggerKind enum | `backend/windmill-common/src/triggers.rs` |
|
||||
| JobTriggerKind enum | `backend/windmill-common/src/jobs.rs` |
|
||||
| Frontend service registry | `frontend/src/lib/components/triggers/native/utils.ts` |
|
||||
| Frontend trigger utilities | `frontend/src/lib/components/triggers/utils.ts` |
|
||||
| Trigger badges (icons + counts) | `frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte` |
|
||||
| Workspace integrations UI | `frontend/src/lib/components/workspaceSettings/WorkspaceIntegrations.svelte` |
|
||||
| OAuth config form component | `frontend/src/lib/components/workspaceSettings/OAuthClientConfig.svelte` |
|
||||
| OpenAPI spec | `backend/windmill-api/openapi.yaml` |
|
||||
| Reference: Nextcloud module | `backend/windmill-native-triggers/src/nextcloud/` |
|
||||
| Reference: Google module | `backend/windmill-native-triggers/src/google/` |
|
||||
|
||||
### Crate Structure
|
||||
|
||||
The native trigger code lives in the `windmill-native-triggers` crate (`backend/windmill-native-triggers/`). The `windmill-api` crate re-exports everything via a shim:
|
||||
|
||||
```rust
|
||||
// backend/windmill-api/src/native_triggers/mod.rs
|
||||
pub use windmill_native_triggers::*;
|
||||
```
|
||||
|
||||
All new service modules go in `backend/windmill-native-triggers/src/`.
|
||||
|
||||
---
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### The `External` Trait
|
||||
|
||||
Every native trigger service implements the `External` trait defined in `lib.rs`:
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait External: Send + Sync + 'static {
|
||||
// Associated types:
|
||||
type ServiceConfig: Debug + DeserializeOwned + Serialize + Send + Sync;
|
||||
type TriggerData: Debug + Serialize + Send + Sync;
|
||||
type OAuthData: DeserializeOwned + Serialize + Clone + Send + Sync;
|
||||
type CreateResponse: DeserializeOwned + Send + Sync;
|
||||
|
||||
// Constants:
|
||||
const SUPPORT_WEBHOOK: bool;
|
||||
const SERVICE_NAME: ServiceName;
|
||||
const DISPLAY_NAME: &'static str;
|
||||
const TOKEN_ENDPOINT: &'static str;
|
||||
const REFRESH_ENDPOINT: &'static str;
|
||||
const AUTH_ENDPOINT: &'static str;
|
||||
|
||||
// Required methods:
|
||||
async fn create(&self, w_id, oauth_data, webhook_token, data, db, tx) -> Result<Self::CreateResponse>;
|
||||
async fn update(&self, w_id, oauth_data, external_id, webhook_token, data, db, tx) -> Result<serde_json::Value>;
|
||||
async fn get(&self, w_id, oauth_data, external_id, db, tx) -> Result<Self::TriggerData>;
|
||||
async fn delete(&self, w_id, oauth_data, external_id, db, tx) -> Result<()>;
|
||||
async fn exists(&self, w_id, oauth_data, external_id, db, tx) -> Result<bool>;
|
||||
async fn maintain_triggers(&self, db, workspace_id, triggers, oauth_data, synced, errors);
|
||||
fn external_id_and_metadata_from_response(&self, resp) -> (String, Option<serde_json::Value>);
|
||||
|
||||
// Methods with defaults:
|
||||
async fn prepare_webhook(&self, db, w_id, headers, body, script_path, is_flow) -> Result<PushArgsOwned>;
|
||||
fn service_config_from_create_response(&self, data, resp) -> Option<serde_json::Value>;
|
||||
fn additional_routes(&self) -> axum::Router;
|
||||
async fn http_client_request<T, B>(&self, url, method, workspace_id, tx, db, headers, body) -> Result<T>;
|
||||
}
|
||||
```
|
||||
|
||||
Key design points:
|
||||
- **`update()` returns `serde_json::Value`** - the resolved service_config to store. Each service is responsible for building the final config.
|
||||
- **`maintain_triggers()`** - periodic background maintenance. Each service implements its own strategy (Nextcloud: reconcile with external state; Google: renew expiring channels).
|
||||
- **No `list_all()` in the trait** - services that need it (Nextcloud) implement it privately; services that don't (Google) use different maintenance strategies.
|
||||
- **No `get_external_id_from_trigger_data()` or `extract_service_config_from_trigger_data()`** - removed in favor of the `maintain_triggers` pattern.
|
||||
|
||||
### Create Lifecycle: Two Paths
|
||||
|
||||
The `create_native_trigger` handler in `handler.rs` supports two creation flows, controlled by `service_config_from_create_response()`:
|
||||
|
||||
**Path A: Short (Google pattern)** - `service_config_from_create_response()` returns `Some(config)`:
|
||||
1. `create()` registers on external service
|
||||
2. `external_id_and_metadata_from_response()` extracts the ID
|
||||
3. `service_config_from_create_response()` builds the config directly from input data + response metadata
|
||||
4. Stores trigger in DB -- done, no extra round-trip
|
||||
|
||||
Use this when the external_id is known before the create call (e.g., Google generates the channel_id as a UUID upfront and includes it in the webhook URL).
|
||||
|
||||
**Path B: Long (Nextcloud pattern)** - `service_config_from_create_response()` returns `None` (default):
|
||||
1. `create()` registers on external service (webhook URL has no external_id yet)
|
||||
2. `external_id_and_metadata_from_response()` extracts the ID
|
||||
3. `update()` is called to fix the webhook URL with the now-known external_id
|
||||
4. `update()` returns the resolved service_config
|
||||
5. Stores trigger in DB
|
||||
|
||||
Use this when the external_id is assigned by the remote service and the webhook URL needs to be corrected after creation.
|
||||
|
||||
### OAuth Token Storage (Three-Table Pattern)
|
||||
|
||||
OAuth tokens are stored across three tables, NOT in `workspace_integrations.oauth_data` directly:
|
||||
|
||||
| Table | What's Stored |
|
||||
|-------|---------------|
|
||||
| `workspace_integrations` | `oauth_data` JSON with `base_url`, `client_id`, `client_secret`, `instance_shared` flag; `resource_path` pointing to the variable |
|
||||
| `variable` | Encrypted `access_token` (at the path stored in `resource_path`), linked to `account` via `account` column |
|
||||
| `account` | `refresh_token`, keyed by `workspace_id` + `client` (service name) + `is_workspace_integration = true` |
|
||||
|
||||
The `decrypt_oauth_data()` function in `lib.rs` assembles these into a unified struct:
|
||||
```rust
|
||||
pub struct OAuthConfig {
|
||||
pub base_url: String,
|
||||
pub access_token: String, // decrypted from variable
|
||||
pub refresh_token: Option<String>, // from account table
|
||||
pub client_id: String, // from oauth_data or instance settings
|
||||
pub client_secret: String, // from oauth_data or instance settings
|
||||
}
|
||||
```
|
||||
|
||||
Instance-level sharing: when `oauth_data.instance_shared == true`, `client_id` and `client_secret` are read from global settings instead of workspace_integrations.
|
||||
|
||||
### URL Resolution
|
||||
|
||||
The `resolve_endpoint()` helper handles both absolute and relative OAuth URLs:
|
||||
|
||||
```rust
|
||||
pub fn resolve_endpoint(base_url: &str, endpoint: &str) -> String {
|
||||
if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
|
||||
endpoint.to_string() // Google: absolute URLs
|
||||
} else {
|
||||
format!("{}{}", base_url, endpoint) // Nextcloud: relative paths
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ServiceName Methods
|
||||
|
||||
`ServiceName` is the central registry enum. Each variant must implement these match arms:
|
||||
|
||||
| Method | Purpose |
|
||||
|--------|---------|
|
||||
| `as_str()` | Lowercase identifier (e.g., `"google"`) |
|
||||
| `as_trigger_kind()` | Maps to `TriggerKind` enum |
|
||||
| `as_job_trigger_kind()` | Maps to `JobTriggerKind` enum |
|
||||
| `token_endpoint()` | OAuth token endpoint (relative or absolute) |
|
||||
| `auth_endpoint()` | OAuth authorization endpoint |
|
||||
| `oauth_scopes()` | Space-separated OAuth scopes |
|
||||
| `resource_type()` | Resource type for token storage (e.g., `"gworkspace"`) |
|
||||
| `extra_auth_params()` | Extra OAuth params (e.g., Google needs `access_type=offline`, `prompt=consent`) |
|
||||
| `integration_service()` | Maps to the workspace integration service (usually `*self`) |
|
||||
| `TryFrom<String>` | Parse from string |
|
||||
| `Display` | Delegates to `as_str()` |
|
||||
|
||||
---
|
||||
|
||||
## Step-by-Step Implementation Guide
|
||||
|
||||
### Step 1: Database Migration
|
||||
|
||||
Create a new migration file: `backend/migrations/YYYYMMDDHHMMSS_newservice_trigger.up.sql`
|
||||
|
||||
```sql
|
||||
-- Add the service to the native_trigger_service enum
|
||||
ALTER TYPE native_trigger_service ADD VALUE IF NOT EXISTS 'newservice';
|
||||
|
||||
-- Add to TRIGGER_KIND enum (used for trigger tracking)
|
||||
ALTER TYPE TRIGGER_KIND ADD VALUE IF NOT EXISTS 'newservice';
|
||||
|
||||
-- Add to job_trigger_kind enum (used for job tracking)
|
||||
ALTER TYPE job_trigger_kind ADD VALUE IF NOT EXISTS 'newservice';
|
||||
```
|
||||
|
||||
Also create the corresponding down migration.
|
||||
|
||||
### Step 2: Update windmill-common Enums
|
||||
|
||||
#### `backend/windmill-common/src/triggers.rs`
|
||||
|
||||
Add variant to `TriggerKind` enum, and update `to_key()` and `fmt()` implementations.
|
||||
|
||||
#### `backend/windmill-common/src/jobs.rs`
|
||||
|
||||
Add variant to `JobTriggerKind` enum and update the `Display` implementation.
|
||||
|
||||
### Step 3: Backend Service Module
|
||||
|
||||
Create a new directory: `backend/windmill-native-triggers/src/newservice/`
|
||||
|
||||
#### `mod.rs` - Type Definitions
|
||||
|
||||
```rust
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod external;
|
||||
// pub mod routes; // Only if you need additional service-specific routes
|
||||
|
||||
/// OAuth data deserialized from the three-table pattern.
|
||||
/// The actual structure is built by decrypt_oauth_data() from variable + account + workspace_integrations.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct NewServiceOAuthData {
|
||||
pub base_url: String, // from workspace_integrations.oauth_data
|
||||
pub access_token: String, // decrypted from variable table
|
||||
pub refresh_token: Option<String>, // from account table
|
||||
// Note: client_id and client_secret are in OAuthConfig, not here
|
||||
// unless the service needs them at runtime for API calls
|
||||
}
|
||||
|
||||
/// Configuration provided by user when creating/updating a trigger.
|
||||
/// Stored as JSON in native_trigger.service_config.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NewServiceConfig {
|
||||
// Service-specific configuration fields
|
||||
pub folder_path: String,
|
||||
pub file_filter: Option<String>,
|
||||
}
|
||||
|
||||
/// Data retrieved from the external service about a trigger.
|
||||
/// Returned by the get() method and shown in the UI.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NewServiceTriggerData {
|
||||
pub folder_path: String,
|
||||
pub file_filter: Option<String>,
|
||||
// Fields that shouldn't affect service_config comparison should use #[serde(skip_serializing)]
|
||||
}
|
||||
|
||||
/// Response from external service when creating a trigger/webhook.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateTriggerResponse {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
/// Handler struct (stateless, used for routing)
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct NewService;
|
||||
```
|
||||
|
||||
#### `external.rs` - External Trait Implementation
|
||||
|
||||
```rust
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Method;
|
||||
use sqlx::PgConnection;
|
||||
use std::collections::HashMap;
|
||||
use windmill_common::{
|
||||
error::{Error, Result},
|
||||
BASE_URL, DB,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
generate_webhook_service_url, External, NativeTrigger, NativeTriggerData, ServiceName,
|
||||
sync::{SyncError, TriggerSyncInfo},
|
||||
};
|
||||
use super::{NewService, NewServiceConfig, NewServiceOAuthData, NewServiceTriggerData, CreateTriggerResponse};
|
||||
|
||||
#[async_trait]
|
||||
impl External for NewService {
|
||||
type ServiceConfig = NewServiceConfig;
|
||||
type TriggerData = NewServiceTriggerData;
|
||||
type OAuthData = NewServiceOAuthData;
|
||||
type CreateResponse = CreateTriggerResponse;
|
||||
|
||||
const SERVICE_NAME: ServiceName = ServiceName::NewService;
|
||||
const DISPLAY_NAME: &'static str = "New Service";
|
||||
const SUPPORT_WEBHOOK: bool = true;
|
||||
const TOKEN_ENDPOINT: &'static str = "/oauth/token";
|
||||
const REFRESH_ENDPOINT: &'static str = "/oauth/token";
|
||||
const AUTH_ENDPOINT: &'static str = "/oauth/authorize";
|
||||
|
||||
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> {
|
||||
let base_url = &*BASE_URL.read().await;
|
||||
|
||||
// external_id is None during create (we get it from the response)
|
||||
let webhook_url = generate_webhook_service_url(
|
||||
base_url, w_id, &data.script_path, data.is_flow,
|
||||
None, Self::SERVICE_NAME, webhook_token,
|
||||
);
|
||||
|
||||
let url = format!("{}/api/webhooks/create", oauth_data.base_url);
|
||||
let payload = serde_json::json!({
|
||||
"callback_url": webhook_url,
|
||||
"folder_path": data.service_config.folder_path,
|
||||
});
|
||||
|
||||
let response: CreateTriggerResponse = self
|
||||
.http_client_request(&url, Method::POST, w_id, tx, db, None, Some(&payload))
|
||||
.await?;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Update returns the resolved service_config as JSON.
|
||||
/// For services using the update+get pattern, call self.get() and serialize.
|
||||
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<serde_json::Value> {
|
||||
let base_url = &*BASE_URL.read().await;
|
||||
|
||||
let webhook_url = generate_webhook_service_url(
|
||||
base_url, w_id, &data.script_path, data.is_flow,
|
||||
Some(external_id), Self::SERVICE_NAME, webhook_token,
|
||||
);
|
||||
|
||||
let url = format!("{}/api/webhooks/{}", oauth_data.base_url, external_id);
|
||||
let payload = serde_json::json!({
|
||||
"callback_url": webhook_url,
|
||||
"folder_path": data.service_config.folder_path,
|
||||
});
|
||||
|
||||
let _: serde_json::Value = self
|
||||
.http_client_request(&url, Method::PUT, w_id, tx, db, None, Some(&payload))
|
||||
.await?;
|
||||
|
||||
// Fetch back the updated state to get the resolved config
|
||||
let trigger_data = self.get(w_id, oauth_data, external_id, db, tx).await?;
|
||||
serde_json::to_value(&trigger_data)
|
||||
.map_err(|e| Error::InternalErr(format!("Failed to serialize trigger data: {}", e)))
|
||||
}
|
||||
|
||||
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!("{}/api/webhooks/{}", oauth_data.base_url, external_id);
|
||||
self.http_client_request::<_, ()>(&url, Method::GET, w_id, tx, db, None, None).await
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
w_id: &str,
|
||||
oauth_data: &Self::OAuthData,
|
||||
external_id: &str,
|
||||
db: &DB,
|
||||
tx: &mut PgConnection,
|
||||
) -> Result<()> {
|
||||
let url = format!("{}/api/webhooks/{}", oauth_data.base_url, external_id);
|
||||
let _: serde_json::Value = self
|
||||
.http_client_request::<_, ()>(&url, Method::DELETE, w_id, tx, db, None, 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> {
|
||||
match self.get(w_id, oauth_data, external_id, db, tx).await {
|
||||
Ok(_) => Ok(true),
|
||||
Err(Error::NotFound(_)) => Ok(false),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Background maintenance. Choose the right pattern for your service:
|
||||
/// - For services with queryable external state: use reconcile_with_external_state()
|
||||
/// - For channel-based services with expiration: implement renewal logic
|
||||
async fn maintain_triggers(
|
||||
&self,
|
||||
db: &DB,
|
||||
workspace_id: &str,
|
||||
triggers: &[NativeTrigger],
|
||||
oauth_data: &Self::OAuthData,
|
||||
synced: &mut Vec<TriggerSyncInfo>,
|
||||
errors: &mut Vec<SyncError>,
|
||||
) {
|
||||
// Option A: Reconcile with external state (Nextcloud pattern)
|
||||
// Fetch all triggers from external service and compare with DB
|
||||
let external_triggers = match self.list_all(workspace_id, oauth_data, db).await {
|
||||
Ok(triggers) => triggers,
|
||||
Err(e) => {
|
||||
errors.push(SyncError {
|
||||
resource_path: format!("workspace:{}", workspace_id),
|
||||
error_message: format!("Failed to list triggers: {}", e),
|
||||
error_type: "api_error".to_string(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Convert to (external_id, config_json) pairs
|
||||
let external_pairs: Vec<(String, serde_json::Value)> = external_triggers
|
||||
.into_iter()
|
||||
.map(|t| (t.id.clone(), serde_json::to_value(&t).unwrap_or_default()))
|
||||
.collect();
|
||||
|
||||
crate::sync::reconcile_with_external_state(
|
||||
db, workspace_id, Self::SERVICE_NAME, triggers, &external_pairs, synced, errors,
|
||||
).await;
|
||||
}
|
||||
|
||||
fn external_id_and_metadata_from_response(
|
||||
&self,
|
||||
resp: &Self::CreateResponse,
|
||||
) -> (String, Option<serde_json::Value>) {
|
||||
(resp.id.clone(), None)
|
||||
}
|
||||
|
||||
// service_config_from_create_response: NOT overridden (returns None).
|
||||
// This means the handler uses the update+get pattern after create.
|
||||
// Override and return Some(...) to skip the update+get cycle (Google pattern).
|
||||
}
|
||||
|
||||
impl NewService {
|
||||
/// Private helper to list all triggers from the external service.
|
||||
async fn list_all(
|
||||
&self,
|
||||
w_id: &str,
|
||||
oauth_data: &<Self as External>::OAuthData,
|
||||
db: &DB,
|
||||
) -> Result<Vec<<Self as External>::TriggerData>> {
|
||||
// Implementation depends on the external service's API
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Update lib.rs Registry
|
||||
|
||||
In `backend/windmill-native-triggers/src/lib.rs`:
|
||||
|
||||
```rust
|
||||
// Service modules - add new services here:
|
||||
#[cfg(feature = "native_trigger")]
|
||||
pub mod newservice; // <-- Add this
|
||||
|
||||
// ServiceName enum - add variant:
|
||||
pub enum ServiceName {
|
||||
Nextcloud,
|
||||
Google,
|
||||
NewService, // <-- Add this
|
||||
}
|
||||
|
||||
// Then add match arms in ALL ServiceName methods:
|
||||
// as_str(), as_trigger_kind(), as_job_trigger_kind(), token_endpoint(),
|
||||
// auth_endpoint(), oauth_scopes(), resource_type(), extra_auth_params(),
|
||||
// integration_service(), TryFrom<String>, Display
|
||||
```
|
||||
|
||||
### Step 5: Update handler.rs Routes
|
||||
|
||||
In `backend/windmill-native-triggers/src/handler.rs`:
|
||||
|
||||
```rust
|
||||
pub fn generate_native_trigger_routers() -> Router {
|
||||
// ...
|
||||
#[cfg(feature = "native_trigger")]
|
||||
{
|
||||
use crate::newservice::NewService;
|
||||
return router
|
||||
.nest("/nextcloud", service_routes(NextCloud))
|
||||
.nest("/google", service_routes(Google))
|
||||
.nest("/newservice", service_routes(NewService)); // <-- Add this
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: Update sync.rs
|
||||
|
||||
In `backend/windmill-native-triggers/src/sync.rs`:
|
||||
|
||||
```rust
|
||||
pub async fn sync_all_triggers(db: &DB) -> Result<BackgroundSyncResult> {
|
||||
// ...
|
||||
#[cfg(feature = "native_trigger")]
|
||||
{
|
||||
use crate::newservice::NewService;
|
||||
|
||||
// ... existing service syncs ...
|
||||
|
||||
// New service sync
|
||||
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);
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Step 7: Frontend Service Registry
|
||||
|
||||
In `frontend/src/lib/components/triggers/native/utils.ts`:
|
||||
|
||||
Add to `NATIVE_TRIGGER_SERVICES`, `getTriggerIconName()`, and `getServiceIcon()`.
|
||||
|
||||
### Step 8: Frontend Trigger Form Component
|
||||
|
||||
Create: `frontend/src/lib/components/triggers/native/services/newservice/NewServiceTriggerForm.svelte`
|
||||
|
||||
### Step 9: Frontend Icon Component
|
||||
|
||||
Create: `frontend/src/lib/components/icons/NewServiceIcon.svelte`
|
||||
|
||||
### Step 10: Update NativeTriggerEditor
|
||||
|
||||
Check `frontend/src/lib/components/triggers/native/NativeTriggerEditor.svelte` to ensure it dynamically loads form components based on service name.
|
||||
|
||||
### Step 11: Workspace Integration UI
|
||||
|
||||
Add your service to the `supportedServices` map in `frontend/src/lib/components/workspaceSettings/WorkspaceIntegrations.svelte`:
|
||||
|
||||
```typescript
|
||||
const supportedServices: Record<string, ServiceConfig> = {
|
||||
// ... existing services ...
|
||||
newservice: {
|
||||
name: 'newservice',
|
||||
displayName: 'New Service',
|
||||
description: 'Connect to New Service for triggers',
|
||||
icon: NewServiceIcon,
|
||||
docsUrl: 'https://www.windmill.dev/docs/integrations/newservice',
|
||||
requiresBaseUrl: false, // false for cloud services, true for self-hosted
|
||||
setupInstructions: [
|
||||
'Step 1: Create an OAuth app on the service',
|
||||
'Step 2: Configure the redirect URI shown below',
|
||||
'Step 3: Enter the client credentials below'
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 12: Update `frontend/src/lib/components/triggers/utils.ts`
|
||||
|
||||
Update ALL of these maps/functions:
|
||||
1. `triggerIconMap` - import and add icon
|
||||
2. `triggerDisplayNamesMap` - add display name
|
||||
3. `triggerTypeOrder` in `sortTriggers()` - add type
|
||||
4. `getLightConfig()` - add case for your service
|
||||
5. `getTriggerLabel()` - add case for your service
|
||||
6. `jobTriggerKinds` - add to array
|
||||
7. `countPropertyMap` - add count property
|
||||
8. `triggerSaveFunctions` - add save function
|
||||
|
||||
### Step 13: Update TriggersBadge Component
|
||||
|
||||
In `frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte`:
|
||||
|
||||
1. Import the icon
|
||||
2. Add to `baseConfig` with `countKey` (the dynamic `availableNativeServices` loop does NOT set `countKey`)
|
||||
3. Add to the `allTypes` array
|
||||
|
||||
### Step 14: Update OpenAPI Spec and Regenerate Types
|
||||
|
||||
Add to `JobTriggerKind` enum in `backend/windmill-api/openapi.yaml`, then:
|
||||
|
||||
```bash
|
||||
cd frontend && npm run generate-backend-client
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Special Patterns
|
||||
|
||||
### Unified Service with `trigger_type` (Google Pattern)
|
||||
|
||||
When a single service handles multiple trigger types (e.g., Google Drive + Calendar share OAuth and API patterns), use a single `ServiceName` variant with a discriminator field:
|
||||
|
||||
```rust
|
||||
pub enum GoogleTriggerType { Drive, Calendar }
|
||||
|
||||
pub struct GoogleServiceConfig {
|
||||
pub trigger_type: GoogleTriggerType,
|
||||
// Drive-specific fields (only used when trigger_type = Drive)
|
||||
pub resource_id: Option<String>,
|
||||
pub resource_name: Option<String>,
|
||||
// Calendar-specific fields (only used when trigger_type = Calendar)
|
||||
pub calendar_id: Option<String>,
|
||||
pub calendar_name: Option<String>,
|
||||
// Metadata set after creation
|
||||
pub google_resource_id: Option<String>,
|
||||
pub expiration: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
Branch in trait methods based on `trigger_type`. Frontend uses a `ToggleButtonGroup` to switch between types. This keeps the codebase simpler (one service, one OAuth flow, one set of routes).
|
||||
|
||||
See `backend/windmill-native-triggers/src/google/` for the reference implementation.
|
||||
|
||||
### Skipping update+get After Create (Google Pattern)
|
||||
|
||||
Override `service_config_from_create_response()` to return `Some(config)` when the external_id is known before the create call:
|
||||
|
||||
```rust
|
||||
fn service_config_from_create_response(
|
||||
&self,
|
||||
data: &NativeTriggerData<Self::ServiceConfig>,
|
||||
resp: &Self::CreateResponse,
|
||||
) -> Option<serde_json::Value> {
|
||||
// Clone input config, add metadata from response
|
||||
let mut config = data.service_config.clone();
|
||||
config.google_resource_id = Some(resp.resource_id.clone());
|
||||
config.expiration = Some(resp.expiration.clone());
|
||||
Some(serde_json::to_value(&config).unwrap())
|
||||
}
|
||||
```
|
||||
|
||||
### Services with Absolute OAuth Endpoints (Google)
|
||||
|
||||
Unlike self-hosted services where OAuth endpoints are relative paths appended to `base_url`, services like Google have absolute URLs:
|
||||
|
||||
```rust
|
||||
// Nextcloud: relative paths
|
||||
ServiceName::Nextcloud => "/apps/oauth2/api/v1/token",
|
||||
// Google: absolute URLs
|
||||
ServiceName::Google => "https://oauth2.googleapis.com/token",
|
||||
```
|
||||
|
||||
The `resolve_endpoint()` function handles both. For services with absolute endpoints:
|
||||
- `base_url` can be empty
|
||||
- `requiresBaseUrl: false` in the frontend workspace integration config
|
||||
- Add `extra_auth_params()` if needed (Google requires `access_type=offline` and `prompt=consent`)
|
||||
|
||||
### Channel-Based Push Notifications with Renewal (Google Pattern)
|
||||
|
||||
For services using expiring watch channels instead of persistent webhooks:
|
||||
|
||||
1. Store expiration in `service_config` (as part of `ServiceConfig`)
|
||||
2. In `maintain_triggers()`, implement renewal logic instead of using `reconcile_with_external_state()`:
|
||||
```rust
|
||||
async fn maintain_triggers(&self, db, workspace_id, triggers, oauth_data, synced, errors) {
|
||||
for trigger in triggers {
|
||||
if should_renew_channel(trigger) {
|
||||
self.renew_channel(db, trigger, oauth_data).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
3. Renewal: best-effort stop old channel, create new one with same external_id, update service_config with new expiration
|
||||
4. Google example: Drive channels expire in 24h (renew when <1h left), Calendar channels expire in 7 days (renew when <1 day left)
|
||||
|
||||
### reconcile_with_external_state (Nextcloud Pattern)
|
||||
|
||||
The reusable function in `sync.rs` compares external triggers with DB state:
|
||||
- Triggers missing externally: sets error "Trigger no longer exists on external service"
|
||||
- Triggers present externally: clears errors, updates service_config if it differs
|
||||
|
||||
Usage in `maintain_triggers()`:
|
||||
```rust
|
||||
let external_pairs: Vec<(String, serde_json::Value)> = /* fetch from external */;
|
||||
crate::sync::reconcile_with_external_state(
|
||||
db, workspace_id, Self::SERVICE_NAME, triggers, &external_pairs, synced, errors,
|
||||
).await;
|
||||
```
|
||||
|
||||
### Webhook Payload Processing
|
||||
|
||||
Override `prepare_webhook()` to parse service-specific payloads into script/flow args:
|
||||
|
||||
```rust
|
||||
async fn prepare_webhook(&self, db, w_id, headers, body, script_path, is_flow) -> Result<PushArgsOwned> {
|
||||
let mut args = HashMap::new();
|
||||
args.insert("event_type".to_string(), Box::new(headers.get("x-event-type").cloned()) as _);
|
||||
args.insert("payload".to_string(), Box::new(serde_json::from_str::<serde_json::Value>(&body)?) as _);
|
||||
Ok(PushArgsOwned { extra: None, args })
|
||||
}
|
||||
```
|
||||
|
||||
Then register in `prepare_native_trigger_args()` in `lib.rs`:
|
||||
```rust
|
||||
pub async fn prepare_native_trigger_args(service_name, db, w_id, headers, body) -> Result<Option<PushArgsOwned>> {
|
||||
match service_name {
|
||||
ServiceName::Google => { /* ... */ Ok(Some(args)) }
|
||||
ServiceName::NewService => { /* ... */ Ok(Some(args)) }
|
||||
ServiceName::Nextcloud => Ok(None), // Uses default body parsing
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Instance-Level OAuth Credentials
|
||||
|
||||
When `workspace_integrations.oauth_data.instance_shared == true`, `decrypt_oauth_data()` reads `client_id` and `client_secret` from instance-level global settings instead of workspace-level. This allows admins to share OAuth app credentials across workspaces.
|
||||
|
||||
The frontend handles this via the `generate_instance_connect_url` endpoint in `workspace_integrations.rs`.
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Database migration runs successfully
|
||||
- [ ] `cargo check -p windmill-native-triggers --features native_trigger` passes
|
||||
- [ ] `npx svelte-check --threshold error` passes (in frontend/)
|
||||
- [ ] Service appears in workspace integrations list
|
||||
- [ ] OAuth flow completes successfully
|
||||
- [ ] Can create a new trigger
|
||||
- [ ] Can view trigger details
|
||||
- [ ] Can update trigger configuration
|
||||
- [ ] Can delete trigger
|
||||
- [ ] Webhook receives and processes payloads
|
||||
- [ ] Background sync works correctly (reconciliation or channel renewal)
|
||||
- [ ] Error handling works (expired tokens, service unavailable)
|
||||
|
||||
---
|
||||
|
||||
## Reference Implementations
|
||||
|
||||
### Nextcloud (Self-Hosted, Update+Get Pattern)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `nextcloud/mod.rs` | Types: NextCloudOAuthData, NextcloudServiceConfig, NextCloudTriggerData |
|
||||
| `nextcloud/external.rs` | External trait: uses update+get pattern, reconcile_with_external_state for sync |
|
||||
| `nextcloud/routes.rs` | Additional route: `GET /events` |
|
||||
|
||||
Key patterns: relative OAuth endpoints, base_url required, list_all + reconcile for sync, update returns JSON from get().
|
||||
|
||||
### Google (Cloud, Unified Service, Short Create)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `google/mod.rs` | Types: GoogleServiceConfig with trigger_type discriminator, GoogleTriggerType enum |
|
||||
| `google/external.rs` | External trait: overrides service_config_from_create_response, channel renewal for sync |
|
||||
| `google/routes.rs` | Additional routes: `GET /calendars`, `GET /drive/files`, `GET /drive/shared_drives` |
|
||||
|
||||
Key patterns: absolute OAuth endpoints, empty base_url, trigger_type for Drive/Calendar, expiring watch channels with renewal, service_config_from_create_response skips update+get, get() reconstructs data from stored service_config (no external "get channel" API).
|
||||
40
CHANGELOG.md
40
CHANGELOG.md
@@ -1,5 +1,45 @@
|
||||
# Changelog
|
||||
|
||||
## [1.636.0](https://github.com/windmill-labs/windmill/compare/v1.635.1...v1.636.0) (2026-02-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* allow adding workspace scripts and flows as AI chat context ([#7882](https://github.com/windmill-labs/windmill/issues/7882)) ([5b8ec50](https://github.com/windmill-labs/windmill/commit/5b8ec502fef8fb439200e18b8c610d0f5998b6df))
|
||||
* google native triggers ([#7837](https://github.com/windmill-labs/windmill/issues/7837)) ([6f24f19](https://github.com/windmill-labs/windmill/commit/6f24f1939d75a597acc74c1589794d511e041baa))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* mark base_url as unsaved when using browser fallback ([#7964](https://github.com/windmill-labs/windmill/issues/7964)) ([e7b0b00](https://github.com/windmill-labs/windmill/commit/e7b0b00f5696828dec094155298d0c9dc033b355))
|
||||
|
||||
## [1.635.1](https://github.com/windmill-labs/windmill/compare/v1.635.0...v1.635.1) (2026-02-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* pin strum_macros to 0.27 to match strum version ([#7957](https://github.com/windmill-labs/windmill/issues/7957)) ([68f766e](https://github.com/windmill-labs/windmill/commit/68f766e1ae54dbe2fe42769559d81d4d76a409ef))
|
||||
|
||||
## [1.635.0](https://github.com/windmill-labs/windmill/compare/v1.634.6...v1.635.0) (2026-02-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add Kubernetes operator and instance settings YAML editor ([#7836](https://github.com/windmill-labs/windmill/issues/7836)) ([82e5f6d](https://github.com/windmill-labs/windmill/commit/82e5f6de48e246a49b25e7d4ea7be65122e8772c))
|
||||
* add maven settings.xml support for java private registries ([#7940](https://github.com/windmill-labs/windmill/issues/7940)) ([581dde8](https://github.com/windmill-labs/windmill/commit/581dde8d0bc4428a5e95fcb5341239231ab36ef6))
|
||||
* **cli:** add `lint` command ([#7917](https://github.com/windmill-labs/windmill/issues/7917)) ([37d1277](https://github.com/windmill-labs/windmill/commit/37d1277b91d1b8a03e327b0585f547037482498d))
|
||||
* handle $var: and $res: in arrays for transform_json_value ([#7949](https://github.com/windmill-labs/windmill/issues/7949)) ([e4a34d0](https://github.com/windmill-labs/windmill/commit/e4a34d031b2bdb1b73a2a7ca68544fa34f83ed0f))
|
||||
* IaC hints, YAML editor for worker configs ([#7956](https://github.com/windmill-labs/windmill/issues/7956)) ([8b8e33e](https://github.com/windmill-labs/windmill/commit/8b8e33e2dc1a2b4c0effab70463f6d4b402a0f7f))
|
||||
* open-source worker group configuration UI ([#7954](https://github.com/windmill-labs/windmill/issues/7954)) ([6cf3f5f](https://github.com/windmill-labs/windmill/commit/6cf3f5f4a35a6139b5cdf9f44af29c3941f19645))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* allow renaming of backend runnables in the UI ([6215760](https://github.com/windmill-labs/windmill/commit/6215760b1294d55245909a1c1de6c4cc8cef320a))
|
||||
* **go:** preserve proxy envs for go mod tidy/download ([#7946](https://github.com/windmill-labs/windmill/issues/7946)) ([8410b59](https://github.com/windmill-labs/windmill/commit/8410b59a8f23d62c57e497d170449643b46595a0))
|
||||
* Missing app policy for datatable ([#7944](https://github.com/windmill-labs/windmill/issues/7944)) ([a9dbd1f](https://github.com/windmill-labs/windmill/commit/a9dbd1f73fca9100b64106281802c43881181e78))
|
||||
* strip slack_oauth_client_secret from get_settings for non-admins ([#7950](https://github.com/windmill-labs/windmill/issues/7950)) ([43218c6](https://github.com/windmill-labs/windmill/commit/43218c62852490d0efafa8f94385bfe0e8f2ad82))
|
||||
|
||||
## [1.634.6](https://github.com/windmill-labs/windmill/compare/v1.634.5...v1.634.6) (2026-02-13)
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n oauth_data as \"oauth_data!: sqlx::types::Json<WorkspaceOAuthConfig>\",\n service_name as \"service_name!: ServiceName\"\n FROM\n workspace_integrations\n WHERE\n workspace_id = $1\n ",
|
||||
"query": "\n SELECT\n oauth_data as \"oauth_data: sqlx::types::Json<WorkspaceOAuthConfig>\",\n service_name as \"service_name!: ServiceName\",\n resource_path\n FROM\n workspace_integrations\n WHERE\n workspace_id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "oauth_data!: sqlx::types::Json<WorkspaceOAuthConfig>",
|
||||
"name": "oauth_data: sqlx::types::Json<WorkspaceOAuthConfig>",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
@@ -22,6 +22,11 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "resource_path",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -30,9 +35,10 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "5368683c19f8d6744d5dbc53e5b2ab0f2348646d79f5306c6868e2c3a8f389ee"
|
||||
"hash": "0010ef26da16facd1c2c832601ac687c4c27de46a90f45496b8446af1a9d0578"
|
||||
}
|
||||
15
backend/.sqlx/query-05e05a9b979941c7a11cd881da652f459e4a0444d63a96deba4a879fbe1124ff.json
generated
Normal file
15
backend/.sqlx/query-05e05a9b979941c7a11cd881da652f459e4a0444d63a96deba4a879fbe1124ff.json
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM resource WHERE workspace_id = $1 AND path = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "05e05a9b979941c7a11cd881da652f459e4a0444d63a96deba4a879fbe1124ff"
|
||||
}
|
||||
@@ -46,11 +46,11 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
|
||||
@@ -30,7 +30,8 @@
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +122,8 @@
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE workspace_integrations\n SET oauth_data = $1, updated_at = now()\n WHERE workspace_id = $2 AND service_name = $3\n ",
|
||||
"query": "DELETE FROM native_trigger WHERE workspace_id = $1 AND service_name = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Jsonb",
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
@@ -22,5 +21,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3b3f60623126626b52ca0a4a188655ddf728cd3f21ee308db7393694ccc5c7b3"
|
||||
"hash": "1af48c42255f1c973b4a9c9a58050bf5ec1ee6f93f0a90c1c7d0c0fcd816702d"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT client, refresh_token, grant_type, cc_client_id, cc_client_secret, cc_token_url, mcp_server_url FROM account WHERE workspace_id = $1 AND id = $2",
|
||||
"query": "SELECT client, refresh_token, grant_type, cc_client_id, cc_client_secret, cc_token_url, mcp_server_url, is_workspace_integration FROM account WHERE workspace_id = $1 AND id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -37,6 +37,11 @@
|
||||
"ordinal": 6,
|
||||
"name": "mcp_server_url",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "is_workspace_integration",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -52,8 +57,9 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "e26ccc6607a9c78c1a8c1fd7b3bec931cf0ed27f79f852ae7f63a0ed6e12042f"
|
||||
"hash": "1ba2e23d4ba816048ec1e88af9e342867fc0443cabea16d111afa2b91d3fe03b"
|
||||
}
|
||||
@@ -34,7 +34,8 @@
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -67,7 +68,8 @@
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
25
backend/.sqlx/query-26beff5e94b68703ad81ef9dd2d08869eb3bb7659efd9bac04cdf98ae963063d.json
generated
Normal file
25
backend/.sqlx/query-26beff5e94b68703ad81ef9dd2d08869eb3bb7659efd9bac04cdf98ae963063d.json
generated
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO account (workspace_id, client, expires_at, refresh_token, is_workspace_integration)\n VALUES ($1, $2, now() + ($3 || ' seconds')::interval, $4, true)\n RETURNING id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "26beff5e94b68703ad81ef9dd2d08869eb3bb7659efd9bac04cdf98ae963063d"
|
||||
}
|
||||
23
backend/.sqlx/query-27065225c6affd26f1533dacffe1c38321511b5a7dd2a7e9435c04868188fd44.json
generated
Normal file
23
backend/.sqlx/query-27065225c6affd26f1533dacffe1c38321511b5a7dd2a7e9435c04868188fd44.json
generated
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM account WHERE workspace_id = $1 AND client = $2 AND is_workspace_integration = true RETURNING id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "27065225c6affd26f1533dacffe1c38321511b5a7dd2a7e9435c04868188fd44"
|
||||
}
|
||||
20
backend/.sqlx/query-2e5dd992b0bfd7550d6f4cb5424a1c14352527b98249bce286790641bf56491e.json
generated
Normal file
20
backend/.sqlx/query-2e5dd992b0bfd7550d6f4cb5424a1c14352527b98249bce286790641bf56491e.json
generated
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT count(*) FROM native_trigger WHERE workspace_id = 'test-workspace' AND service_name = 'google'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "count",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "2e5dd992b0bfd7550d6f4cb5424a1c14352527b98249bce286790641bf56491e"
|
||||
}
|
||||
@@ -24,7 +24,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
14
backend/.sqlx/query-4be53f0b801ebc1a33a184556fd138fdec8082f31f56d7023cf8c6311964f3b0.json
generated
Normal file
14
backend/.sqlx/query-4be53f0b801ebc1a33a184556fd138fdec8082f31f56d7023cf8c6311964f3b0.json
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO global_settings (name, value) VALUES ('oauths', $1)\n ON CONFLICT (name) DO UPDATE SET value = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "4be53f0b801ebc1a33a184556fd138fdec8082f31f56d7023cf8c6311964f3b0"
|
||||
}
|
||||
@@ -37,7 +37,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -70,7 +71,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,7 +245,8 @@
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
22
backend/.sqlx/query-607c13333627e79557d3b6f6f68eee0a5dbe7cd4643e4bf99a592eb1bb82580c.json
generated
Normal file
22
backend/.sqlx/query-607c13333627e79557d3b6f6f68eee0a5dbe7cd4643e4bf99a592eb1bb82580c.json
generated
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT count(*) FROM variable WHERE workspace_id = 'test-workspace' AND path = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "count",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "607c13333627e79557d3b6f6f68eee0a5dbe7cd4643e4bf99a592eb1bb82580c"
|
||||
}
|
||||
@@ -35,7 +35,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
19
backend/.sqlx/query-6868520d496afe306bbd93293076ea4bb155097d1e8d3ffe5b75dd80ced735de.json
generated
Normal file
19
backend/.sqlx/query-6868520d496afe306bbd93293076ea4bb155097d1e8d3ffe5b75dd80ced735de.json
generated
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO resource (workspace_id, path, value, resource_type, description, created_by)\n VALUES ($1, $2, $3, $4, $5, $6)\n ON CONFLICT (workspace_id, path) DO UPDATE\n SET value = EXCLUDED.value, resource_type = EXCLUDED.resource_type",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Jsonb",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6868520d496afe306bbd93293076ea4bb155097d1e8d3ffe5b75dd80ced735de"
|
||||
}
|
||||
23
backend/.sqlx/query-6b4d48527af6f1411dc5e03f9144fb127488a79ac53a154d71253628320b1084.json
generated
Normal file
23
backend/.sqlx/query-6b4d48527af6f1411dc5e03f9144fb127488a79ac53a154d71253628320b1084.json
generated
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT refresh_token FROM account WHERE workspace_id = $1 AND id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "refresh_token",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "6b4d48527af6f1411dc5e03f9144fb127488a79ac53a154d71253628320b1084"
|
||||
}
|
||||
16
backend/.sqlx/query-6e83478011a8f65bf294ad7886139369e386f6a552c6626be48ecaa0e5ab78a7.json
generated
Normal file
16
backend/.sqlx/query-6e83478011a8f65bf294ad7886139369e386f6a552c6626be48ecaa0e5ab78a7.json
generated
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE account SET\n refresh_token = $1,\n expires_at = now() + interval '1 hour',\n refresh_error = NULL\n WHERE workspace_id = $2 AND client = $3 AND is_workspace_integration = true",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6e83478011a8f65bf294ad7886139369e386f6a552c6626be48ecaa0e5ab78a7"
|
||||
}
|
||||
@@ -29,7 +29,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n oauth_data,\n created_at,\n updated_at,\n created_by\n FROM\n workspace_integrations\n WHERE\n workspace_id = $1\n AND service_name = $2\n ",
|
||||
"query": "\n SELECT\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n oauth_data,\n resource_path,\n created_at,\n updated_at,\n created_by\n FROM\n workspace_integrations\n WHERE\n workspace_id = $1\n AND service_name = $2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -30,16 +30,21 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "resource_path",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"ordinal": 5,
|
||||
"name": "updated_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"ordinal": 6,
|
||||
"name": "created_by",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
@@ -63,11 +68,12 @@
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "3e5bdc2e071fc2f1e3c7971736272f20bb5a0aa921a614bd02898d3f162660c2"
|
||||
"hash": "7443ba1f922e190bb9a0ca313847f8d27c35a6ee3aff20157d6285e73aa923ef"
|
||||
}
|
||||
@@ -40,7 +40,8 @@
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
23
backend/.sqlx/query-7a57f58e809e482a599722d3887fb7e115506ff1e5ec9cf6dd2af84ed9a78632.json
generated
Normal file
23
backend/.sqlx/query-7a57f58e809e482a599722d3887fb7e115506ff1e5ec9cf6dd2af84ed9a78632.json
generated
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO account (workspace_id, client, expires_at, refresh_token, is_workspace_integration)\n VALUES ('test-workspace', $1, now() + interval '1 hour', $2, true)\n RETURNING id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "7a57f58e809e482a599722d3887fb7e115506ff1e5ec9cf6dd2af84ed9a78632"
|
||||
}
|
||||
15
backend/.sqlx/query-7b1d29170c9c4ad4e3a15c4e8acbeb6769dc6fc97269beee607a5625a495a121.json
generated
Normal file
15
backend/.sqlx/query-7b1d29170c9c4ad4e3a15c4e8acbeb6769dc6fc97269beee607a5625a495a121.json
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE account SET\n expires_at = now() + interval '1 hour',\n refresh_error = NULL\n WHERE workspace_id = $1 AND client = $2 AND is_workspace_integration = true",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "7b1d29170c9c4ad4e3a15c4e8acbeb6769dc6fc97269beee607a5625a495a121"
|
||||
}
|
||||
@@ -27,7 +27,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT EXISTS (\n SELECT 1\n FROM workspace_integrations\n WHERE workspace_id = $1\n AND service_name = $2\n AND oauth_data IS NOT NULL\n )\n ",
|
||||
"query": "\n SELECT EXISTS (\n SELECT 1\n FROM workspace_integrations wi\n WHERE wi.workspace_id = $1\n AND wi.service_name = $2\n AND wi.oauth_data IS NOT NULL\n )\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -29,5 +29,5 @@
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "95c57fb921a2e3725b92cbafac6e3dc360b88429f03dd1e2b1b55cfabe208cb7"
|
||||
"hash": "823fc5f998fe747ec8537752d9eb7ef548b2fd9ee1f5380084f27796c2bcc8ad"
|
||||
}
|
||||
15
backend/.sqlx/query-826a4216830f6a930c382209a20bc7f8b460064480e080b989e31df3d6a30e31.json
generated
Normal file
15
backend/.sqlx/query-826a4216830f6a930c382209a20bc7f8b460064480e080b989e31df3d6a30e31.json
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM variable WHERE workspace_id = $1 AND account = ANY($2)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int4Array"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "826a4216830f6a930c382209a20bc7f8b460064480e080b989e31df3d6a30e31"
|
||||
}
|
||||
@@ -24,7 +24,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
39
backend/.sqlx/query-83d6e371ca84903e9f487afc065353a9f7be86ff752612909587ec3cb770cb75.json
generated
Normal file
39
backend/.sqlx/query-83d6e371ca84903e9f487afc065353a9f7be86ff752612909587ec3cb770cb75.json
generated
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT external_id, webhook_token_prefix FROM native_trigger WHERE workspace_id = $1 AND service_name = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "external_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "webhook_token_prefix",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "native_trigger_service",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "83d6e371ca84903e9f487afc065353a9f7be86ff752612909587ec3cb770cb75"
|
||||
}
|
||||
@@ -35,7 +35,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
16
backend/.sqlx/query-89791ad1a0862b6475ebfdeb54b0101e124fcf9d12e93d84b44457c72c7604a5.json
generated
Normal file
16
backend/.sqlx/query-89791ad1a0862b6475ebfdeb54b0101e124fcf9d12e93d84b44457c72c7604a5.json
generated
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_integrations SET resource_path = REGEXP_REPLACE(resource_path, 'u/' || $2 || '/(.*)', 'u/' || $1 || '/\\1') WHERE resource_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "89791ad1a0862b6475ebfdeb54b0101e124fcf9d12e93d84b44457c72c7604a5"
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT EXISTS(\n SELECT 1\n FROM native_trigger\n WHERE\n workspace_id = $1 AND\n service_name = $2 AND\n external_id = $3\n )\n ",
|
||||
"query": "\n SELECT service_config\n FROM native_trigger\n WHERE external_id = $1 AND service_name = $2 AND workspace_id = $3\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
"name": "service_config",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -27,8 +27,8 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "06072cfe26abe58629623a8b38382b33947c5a5c702ce586e6e6ea51430380bf"
|
||||
"hash": "8a4d42e373043bc509985ab320894bf3e6afa7b2019cc4739baac9165f7ead9e"
|
||||
}
|
||||
15
backend/.sqlx/query-8e5881225f4bf7243bd40397ac8b8708fb6ce6c0ba0d263bb7eeb404f5dd62ff.json
generated
Normal file
15
backend/.sqlx/query-8e5881225f4bf7243bd40397ac8b8708fb6ce6c0ba0d263bb7eeb404f5dd62ff.json
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE account SET refresh_token = $1 WHERE workspace_id = 'test-workspace' AND id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "8e5881225f4bf7243bd40397ac8b8708fb6ce6c0ba0d263bb7eeb404f5dd62ff"
|
||||
}
|
||||
15
backend/.sqlx/query-8f33846f6c25a78267a5c8143b414f033280ebf57b7b9568dc2fe31bc625020d.json
generated
Normal file
15
backend/.sqlx/query-8f33846f6c25a78267a5c8143b414f033280ebf57b7b9568dc2fe31bc625020d.json
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM resource WHERE workspace_id = $1 AND resource_type = $2 AND path LIKE 'u/%/native_%'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "8f33846f6c25a78267a5c8143b414f033280ebf57b7b9568dc2fe31bc625020d"
|
||||
}
|
||||
@@ -26,7 +26,7 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "9052b7cd438ff029a37bd489190d98d365acec09f2f102b7de71dcc9d356900e"
|
||||
|
||||
22
backend/.sqlx/query-9242b5a866d0dd489bebe4284413d37202be70068affca92c45d112e0210538a.json
generated
Normal file
22
backend/.sqlx/query-9242b5a866d0dd489bebe4284413d37202be70068affca92c45d112e0210538a.json
generated
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT count(*) FROM resource WHERE workspace_id = 'test-workspace' AND path = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "count",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "9242b5a866d0dd489bebe4284413d37202be70068affca92c45d112e0210538a"
|
||||
}
|
||||
@@ -24,7 +24,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
33
backend/.sqlx/query-993e514229a1d508a44ef07b4399f9697cdefaa1f45ca665f72bc6fcf2797c7e.json
generated
Normal file
33
backend/.sqlx/query-993e514229a1d508a44ef07b4399f9697cdefaa1f45ca665f72bc6fcf2797c7e.json
generated
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT resource_path FROM workspace_integrations WHERE workspace_id = $1 AND service_name = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "resource_path",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "native_trigger_service",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "993e514229a1d508a44ef07b4399f9697cdefaa1f45ca665f72bc6fcf2797c7e"
|
||||
}
|
||||
@@ -32,7 +32,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,8 @@
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +155,8 @@
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,8 @@
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO workspace_integrations (\n workspace_id,\n service_name,\n oauth_data,\n created_by,\n created_at,\n updated_at\n ) VALUES (\n $1, $2, $3, $4, now(), now()\n )\n ON CONFLICT (workspace_id, service_name)\n DO UPDATE SET\n oauth_data = $3,\n updated_at = now()\n ",
|
||||
"query": "\n INSERT INTO workspace_integrations (\n workspace_id,\n service_name,\n oauth_data,\n resource_path,\n created_by,\n created_at,\n updated_at\n ) VALUES (\n $1, $2, $3, $4, $5, now(), now()\n )\n ON CONFLICT (workspace_id, service_name)\n DO UPDATE SET\n oauth_data = $3,\n resource_path = $4,\n updated_at = now()\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -18,10 +18,11 @@
|
||||
}
|
||||
},
|
||||
"Jsonb",
|
||||
"Text",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "2602402bedcdbc45cdc0d64a76ce075c6ae51404037b0a7d4d33faf6a7d6a6d8"
|
||||
"hash": "a588f4caa014008b50eccd09122b09f8a098e58791893bacaaf2ff67a30c031c"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n EXISTS(SELECT 1 FROM websocket_trigger WHERE workspace_id = $1) AS \"websocket_used!\",\n EXISTS(SELECT 1 FROM http_trigger WHERE workspace_id = $1) AS \"http_routes_used!\",\n EXISTS(SELECT 1 FROM kafka_trigger WHERE workspace_id = $1) as \"kafka_used!\",\n EXISTS(SELECT 1 FROM nats_trigger WHERE workspace_id = $1) as \"nats_used!\",\n EXISTS(SELECT 1 FROM postgres_trigger WHERE workspace_id = $1) AS \"postgres_used!\",\n EXISTS(SELECT 1 FROM mqtt_trigger WHERE workspace_id = $1) AS \"mqtt_used!\",\n EXISTS(SELECT 1 FROM sqs_trigger WHERE workspace_id = $1) AS \"sqs_used!\",\n EXISTS(SELECT 1 FROM gcp_trigger WHERE workspace_id = $1) AS \"gcp_used!\",\n EXISTS(SELECT 1 FROM email_trigger WHERE workspace_id = $1) AS \"email_used!\",\n EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = 'nextcloud'::native_trigger_service) AS \"nextcloud_used!\"\n ",
|
||||
"query": "\n SELECT\n EXISTS(SELECT 1 FROM websocket_trigger WHERE workspace_id = $1) AS \"websocket_used!\",\n EXISTS(SELECT 1 FROM http_trigger WHERE workspace_id = $1) AS \"http_routes_used!\",\n EXISTS(SELECT 1 FROM kafka_trigger WHERE workspace_id = $1) as \"kafka_used!\",\n EXISTS(SELECT 1 FROM nats_trigger WHERE workspace_id = $1) as \"nats_used!\",\n EXISTS(SELECT 1 FROM postgres_trigger WHERE workspace_id = $1) AS \"postgres_used!\",\n EXISTS(SELECT 1 FROM mqtt_trigger WHERE workspace_id = $1) AS \"mqtt_used!\",\n EXISTS(SELECT 1 FROM sqs_trigger WHERE workspace_id = $1) AS \"sqs_used!\",\n EXISTS(SELECT 1 FROM gcp_trigger WHERE workspace_id = $1) AS \"gcp_used!\",\n EXISTS(SELECT 1 FROM email_trigger WHERE workspace_id = $1) AS \"email_used!\",\n EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = 'nextcloud'::native_trigger_service) AS \"nextcloud_used!\",\n EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = 'google'::native_trigger_service) AS \"google_used!\"\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -52,6 +52,11 @@
|
||||
"ordinal": 9,
|
||||
"name": "nextcloud_used!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "google_used!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -69,8 +74,9 @@
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "d14e982c3d74499ec4bc62118e0edf065799eec5cf16f439b5f7568f392e60c3"
|
||||
"hash": "a6a25545af16db9f03552ce2ac178cfda3f2ced1b8f1e60bdfc7d84c642903d2"
|
||||
}
|
||||
18
backend/.sqlx/query-ab5720c0af66aba9fd7d6b842f098878c431d52bdd7b71355fc7192144b91300.json
generated
Normal file
18
backend/.sqlx/query-ab5720c0af66aba9fd7d6b842f098878c431d52bdd7b71355fc7192144b91300.json
generated
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO variable (workspace_id, path, value, is_secret, description, account, is_oauth)\n VALUES ($1, $2, $3, true, $4, $5, true)\n ON CONFLICT (workspace_id, path) DO UPDATE\n SET value = EXCLUDED.value, account = EXCLUDED.account",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ab5720c0af66aba9fd7d6b842f098878c431d52bdd7b71355fc7192144b91300"
|
||||
}
|
||||
@@ -160,7 +160,8 @@
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
24
backend/.sqlx/query-bc1298e492d3008386d9b1b449a98156fe186832494d16a434921727e5d3314d.json
generated
Normal file
24
backend/.sqlx/query-bc1298e492d3008386d9b1b449a98156fe186832494d16a434921727e5d3314d.json
generated
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT COUNT(*) FROM native_trigger WHERE workspace_id = $1 AND script_path = $2 AND is_flow = $3 AND service_name = 'google'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "count",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "bc1298e492d3008386d9b1b449a98156fe186832494d16a434921727e5d3314d"
|
||||
}
|
||||
@@ -105,7 +105,8 @@
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
29
backend/.sqlx/query-c0aff25f0cc3b71842b0ba9ae55b6bc5eca203bf02f46164db08580d128b860a.json
generated
Normal file
29
backend/.sqlx/query-c0aff25f0cc3b71842b0ba9ae55b6bc5eca203bf02f46164db08580d128b860a.json
generated
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT value, account FROM variable WHERE workspace_id = $1 AND path = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "value",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "account",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "c0aff25f0cc3b71842b0ba9ae55b6bc5eca203bf02f46164db08580d128b860a"
|
||||
}
|
||||
22
backend/.sqlx/query-c1757ea525295ac9a0681be83a1f9d1e70944f65562e38c078b683e09cd9fb09.json
generated
Normal file
22
backend/.sqlx/query-c1757ea525295ac9a0681be83a1f9d1e70944f65562e38c078b683e09cd9fb09.json
generated
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT count(*) FROM account WHERE workspace_id = 'test-workspace' AND id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "count",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "c1757ea525295ac9a0681be83a1f9d1e70944f65562e38c078b683e09cd9fb09"
|
||||
}
|
||||
16
backend/.sqlx/query-c2bf1109d208d3aa989b2e12c0380f54638edc40788d7417a08d08a267426b5e.json
generated
Normal file
16
backend/.sqlx/query-c2bf1109d208d3aa989b2e12c0380f54638edc40788d7417a08d08a267426b5e.json
generated
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by)\n VALUES ('test-workspace', $1, $2, $3, '{}'::jsonb, 'test-user')",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Jsonb",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c2bf1109d208d3aa989b2e12c0380f54638edc40788d7417a08d08a267426b5e"
|
||||
}
|
||||
@@ -31,7 +31,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +105,8 @@
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
33
backend/.sqlx/query-e241023a0d7b24adf7940ae764f14136b6d19fefbd8389e5ecd3bfc9bd652632.json
generated
Normal file
33
backend/.sqlx/query-e241023a0d7b24adf7940ae764f14136b6d19fefbd8389e5ecd3bfc9bd652632.json
generated
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT oauth_data FROM workspace_integrations\n WHERE workspace_id = $1 AND service_name = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "oauth_data",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "native_trigger_service",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "e241023a0d7b24adf7940ae764f14136b6d19fefbd8389e5ecd3bfc9bd652632"
|
||||
}
|
||||
15
backend/.sqlx/query-e4c508a9bc69ccb4b32cf50caa97f9a2ff7c5990df953296d7227c1c81bc5130.json
generated
Normal file
15
backend/.sqlx/query-e4c508a9bc69ccb4b32cf50caa97f9a2ff7c5990df953296d7227c1c81bc5130.json
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE variable SET value = $1 WHERE workspace_id = 'test-workspace' AND path = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e4c508a9bc69ccb4b32cf50caa97f9a2ff7c5990df953296d7227c1c81bc5130"
|
||||
}
|
||||
@@ -185,7 +185,8 @@
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
16
backend/.sqlx/query-e80dc984cd1d2388cbf17206ad059137cf7f92d0222382af1a66de807f3138e8.json
generated
Normal file
16
backend/.sqlx/query-e80dc984cd1d2388cbf17206ad059137cf7f92d0222382af1a66de807f3138e8.json
generated
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO variable (workspace_id, path, value, is_secret, description, account, is_oauth)\n VALUES ('test-workspace', $1, $2, true, 'test oauth token', $3, true)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e80dc984cd1d2388cbf17206ad059137cf7f92d0222382af1a66de807f3138e8"
|
||||
}
|
||||
@@ -24,7 +24,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
20
backend/.sqlx/query-f11d3a43f804a33e412a69be54824d7666d4dac0b824095c4c4fefb1f802c6f7.json
generated
Normal file
20
backend/.sqlx/query-f11d3a43f804a33e412a69be54824d7666d4dac0b824095c4c4fefb1f802c6f7.json
generated
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT email FROM password ORDER BY email LIMIT 1000",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "f11d3a43f804a33e412a69be54824d7666d4dac0b824095c4c4fefb1f802c6f7"
|
||||
}
|
||||
16
backend/.sqlx/query-f5460eb13ea4f0e9896928a3266e419090f72e54f7347b35254a661116ba822d.json
generated
Normal file
16
backend/.sqlx/query-f5460eb13ea4f0e9896928a3266e419090f72e54f7347b35254a661116ba822d.json
generated
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_integrations SET resource_path = $1 WHERE workspace_id = $2 AND resource_path = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f5460eb13ea4f0e9896928a3266e419090f72e54f7347b35254a661116ba822d"
|
||||
}
|
||||
445
backend/Cargo.lock
generated
445
backend/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.634.6"
|
||||
version = "1.636.0"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
@@ -75,7 +75,7 @@ members = [
|
||||
exclude = ["./windmill-duckdb-ffi-internal"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.634.6"
|
||||
version = "1.636.0"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -96,7 +96,7 @@ lto = "thin"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
private = ["windmill-api/private", "windmill-api-agent-workers?/private", "windmill-autoscaling/private", "windmill-common/private", "windmill-git-sync/private", "windmill-indexer/private", "windmill-queue/private", "windmill-worker/private", "windmill-test-utils/private"]
|
||||
private = ["windmill-api/private", "windmill-api-agent-workers?/private", "windmill-autoscaling/private", "windmill-common/private", "windmill-git-sync/private", "windmill-indexer/private", "windmill-operator?/private", "windmill-queue/private", "windmill-worker/private", "windmill-test-utils/private"]
|
||||
agent_worker_server = ["windmill-api/agent_worker_server", "dep:windmill-api-agent-workers", "windmill-test-utils/agent_worker_server"]
|
||||
enterprise = ["windmill-worker/enterprise", "windmill-queue/enterprise", "windmill-api/enterprise", "windmill-api-agent-workers?/enterprise", "dep:windmill-autoscaling", "windmill-autoscaling/enterprise", "windmill-git-sync/enterprise", "windmill-common/prometheus", "windmill-common/enterprise"]
|
||||
local_reports = ["windmill-common/local_reports"]
|
||||
@@ -166,7 +166,7 @@ oss_core = [
|
||||
"static_frontend", "mcp", "bedrock", "inline_preview",
|
||||
"quickjs"
|
||||
]
|
||||
ce_core = ["oss_core", "private"]
|
||||
ce_core = ["oss_core", "private", "operator"]
|
||||
ee_core = [
|
||||
"enterprise", "stripe", "prometheus", "cloud",
|
||||
"kafka", "sqs_trigger", "nats", "gcp_trigger",
|
||||
@@ -584,7 +584,7 @@ tree-sitter-ruby = "0.23.0"
|
||||
oracle = { version = "0.6.3", features = ["chrono"] }
|
||||
rumqttc = { version = "0.24.0", features = ["use-native-tls"]}
|
||||
strum = { version = "0.27", features = ["derive"] }
|
||||
strum_macros = "^0"
|
||||
strum_macros = "0.27"
|
||||
hudsucker = { version = "0.22", features = ["rcgen-ca", "native-tls-client"] }
|
||||
hyper-http-proxy = { version = "1", default-features = false, features = ["native-tls"] }
|
||||
rcgen = "0.13"
|
||||
|
||||
@@ -1 +1 @@
|
||||
e7f80bca9320580e1cb96b4f4ca9942649abce7f
|
||||
9f6e1e533df7711600ec2b8d5f0c958448db1a20
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Note: PostgreSQL does not support removing enum values directly.
|
||||
-- This down migration is a placeholder. To fully reverse, you would need to:
|
||||
-- 1. Create a new enum type without the values
|
||||
-- 2. Update all columns to use the new type
|
||||
-- 3. Drop the old type
|
||||
-- 4. Rename the new type
|
||||
|
||||
-- For now, we just document what was added:
|
||||
-- Removed from native_trigger_service: 'google'
|
||||
-- Removed from TRIGGER_KIND: 'google'
|
||||
-- Removed from job_trigger_kind: 'google'
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Add Google to native_trigger_service enum
|
||||
-- 'google' is a unified service that handles both Drive and Calendar triggers
|
||||
-- The trigger_type field in service_config determines which Google service is used
|
||||
ALTER TYPE native_trigger_service ADD VALUE IF NOT EXISTS 'google';
|
||||
|
||||
-- Add to TRIGGER_KIND enum (used for trigger tracking)
|
||||
ALTER TYPE TRIGGER_KIND ADD VALUE IF NOT EXISTS 'google';
|
||||
|
||||
-- Add to job_trigger_kind enum (used for job tracking)
|
||||
ALTER TYPE job_trigger_kind ADD VALUE IF NOT EXISTS 'google';
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE account DROP COLUMN IF EXISTS is_workspace_integration;
|
||||
ALTER TABLE workspace_integrations ALTER COLUMN oauth_data SET NOT NULL;
|
||||
ALTER TABLE workspace_integrations DROP COLUMN IF EXISTS resource_path;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Migrate native trigger OAuth tokens from workspace_integrations to account+variable+resource pattern
|
||||
|
||||
-- Add flag to distinguish workspace integration accounts from regular user OAuth accounts
|
||||
ALTER TABLE account ADD COLUMN is_workspace_integration BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- Make oauth_data nullable since it will only store client config (no tokens) going forward
|
||||
ALTER TABLE workspace_integrations ALTER COLUMN oauth_data DROP NOT NULL;
|
||||
|
||||
-- Add resource_path column to workspace_integrations
|
||||
ALTER TABLE workspace_integrations ADD COLUMN IF NOT EXISTS resource_path TEXT;
|
||||
@@ -1346,6 +1346,10 @@ pub async fn reload_maven_settings_xml_setting(conn: &Connection) {
|
||||
)
|
||||
.await;
|
||||
|
||||
if !cfg!(feature = "enterprise") {
|
||||
return;
|
||||
}
|
||||
|
||||
let settings_xml = MAVEN_SETTINGS_XML.read().await.clone();
|
||||
match settings_xml {
|
||||
Some(ref content) if !content.trim().is_empty() => {
|
||||
|
||||
@@ -23,7 +23,7 @@ use windmill_common::{
|
||||
DB,
|
||||
};
|
||||
|
||||
use windmill_api_auth::{ApiAuthed, require_devops_role};
|
||||
use windmill_api_auth::{require_devops_role, ApiAuthed};
|
||||
|
||||
pub fn global_service() -> Router {
|
||||
Router::new()
|
||||
@@ -128,12 +128,16 @@ async fn update_config(
|
||||
require_devops_role(&db, &authed.email).await?;
|
||||
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
if name.starts_with("worker__") {
|
||||
return Err(error::Error::BadRequest(
|
||||
"Worker groups configurable from UI available only in the enterprise version"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
let config = if name.starts_with("worker__") {
|
||||
// In CE, only allow setting worker_tags, cache_clear, and init_bash
|
||||
serde_json::json!({
|
||||
"worker_tags": config.get("worker_tags"),
|
||||
"cache_clear": config.get("cache_clear"),
|
||||
"init_bash": config.get("init_bash")
|
||||
})
|
||||
} else {
|
||||
config
|
||||
};
|
||||
|
||||
if name.starts_with("worker__") {
|
||||
let periodic_script_bash = config
|
||||
|
||||
@@ -19,6 +19,8 @@ mcp = []
|
||||
windmill-test-utils.workspace = true
|
||||
windmill-api-client.workspace = true
|
||||
windmill-common = { workspace = true, default-features = false }
|
||||
windmill-native-triggers = { workspace = true, features = ["native_trigger"] }
|
||||
windmill-api-auth.workspace = true
|
||||
sqlx.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
595
backend/windmill-api-integration-tests/tests/native_triggers.rs
Normal file
595
backend/windmill-api-integration-tests/tests/native_triggers.rs
Normal file
@@ -0,0 +1,595 @@
|
||||
/*!
|
||||
* Integration tests for the native trigger system (Google).
|
||||
*
|
||||
* Tests cover 4 business-logic areas:
|
||||
* 1. Resource path change — cleanup old path, recreate at new path
|
||||
* 2. Config loading — workspace-level, instance-level, token update
|
||||
* 3. Channel expiration renewal — should_renew_channel pure logic
|
||||
* 4. Delete workspace integration — full cascade, cleanup preserves triggers, parse_stop_channel_params
|
||||
*/
|
||||
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
|
||||
use windmill_api_auth::ApiAuthed;
|
||||
use windmill_common::variables::{build_crypt, encrypt};
|
||||
use windmill_native_triggers::{
|
||||
decrypt_oauth_data, delete_native_trigger, delete_workspace_integration,
|
||||
get_workspace_integration,
|
||||
google::{parse_stop_channel_params, should_renew_channel},
|
||||
store_native_trigger, store_workspace_integration, NativeTriggerConfig, OAuthConfig,
|
||||
ServiceName,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
async fn insert_test_script(db: &Pool<Postgres>, path: &str) -> anyhow::Result<i64> {
|
||||
let hash: i64 = rand::random::<i64>().unsigned_abs() as i64;
|
||||
sqlx::query(
|
||||
"INSERT INTO script (workspace_id, hash, path, summary, description, content,
|
||||
created_by, language, kind, lock)
|
||||
VALUES ('test-workspace', $1, $2, '', '', 'def main(): pass',
|
||||
'test-user', 'python3', 'script', '')",
|
||||
)
|
||||
.bind(hash)
|
||||
.bind(path)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(hash)
|
||||
}
|
||||
|
||||
fn test_authed() -> ApiAuthed {
|
||||
ApiAuthed {
|
||||
email: "test@windmill.dev".to_string(),
|
||||
username: "test-user".to_string(),
|
||||
is_admin: true,
|
||||
is_operator: false,
|
||||
groups: vec!["all".to_string()],
|
||||
folders: vec![],
|
||||
scopes: None,
|
||||
username_override: None,
|
||||
token_prefix: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set up a complete workspace integration with account+variable+resource.
|
||||
/// Returns (resource_path, account_id).
|
||||
async fn setup_oauth_integration(
|
||||
db: &Pool<Postgres>,
|
||||
service_name: ServiceName,
|
||||
resource_path: &str,
|
||||
access_token: &str,
|
||||
refresh_token: &str,
|
||||
oauth_data_override: Option<serde_json::Value>,
|
||||
) -> anyhow::Result<i32> {
|
||||
// 1. Create account with is_workspace_integration=true
|
||||
let account_id: i32 = sqlx::query_scalar!(
|
||||
"INSERT INTO account (workspace_id, client, expires_at, refresh_token, is_workspace_integration)
|
||||
VALUES ('test-workspace', $1, now() + interval '1 hour', $2, true)
|
||||
RETURNING id",
|
||||
service_name.as_str(),
|
||||
refresh_token,
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
|
||||
// 2. Encrypt and create variable
|
||||
let mc = build_crypt(db, "test-workspace").await?;
|
||||
let encrypted = encrypt(&mc, access_token);
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO variable (workspace_id, path, value, is_secret, description, account, is_oauth)
|
||||
VALUES ('test-workspace', $1, $2, true, 'test oauth token', $3, true)",
|
||||
resource_path,
|
||||
encrypted,
|
||||
account_id,
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
// 3. Create resource
|
||||
let resource_value = json!({ "token": format!("$var:{}", resource_path) });
|
||||
sqlx::query!(
|
||||
"INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by)
|
||||
VALUES ('test-workspace', $1, $2, $3, '{}'::jsonb, 'test-user')",
|
||||
resource_path,
|
||||
resource_value,
|
||||
service_name.resource_type(),
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
// 4. Store workspace integration with resource_path
|
||||
let oauth_data = oauth_data_override.unwrap_or_else(|| {
|
||||
json!({
|
||||
"client_id": "test-client-id",
|
||||
"client_secret": "test-client-secret",
|
||||
"base_url": "https://example.com",
|
||||
"resource_path": resource_path,
|
||||
})
|
||||
});
|
||||
|
||||
let authed = test_authed();
|
||||
let mut tx = db.begin().await?;
|
||||
store_workspace_integration(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"test-workspace",
|
||||
service_name,
|
||||
oauth_data,
|
||||
Some(resource_path),
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(account_id)
|
||||
}
|
||||
|
||||
fn now_ms() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as i64
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 1. Resource Path Change
|
||||
// ============================================================================
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_resource_path_change(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let path_a = "u/test-user/native_gworkspace";
|
||||
setup_oauth_integration(
|
||||
&db,
|
||||
ServiceName::Google,
|
||||
path_a,
|
||||
"token-a",
|
||||
"refresh-a",
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Verify decrypt works at path A
|
||||
let config: OAuthConfig =
|
||||
decrypt_oauth_data(&db, "test-workspace", ServiceName::Google).await?;
|
||||
assert_eq!(config.access_token, "token-a");
|
||||
|
||||
// Cleanup old path
|
||||
let mut tx = db.begin().await?;
|
||||
windmill_native_triggers::workspace_integrations::cleanup_oauth_resource(
|
||||
&mut *tx,
|
||||
"test-workspace",
|
||||
ServiceName::Google,
|
||||
)
|
||||
.await;
|
||||
tx.commit().await?;
|
||||
|
||||
// Recreate at path B
|
||||
let path_b = "u/test-user/native_gworkspace_v2";
|
||||
setup_oauth_integration(
|
||||
&db,
|
||||
ServiceName::Google,
|
||||
path_b,
|
||||
"token-b",
|
||||
"refresh-b",
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Path A resources should be gone
|
||||
let var_count: i64 = sqlx::query_scalar!(
|
||||
"SELECT count(*) FROM variable WHERE workspace_id = 'test-workspace' AND path = $1",
|
||||
path_a,
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?
|
||||
.unwrap_or(0);
|
||||
assert_eq!(var_count, 0, "variable at old path should be deleted");
|
||||
|
||||
let res_count: i64 = sqlx::query_scalar!(
|
||||
"SELECT count(*) FROM resource WHERE workspace_id = 'test-workspace' AND path = $1",
|
||||
path_a,
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?
|
||||
.unwrap_or(0);
|
||||
assert_eq!(res_count, 0, "resource at old path should be deleted");
|
||||
|
||||
// Path B should work
|
||||
let config: OAuthConfig =
|
||||
decrypt_oauth_data(&db, "test-workspace", ServiceName::Google).await?;
|
||||
assert_eq!(config.access_token, "token-b");
|
||||
assert_eq!(config.refresh_token.as_deref(), Some("refresh-b"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 2. Config Loading — workspace vs instance + token update
|
||||
// ============================================================================
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_decrypt_workspace_level(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let resource_path = "u/test-user/native_gworkspace";
|
||||
setup_oauth_integration(
|
||||
&db,
|
||||
ServiceName::Google,
|
||||
resource_path,
|
||||
"ws-access-token",
|
||||
"ws-refresh-token",
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let config: OAuthConfig =
|
||||
decrypt_oauth_data(&db, "test-workspace", ServiceName::Google).await?;
|
||||
|
||||
assert_eq!(config.access_token, "ws-access-token");
|
||||
assert_eq!(config.refresh_token.as_deref(), Some("ws-refresh-token"));
|
||||
assert_eq!(config.client_id, "test-client-id");
|
||||
assert_eq!(config.client_secret, "test-client-secret");
|
||||
assert_eq!(config.base_url, "https://example.com");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_decrypt_instance_level(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
// Insert instance-level credentials into global_settings
|
||||
sqlx::query!(
|
||||
"INSERT INTO global_settings (name, value) VALUES ('oauths', $1)
|
||||
ON CONFLICT (name) DO UPDATE SET value = $1",
|
||||
json!({
|
||||
"gworkspace": {
|
||||
"id": "instance-client-id",
|
||||
"secret": "instance-client-secret"
|
||||
}
|
||||
}),
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let resource_path = "u/test-user/native_gworkspace";
|
||||
let oauth_data = json!({
|
||||
"instance_shared": true,
|
||||
"base_url": "https://accounts.google.com",
|
||||
"resource_path": resource_path,
|
||||
});
|
||||
|
||||
setup_oauth_integration(
|
||||
&db,
|
||||
ServiceName::Google,
|
||||
resource_path,
|
||||
"inst-access-token",
|
||||
"inst-refresh-token",
|
||||
Some(oauth_data),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let config: OAuthConfig =
|
||||
decrypt_oauth_data(&db, "test-workspace", ServiceName::Google).await?;
|
||||
|
||||
assert_eq!(config.client_id, "instance-client-id");
|
||||
assert_eq!(config.client_secret, "instance-client-secret");
|
||||
assert_eq!(config.access_token, "inst-access-token");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_token_update_persists(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let resource_path = "u/test-user/native_gworkspace";
|
||||
let account_id = setup_oauth_integration(
|
||||
&db,
|
||||
ServiceName::Google,
|
||||
resource_path,
|
||||
"old-access-token",
|
||||
"old-refresh-token",
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Verify old tokens
|
||||
let config: OAuthConfig =
|
||||
decrypt_oauth_data(&db, "test-workspace", ServiceName::Google).await?;
|
||||
assert_eq!(config.access_token, "old-access-token");
|
||||
|
||||
// Simulate token refresh: update variable + account
|
||||
let mc = build_crypt(&db, "test-workspace").await?;
|
||||
let new_encrypted = encrypt(&mc, "new-access-token");
|
||||
sqlx::query!(
|
||||
"UPDATE variable SET value = $1 WHERE workspace_id = 'test-workspace' AND path = $2",
|
||||
new_encrypted,
|
||||
resource_path,
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE account SET refresh_token = $1 WHERE workspace_id = 'test-workspace' AND id = $2",
|
||||
"new-refresh-token",
|
||||
account_id,
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
// Verify new tokens
|
||||
let config: OAuthConfig =
|
||||
decrypt_oauth_data(&db, "test-workspace", ServiceName::Google).await?;
|
||||
assert_eq!(config.access_token, "new-access-token");
|
||||
assert_eq!(config.refresh_token.as_deref(), Some("new-refresh-token"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 3. Channel Expiration Renewal — should_renew_channel
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_should_renew_drive_channel_expired() {
|
||||
let config = json!({
|
||||
"triggerType": "drive",
|
||||
"expiration": (now_ms() - 1000).to_string(),
|
||||
});
|
||||
assert!(should_renew_channel(&config));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_renew_drive_channel_within_window() {
|
||||
// 30 minutes remaining — within the 1-hour Drive renewal window
|
||||
let config = json!({
|
||||
"triggerType": "drive",
|
||||
"expiration": (now_ms() + 30 * 60 * 1000).to_string(),
|
||||
});
|
||||
assert!(should_renew_channel(&config));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_renew_drive_channel_not_yet() {
|
||||
// 2 hours remaining — outside the 1-hour Drive renewal window
|
||||
let config = json!({
|
||||
"triggerType": "drive",
|
||||
"expiration": (now_ms() + 2 * 60 * 60 * 1000).to_string(),
|
||||
});
|
||||
assert!(!should_renew_channel(&config));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_renew_calendar_channel_within_window() {
|
||||
// 12 hours remaining — within the 1-day Calendar renewal window
|
||||
let config = json!({
|
||||
"triggerType": "calendar",
|
||||
"expiration": (now_ms() + 12 * 60 * 60 * 1000).to_string(),
|
||||
});
|
||||
assert!(should_renew_channel(&config));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_renew_calendar_channel_not_yet() {
|
||||
// 2 days remaining — outside the 1-day Calendar renewal window
|
||||
let config = json!({
|
||||
"triggerType": "calendar",
|
||||
"expiration": (now_ms() + 2 * 24 * 60 * 60 * 1000).to_string(),
|
||||
});
|
||||
assert!(!should_renew_channel(&config));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_renew_channel_zero_expiration() {
|
||||
let config = json!({
|
||||
"triggerType": "drive",
|
||||
"expiration": "0",
|
||||
});
|
||||
assert!(!should_renew_channel(&config));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_renew_channel_missing_fields() {
|
||||
assert!(!should_renew_channel(&json!({})));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 4. Delete Workspace Integration
|
||||
// ============================================================================
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_delete_integration_full_cascade(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let resource_path = "u/test-user/native_gworkspace";
|
||||
let account_id = setup_oauth_integration(
|
||||
&db,
|
||||
ServiceName::Google,
|
||||
resource_path,
|
||||
"token",
|
||||
"refresh",
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Add a native trigger linked to this integration
|
||||
insert_test_script(&db, "f/test/handler").await?;
|
||||
let trigger_config = NativeTriggerConfig {
|
||||
script_path: "f/test/handler".to_string(),
|
||||
is_flow: false,
|
||||
webhook_token: "abcdefghij1234567890".to_string(),
|
||||
};
|
||||
store_native_trigger(
|
||||
&db,
|
||||
"test-workspace",
|
||||
ServiceName::Google,
|
||||
"ext-1",
|
||||
&trigger_config,
|
||||
json!({"triggerType": "drive"}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Step 1: Delete triggers
|
||||
let deleted =
|
||||
delete_native_trigger(&db, "test-workspace", ServiceName::Google, "ext-1").await?;
|
||||
assert!(deleted);
|
||||
|
||||
// Step 2: Cleanup OAuth resources
|
||||
let mut tx = db.begin().await?;
|
||||
windmill_native_triggers::workspace_integrations::cleanup_oauth_resource(
|
||||
&mut *tx,
|
||||
"test-workspace",
|
||||
ServiceName::Google,
|
||||
)
|
||||
.await;
|
||||
tx.commit().await?;
|
||||
|
||||
// Step 3: Delete workspace integration
|
||||
let mut tx = db.begin().await?;
|
||||
let deleted =
|
||||
delete_workspace_integration(&mut *tx, "test-workspace", ServiceName::Google).await?;
|
||||
tx.commit().await?;
|
||||
assert!(deleted);
|
||||
|
||||
// Verify everything is gone
|
||||
let var_count: i64 = sqlx::query_scalar!(
|
||||
"SELECT count(*) FROM variable WHERE workspace_id = 'test-workspace' AND path = $1",
|
||||
resource_path,
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?
|
||||
.unwrap_or(0);
|
||||
assert_eq!(var_count, 0);
|
||||
|
||||
let acc_count: i64 = sqlx::query_scalar!(
|
||||
"SELECT count(*) FROM account WHERE workspace_id = 'test-workspace' AND id = $1",
|
||||
account_id,
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?
|
||||
.unwrap_or(0);
|
||||
assert_eq!(acc_count, 0);
|
||||
|
||||
let res_count: i64 = sqlx::query_scalar!(
|
||||
"SELECT count(*) FROM resource WHERE workspace_id = 'test-workspace' AND path = $1",
|
||||
resource_path,
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?
|
||||
.unwrap_or(0);
|
||||
assert_eq!(res_count, 0);
|
||||
|
||||
assert!(
|
||||
get_workspace_integration(&db, "test-workspace", ServiceName::Google)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_cleanup_preserves_triggers(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let resource_path = "u/test-user/native_gworkspace";
|
||||
setup_oauth_integration(
|
||||
&db,
|
||||
ServiceName::Google,
|
||||
resource_path,
|
||||
"token",
|
||||
"refresh",
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Create a trigger
|
||||
insert_test_script(&db, "f/test/handler").await?;
|
||||
let trigger_config = NativeTriggerConfig {
|
||||
script_path: "f/test/handler".to_string(),
|
||||
is_flow: false,
|
||||
webhook_token: "abcdefghij1234567890".to_string(),
|
||||
};
|
||||
store_native_trigger(
|
||||
&db,
|
||||
"test-workspace",
|
||||
ServiceName::Google,
|
||||
"ext-1",
|
||||
&trigger_config,
|
||||
json!({"triggerType": "drive"}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Cleanup OAuth only — should NOT remove the trigger
|
||||
let mut tx = db.begin().await?;
|
||||
windmill_native_triggers::workspace_integrations::cleanup_oauth_resource(
|
||||
&mut *tx,
|
||||
"test-workspace",
|
||||
ServiceName::Google,
|
||||
)
|
||||
.await;
|
||||
tx.commit().await?;
|
||||
|
||||
// OAuth resources gone
|
||||
let var_count: i64 = sqlx::query_scalar!(
|
||||
"SELECT count(*) FROM variable WHERE workspace_id = 'test-workspace' AND path = $1",
|
||||
resource_path,
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?
|
||||
.unwrap_or(0);
|
||||
assert_eq!(var_count, 0);
|
||||
|
||||
// Trigger still exists
|
||||
let trigger_count: i64 = sqlx::query_scalar!(
|
||||
"SELECT count(*) FROM native_trigger WHERE workspace_id = 'test-workspace' AND service_name = 'google'"
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?
|
||||
.unwrap_or(0);
|
||||
assert_eq!(trigger_count, 1, "trigger should survive OAuth cleanup");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- parse_stop_channel_params ---
|
||||
|
||||
#[test]
|
||||
fn test_parse_stop_channel_params_drive() {
|
||||
let config = json!({
|
||||
"triggerType": "drive",
|
||||
"googleResourceId": "res-123",
|
||||
});
|
||||
let (resource_id, url) = parse_stop_channel_params(&config);
|
||||
assert_eq!(resource_id, "res-123");
|
||||
assert!(
|
||||
url.contains("googleapis.com/drive/v3/channels/stop"),
|
||||
"url={}",
|
||||
url
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_stop_channel_params_calendar() {
|
||||
let config = json!({
|
||||
"triggerType": "calendar",
|
||||
"googleResourceId": "res-456",
|
||||
});
|
||||
let (resource_id, url) = parse_stop_channel_params(&config);
|
||||
assert_eq!(resource_id, "res-456");
|
||||
assert!(
|
||||
url.contains("googleapis.com/calendar/v3/channels/stop"),
|
||||
"url={}",
|
||||
url
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_stop_channel_params_default() {
|
||||
// Missing triggerType defaults to Drive
|
||||
let config = json!({ "googleResourceId": "res-789" });
|
||||
let (resource_id, url) = parse_stop_channel_params(&config);
|
||||
assert_eq!(resource_id, "res-789");
|
||||
assert!(url.contains("drive/v3/channels/stop"), "url={}", url);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_stop_channel_params_missing_resource_id() {
|
||||
let config = json!({ "triggerType": "drive" });
|
||||
let (resource_id, _url) = parse_stop_channel_params(&config);
|
||||
assert_eq!(resource_id, "");
|
||||
}
|
||||
@@ -22,10 +22,12 @@ use ee_oss::validate_license_key;
|
||||
use windmill_common::usernames::generate_instance_username_for_all_users;
|
||||
|
||||
use axum::{
|
||||
extract::{Extension, Path, Query},
|
||||
extract::{Extension, Path},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
#[cfg(feature = "enterprise")]
|
||||
use axum::extract::Query;
|
||||
use serde_json::json;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -407,16 +409,9 @@ async fn get_instance_config(
|
||||
Ok(Json(config))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SetInstanceConfigQuery {
|
||||
#[serde(default)]
|
||||
skip_worker_configs: Option<bool>,
|
||||
}
|
||||
|
||||
async fn set_instance_config(
|
||||
Extension(db): Extension<DB>,
|
||||
authed: ApiAuthed,
|
||||
Query(query): Query<SetInstanceConfigQuery>,
|
||||
Json(desired): Json<InstanceConfig>,
|
||||
) -> error::Result<()> {
|
||||
require_super_admin(&db, &authed.email).await?;
|
||||
@@ -425,20 +420,22 @@ async fn set_instance_config(
|
||||
.await
|
||||
.map_err(|e| error::Error::internal_err(e.to_string()))?;
|
||||
|
||||
let current_map = current.global_settings.to_settings_map();
|
||||
let desired_map = desired.global_settings.to_settings_map();
|
||||
let settings_diff =
|
||||
instance_config::diff_global_settings(¤t_map, &desired_map, ApplyMode::Merge);
|
||||
if !desired_map.is_empty() {
|
||||
let current_map = current.global_settings.to_settings_map();
|
||||
let settings_diff =
|
||||
instance_config::diff_global_settings(¤t_map, &desired_map, ApplyMode::Merge);
|
||||
|
||||
for (key, value) in &settings_diff.upserts {
|
||||
run_setting_pre_write_hook(&db, key, value).await?;
|
||||
for (key, value) in &settings_diff.upserts {
|
||||
run_setting_pre_write_hook(&db, key, value).await?;
|
||||
}
|
||||
|
||||
instance_config::apply_settings_diff(&db, &settings_diff)
|
||||
.await
|
||||
.map_err(|e| error::Error::internal_err(e.to_string()))?;
|
||||
}
|
||||
|
||||
instance_config::apply_settings_diff(&db, &settings_diff)
|
||||
.await
|
||||
.map_err(|e| error::Error::internal_err(e.to_string()))?;
|
||||
|
||||
if !query.skip_worker_configs.unwrap_or(false) {
|
||||
if !desired.worker_configs.is_empty() {
|
||||
let current_wc: std::collections::BTreeMap<String, serde_json::Value> = current
|
||||
.worker_configs
|
||||
.iter()
|
||||
|
||||
@@ -75,6 +75,7 @@ pub fn workspaced_service() -> Router {
|
||||
.route("/whoami", get(whoami))
|
||||
.route("/leave", post(leave_workspace))
|
||||
.route("/username_to_email/:username", get(username_to_email))
|
||||
.route("/list_instance_emails", get(list_instance_emails))
|
||||
}
|
||||
|
||||
pub fn global_service() -> Router {
|
||||
@@ -420,6 +421,22 @@ async fn list_users_as_super_admin(
|
||||
Ok(Json(rows))
|
||||
}
|
||||
|
||||
async fn list_instance_emails(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
) -> JsonResult<Vec<String>> {
|
||||
if *CLOUD_HOSTED {
|
||||
return Err(Error::BadRequest(
|
||||
"This endpoint is not available on cloud hosted instances".to_string(),
|
||||
));
|
||||
}
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
let rows = sqlx::query_scalar!("SELECT email FROM password ORDER BY email LIMIT 1000")
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
Ok(Json(rows))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Progress {
|
||||
progress: u64,
|
||||
|
||||
@@ -2499,6 +2499,7 @@ struct UsedTriggers {
|
||||
pub gcp_used: bool,
|
||||
pub email_used: bool,
|
||||
pub nextcloud_used: bool,
|
||||
pub google_used: bool,
|
||||
}
|
||||
|
||||
async fn get_used_triggers(
|
||||
@@ -2520,7 +2521,8 @@ async fn get_used_triggers(
|
||||
EXISTS(SELECT 1 FROM sqs_trigger WHERE workspace_id = $1) AS "sqs_used!",
|
||||
EXISTS(SELECT 1 FROM gcp_trigger WHERE workspace_id = $1) AS "gcp_used!",
|
||||
EXISTS(SELECT 1 FROM email_trigger WHERE workspace_id = $1) AS "email_used!",
|
||||
EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = 'nextcloud'::native_trigger_service) AS "nextcloud_used!"
|
||||
EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = 'nextcloud'::native_trigger_service) AS "nextcloud_used!",
|
||||
EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = 'google'::native_trigger_service) AS "google_used!"
|
||||
"#,
|
||||
w_id
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.634.6
|
||||
version: 1.636.0
|
||||
title: Windmill API
|
||||
|
||||
contact:
|
||||
@@ -1497,16 +1497,10 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InstanceConfig"
|
||||
put:
|
||||
summary: update instance config (bulk upsert, no deletes)
|
||||
summary: update instance config (bulk upsert, no deletes). Empty or missing global_settings/worker_configs are skipped.
|
||||
operationId: setInstanceConfig
|
||||
tags:
|
||||
- setting
|
||||
parameters:
|
||||
- name: skip_worker_configs
|
||||
in: query
|
||||
description: if true, ignore worker_configs in the request body
|
||||
schema:
|
||||
type: boolean
|
||||
requestBody:
|
||||
description: full instance configuration to apply
|
||||
required: true
|
||||
@@ -2403,6 +2397,24 @@ paths:
|
||||
items:
|
||||
$ref: "#/components/schemas/GlobalUserInfo"
|
||||
|
||||
/w/{workspace}/users/list_instance_emails:
|
||||
get:
|
||||
summary: list all instance user emails (only on non-cloud instances, requires workspace admin)
|
||||
operationId: listInstanceEmails
|
||||
tags:
|
||||
- user
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
responses:
|
||||
"200":
|
||||
description: list of instance user emails
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/list_pending_invites:
|
||||
get:
|
||||
summary: list pending invites for a workspace
|
||||
@@ -3664,6 +3676,8 @@ paths:
|
||||
type: boolean
|
||||
nextcloud_used:
|
||||
type: boolean
|
||||
google_used:
|
||||
type: boolean
|
||||
required:
|
||||
- http_routes_used
|
||||
- websocket_used
|
||||
@@ -3675,6 +3689,7 @@ paths:
|
||||
- sqs_used
|
||||
- email_used
|
||||
- nextcloud_used
|
||||
- google_used
|
||||
/w/{workspace}/users/list:
|
||||
get:
|
||||
summary: list users
|
||||
@@ -12326,6 +12341,55 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/native_triggers/integrations/{service_name}/instance_sharing_available:
|
||||
get:
|
||||
summary: check if instance-level credential sharing is available for a service
|
||||
operationId: checkInstanceSharingAvailable
|
||||
tags:
|
||||
- workspace_integration
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: service_name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
$ref: "#/components/schemas/NativeServiceName"
|
||||
responses:
|
||||
"200":
|
||||
description: whether instance sharing is available
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: boolean
|
||||
|
||||
/w/{workspace}/native_triggers/integrations/{service_name}/generate_instance_connect_url:
|
||||
post:
|
||||
summary: generate connect url using instance-level credentials
|
||||
operationId: generateInstanceConnectUrl
|
||||
tags:
|
||||
- workspace_integration
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: service_name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
$ref: "#/components/schemas/NativeServiceName"
|
||||
requestBody:
|
||||
description: redirect_uri
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/RedirectUri"
|
||||
responses:
|
||||
"200":
|
||||
description: authorization URL using instance credentials
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/native_triggers/integrations/{service_name}/delete:
|
||||
delete:
|
||||
summary: delete native trigger service
|
||||
@@ -12347,7 +12411,7 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/native_triggers/integrations/{service_name}/callback/{code}/{state}:
|
||||
/w/{workspace}/native_triggers/integrations/{service_name}/callback:
|
||||
post:
|
||||
summary: native trigger service oauth callback
|
||||
operationId: nativeTriggerServiceCallback
|
||||
@@ -12360,23 +12424,26 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
$ref: "#/components/schemas/NativeServiceName"
|
||||
- name: code
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: state
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
description: redirect_uri
|
||||
description: OAuth callback data
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/RedirectUri"
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
state:
|
||||
type: string
|
||||
redirect_uri:
|
||||
type: string
|
||||
resource_path:
|
||||
type: string
|
||||
required:
|
||||
- code
|
||||
- state
|
||||
- redirect_uri
|
||||
responses:
|
||||
"200":
|
||||
description: native trigger service oauth completed
|
||||
@@ -12616,6 +12683,91 @@ paths:
|
||||
items:
|
||||
$ref: "#/components/schemas/NextCloudEventType"
|
||||
|
||||
/w/{workspace}/native_triggers/google/calendars:
|
||||
get:
|
||||
summary: list Google Calendars for the authenticated user
|
||||
operationId: listGoogleCalendars
|
||||
tags:
|
||||
- native_trigger
|
||||
parameters:
|
||||
- name: workspace
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: list of Google Calendars
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/GoogleCalendarEntry"
|
||||
|
||||
/w/{workspace}/native_triggers/google/drive/files:
|
||||
get:
|
||||
summary: list or search Google Drive files
|
||||
operationId: listGoogleDriveFiles
|
||||
tags:
|
||||
- native_trigger
|
||||
parameters:
|
||||
- name: workspace
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: q
|
||||
in: query
|
||||
description: search query to filter files by name
|
||||
schema:
|
||||
type: string
|
||||
- name: parent_id
|
||||
in: query
|
||||
description: folder ID to list children of
|
||||
schema:
|
||||
type: string
|
||||
- name: page_token
|
||||
in: query
|
||||
description: token for next page of results
|
||||
schema:
|
||||
type: string
|
||||
- name: shared_with_me
|
||||
in: query
|
||||
description: if true, list files shared with the user
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
responses:
|
||||
"200":
|
||||
description: list of Google Drive files
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GoogleDriveFilesResponse"
|
||||
|
||||
/w/{workspace}/native_triggers/google/drive/shared_drives:
|
||||
get:
|
||||
summary: list shared drives accessible to the user
|
||||
operationId: listGoogleSharedDrives
|
||||
tags:
|
||||
- native_trigger
|
||||
parameters:
|
||||
- name: workspace
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: list of shared drives
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/SharedDriveEntry"
|
||||
|
||||
/native_triggers/{service_name}/w/{workspace_id}/webhook/{internal_id}:
|
||||
post:
|
||||
summary: receive webhook from external native trigger service
|
||||
@@ -20056,6 +20208,7 @@ components:
|
||||
- mqtt
|
||||
- sqs
|
||||
- gcp
|
||||
- google
|
||||
|
||||
TriggerMode:
|
||||
description: job trigger mode
|
||||
@@ -20519,6 +20672,8 @@ components:
|
||||
type: number
|
||||
nextcloud_count:
|
||||
type: number
|
||||
google_count:
|
||||
type: number
|
||||
|
||||
WebsocketTrigger:
|
||||
allOf:
|
||||
@@ -23519,6 +23674,7 @@ components:
|
||||
type: string
|
||||
enum:
|
||||
- nextcloud
|
||||
- google
|
||||
|
||||
NativeTrigger:
|
||||
type: object
|
||||
@@ -23601,6 +23757,10 @@ components:
|
||||
oauth_data:
|
||||
nullable: true
|
||||
$ref: "#/components/schemas/WorkspaceOAuthConfig"
|
||||
resource_path:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Path to the resource storing the OAuth token
|
||||
required:
|
||||
- service_name
|
||||
|
||||
@@ -23722,3 +23882,57 @@ components:
|
||||
- id
|
||||
- name
|
||||
- path
|
||||
|
||||
GoogleCalendarEntry:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
summary:
|
||||
type: string
|
||||
primary:
|
||||
type: boolean
|
||||
default: false
|
||||
required:
|
||||
- id
|
||||
- summary
|
||||
|
||||
GoogleDriveFile:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
mime_type:
|
||||
type: string
|
||||
is_folder:
|
||||
type: boolean
|
||||
default: false
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- mime_type
|
||||
|
||||
GoogleDriveFilesResponse:
|
||||
type: object
|
||||
properties:
|
||||
files:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/GoogleDriveFile"
|
||||
next_page_token:
|
||||
type: string
|
||||
required:
|
||||
- files
|
||||
|
||||
SharedDriveEntry:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
|
||||
@@ -3413,29 +3413,43 @@ pub async fn get_args_and_trigger_metadata(
|
||||
|
||||
// Build trigger metadata if this is a native trigger request
|
||||
#[cfg(feature = "native_trigger")]
|
||||
let trigger_metadata = if let Some(service_name_str) = &run_query.service_name {
|
||||
use crate::native_triggers::ServiceName;
|
||||
let (trigger_metadata, native_args) = if let Some(service_name_str) = &run_query.service_name {
|
||||
use crate::native_triggers::{prepare_native_trigger_args, ServiceName};
|
||||
let service_name = ServiceName::try_from(service_name_str.to_owned())?;
|
||||
Some(TriggerMetadata::new(
|
||||
let metadata = Some(TriggerMetadata::new(
|
||||
run_query.trigger_external_id.clone(),
|
||||
service_name.as_job_trigger_kind(),
|
||||
))
|
||||
));
|
||||
let body = match &args.body {
|
||||
crate::args::RawBody::Json(s) => s.clone(),
|
||||
crate::args::RawBody::Text(s) => s.clone(),
|
||||
_ => String::new(),
|
||||
};
|
||||
let native =
|
||||
prepare_native_trigger_args(service_name, db, w_id, &args.metadata.headers, body)
|
||||
.await?;
|
||||
(metadata, native)
|
||||
} else {
|
||||
None
|
||||
(None, None)
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "native_trigger"))]
|
||||
let trigger_metadata: Option<TriggerMetadata> = None;
|
||||
#[cfg(not(feature = "native_trigger"))]
|
||||
let native_args: Option<windmill_queue::PushArgsOwned> = None;
|
||||
|
||||
let args = args
|
||||
.to_args_from_runnable(
|
||||
let args = if let Some(prepared) = native_args {
|
||||
prepared
|
||||
} else {
|
||||
args.to_args_from_runnable(
|
||||
&authed,
|
||||
&db,
|
||||
&w_id,
|
||||
runnable_id,
|
||||
run_query.skip_preprocessor,
|
||||
)
|
||||
.await?;
|
||||
.await?
|
||||
};
|
||||
|
||||
Ok((args, trigger_metadata))
|
||||
}
|
||||
|
||||
@@ -136,6 +136,7 @@ pub struct TriggersCount {
|
||||
sqs_count: i64,
|
||||
gcp_count: i64,
|
||||
nextcloud_count: i64,
|
||||
google_count: i64,
|
||||
}
|
||||
|
||||
pub async fn get_triggers_count_internal(
|
||||
@@ -305,6 +306,16 @@ pub async fn get_triggers_count_internal(
|
||||
.await?
|
||||
.unwrap_or(0);
|
||||
|
||||
let google_count = sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) FROM native_trigger WHERE workspace_id = $1 AND script_path = $2 AND is_flow = $3 AND service_name = 'google'",
|
||||
w_id,
|
||||
path,
|
||||
is_flow,
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?
|
||||
.unwrap_or(0);
|
||||
|
||||
Ok(axum::Json(TriggersCount {
|
||||
primary_schedule: primary_schedule
|
||||
.map(|s| windmill_trigger::handler::TriggerPrimarySchedule { schedule: s }),
|
||||
@@ -321,5 +332,6 @@ pub async fn get_triggers_count_internal(
|
||||
gcp_count,
|
||||
sqs_count,
|
||||
nextcloud_count,
|
||||
google_count,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -335,6 +335,15 @@ async fn update_username_in_workpsace<'c>(
|
||||
).execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
r#"UPDATE workspace_integrations SET resource_path = REGEXP_REPLACE(resource_path, 'u/' || $2 || '/(.*)', 'u/' || $1 || '/\1') WHERE resource_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#,
|
||||
new_username,
|
||||
old_username,
|
||||
w_id
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE variable SET extra_perms = extra_perms - ('u/' || $2) || jsonb_build_object(('u/' || $1), extra_perms->('u/' || $2)) WHERE extra_perms ? ('u/' || $2) AND workspace_id = $3",
|
||||
new_username,
|
||||
|
||||
@@ -145,6 +145,68 @@ pub async fn load_value_from_global_settings(
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Read OAuth client_id and client_secret from instance-level global settings.
|
||||
/// `oauth_key` is the key under `oauths` (e.g., "gworkspace", "nextcloud").
|
||||
pub async fn get_instance_oauth_credentials(
|
||||
db: &Pool<Postgres>,
|
||||
oauth_key: &str,
|
||||
) -> error::Result<(String, String)> {
|
||||
let oauths_value = load_value_from_global_settings(db, OAUTH_SETTING)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
error::Error::InternalErr("Instance OAuth settings not found".to_string())
|
||||
})?;
|
||||
|
||||
let entry = oauths_value.get(oauth_key).ok_or_else(|| {
|
||||
error::Error::InternalErr(format!("No {} entry in instance OAuth settings", oauth_key))
|
||||
})?;
|
||||
|
||||
let id = entry
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let secret = entry
|
||||
.get("secret")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
if id.is_empty() || secret.is_empty() {
|
||||
return Err(error::Error::InternalErr(format!(
|
||||
"Instance OAuth credentials for {} are incomplete",
|
||||
oauth_key
|
||||
)));
|
||||
}
|
||||
|
||||
Ok((id, secret))
|
||||
}
|
||||
|
||||
/// Map service client name to the OAuth settings key in global_settings.
|
||||
/// e.g. "google" -> "gworkspace", "nextcloud" -> "nextcloud"
|
||||
pub fn workspace_integration_oauth_key(client_name: &str) -> &str {
|
||||
match client_name {
|
||||
"google" => "gworkspace",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the token endpoint URL for a workspace integration service.
|
||||
pub fn workspace_integration_token_endpoint(client_name: &str, base_url: &str) -> String {
|
||||
match client_name {
|
||||
"google" => "https://oauth2.googleapis.com/token".to_string(),
|
||||
_ => format!("{}/apps/oauth2/api/v1/token", base_url),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the auth endpoint URL for a workspace integration service.
|
||||
pub fn workspace_integration_auth_endpoint(client_name: &str, base_url: &str) -> String {
|
||||
match client_name {
|
||||
"google" => "https://accounts.google.com/o/oauth2/v2/auth".to_string(),
|
||||
_ => format!("{}/apps/oauth2/authorize", base_url),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn set_value_in_global_settings(
|
||||
db: &Pool<Postgres>,
|
||||
setting_name: &str,
|
||||
|
||||
@@ -3,6 +3,8 @@ use std::fmt;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::global_settings::LICENSE_KEY_SETTING;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Kubernetes Secret reference support
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -164,6 +166,28 @@ pub struct InstanceConfig {
|
||||
// Global settings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Generate a schema for opaque JSON objects (used for EE-private settings).
|
||||
/// Produces `{"type": "object", "nullable": true}` so the CRD passes K8s
|
||||
/// structural schema validation while still accepting any JSON object.
|
||||
#[cfg(feature = "instance_config_schema")]
|
||||
fn opaque_json_schema(_: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
|
||||
schemars::schema::SchemaObject {
|
||||
instance_type: Some(schemars::schema::InstanceType::Object.into()),
|
||||
metadata: Some(Box::default()),
|
||||
extensions: {
|
||||
let mut m = schemars::Map::new();
|
||||
m.insert("nullable".to_string(), serde_json::Value::Bool(true));
|
||||
m.insert(
|
||||
"x-kubernetes-preserve-unknown-fields".to_string(),
|
||||
serde_json::Value::Bool(true),
|
||||
);
|
||||
m
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Typed global settings with schema validation.
|
||||
/// Known settings have explicit fields; unknown settings pass through via `extra`.
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, Default)]
|
||||
@@ -265,6 +289,10 @@ pub struct GlobalSettings {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub otel_tracing_proxy: Option<OtelTracingProxySettings>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(
|
||||
feature = "instance_config_schema",
|
||||
schemars(schema_with = "opaque_json_schema")
|
||||
)]
|
||||
pub object_store_cache_config: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub critical_error_channels: Option<Vec<CriticalErrorChannel>>,
|
||||
@@ -277,10 +305,22 @@ pub struct GlobalSettings {
|
||||
|
||||
// Opaque settings (EE-private structs or no clear schema)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(
|
||||
feature = "instance_config_schema",
|
||||
schemars(schema_with = "opaque_json_schema")
|
||||
)]
|
||||
pub secret_backend: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(
|
||||
feature = "instance_config_schema",
|
||||
schemars(schema_with = "opaque_json_schema")
|
||||
)]
|
||||
pub slack: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(
|
||||
feature = "instance_config_schema",
|
||||
schemars(schema_with = "opaque_json_schema")
|
||||
)]
|
||||
pub teams: Option<serde_json::Value>,
|
||||
|
||||
/// Catch-all for settings not yet covered by typed fields.
|
||||
@@ -368,6 +408,8 @@ pub struct OAuthClient {
|
||||
pub connect_config: Option<OAuthConfig>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub login_config: Option<OAuthConfig>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub share_with_workspaces: Option<bool>,
|
||||
}
|
||||
|
||||
/// OAuth provider endpoint configuration.
|
||||
@@ -717,6 +759,33 @@ pub const PROTECTED_SETTINGS: &[&str] = &[
|
||||
/// Internal settings that are never exposed via the API or included in config exports.
|
||||
pub const HIDDEN_SETTINGS: &[&str] = &["uid", "rsa_keys", "jwt_secret", "min_keep_alive_version"];
|
||||
|
||||
/// Extract the expiry timestamp from a license key JSON value.
|
||||
///
|
||||
/// License keys have the format `<client_id>.<expiry>.<signature>`.
|
||||
/// Returns `None` if the value is not a string or doesn't match the format.
|
||||
fn license_key_expiry(value: &serde_json::Value) -> Option<u64> {
|
||||
let s = value.as_str()?;
|
||||
let parts: Vec<&str> = s.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
return None;
|
||||
}
|
||||
parts[1].parse::<u64>().ok()
|
||||
}
|
||||
|
||||
/// Returns true if two license key values share the same client ID and signature
|
||||
/// (i.e. they differ only in the expiry field).
|
||||
fn license_keys_same_except_expiry(a: &serde_json::Value, b: &serde_json::Value) -> bool {
|
||||
let (Some(a_str), Some(b_str)) = (a.as_str(), b.as_str()) else {
|
||||
return false;
|
||||
};
|
||||
let a_parts: Vec<&str> = a_str.split('.').collect();
|
||||
let b_parts: Vec<&str> = b_str.split('.').collect();
|
||||
if a_parts.len() != 3 || b_parts.len() != 3 {
|
||||
return false;
|
||||
}
|
||||
a_parts[0] == b_parts[0] && a_parts[2] == b_parts[2]
|
||||
}
|
||||
|
||||
/// Compute the diff between current and desired global settings.
|
||||
pub fn diff_global_settings(
|
||||
current: &BTreeMap<String, serde_json::Value>,
|
||||
@@ -727,6 +796,23 @@ pub fn diff_global_settings(
|
||||
for (key, value) in desired {
|
||||
match current.get(key) {
|
||||
Some(existing) if existing == value => {} // no change
|
||||
Some(existing) if key == LICENSE_KEY_SETTING => {
|
||||
if license_keys_same_except_expiry(existing, value) {
|
||||
let current_expiry = license_key_expiry(existing).unwrap_or(0);
|
||||
let desired_expiry = license_key_expiry(value).unwrap_or(0);
|
||||
if desired_expiry > current_expiry {
|
||||
upserts.insert(key.clone(), value.clone());
|
||||
} else {
|
||||
tracing::info!(
|
||||
"Skipping license_key update: desired expiry ({}) is not posterior to current expiry ({})",
|
||||
desired_expiry,
|
||||
current_expiry
|
||||
);
|
||||
}
|
||||
} else {
|
||||
upserts.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
upserts.insert(key.clone(), value.clone());
|
||||
}
|
||||
@@ -1866,6 +1952,7 @@ mod tests {
|
||||
allowed_domains: None,
|
||||
connect_config: None,
|
||||
login_config: None,
|
||||
share_with_workspaces: None,
|
||||
},
|
||||
);
|
||||
m
|
||||
@@ -1910,4 +1997,117 @@ mod tests {
|
||||
Some("plain-token")
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// License key expiry diff tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn diff_license_key_skips_older_expiry() {
|
||||
let mut current = BTreeMap::new();
|
||||
current.insert(
|
||||
"license_key".to_string(),
|
||||
serde_json::json!("client1.2000000000.sig123"),
|
||||
);
|
||||
|
||||
let mut desired = BTreeMap::new();
|
||||
desired.insert(
|
||||
"license_key".to_string(),
|
||||
serde_json::json!("client1.1000000000.sig123"),
|
||||
);
|
||||
|
||||
let diff = diff_global_settings(¤t, &desired, ApplyMode::Merge);
|
||||
assert!(
|
||||
diff.upserts.is_empty(),
|
||||
"Should not update license_key when desired expiry is older"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_license_key_skips_equal_expiry() {
|
||||
let mut current = BTreeMap::new();
|
||||
current.insert(
|
||||
"license_key".to_string(),
|
||||
serde_json::json!("client1.2000000000.sig_a"),
|
||||
);
|
||||
|
||||
let mut desired = BTreeMap::new();
|
||||
desired.insert(
|
||||
"license_key".to_string(),
|
||||
serde_json::json!("client1.2000000000.sig_b"),
|
||||
);
|
||||
|
||||
let diff = diff_global_settings(¤t, &desired, ApplyMode::Merge);
|
||||
assert_eq!(
|
||||
diff.upserts.len(),
|
||||
1,
|
||||
"Different signature means different key, should update"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_license_key_updates_newer_expiry() {
|
||||
let mut current = BTreeMap::new();
|
||||
current.insert(
|
||||
"license_key".to_string(),
|
||||
serde_json::json!("client1.1000000000.sig123"),
|
||||
);
|
||||
|
||||
let mut desired = BTreeMap::new();
|
||||
desired.insert(
|
||||
"license_key".to_string(),
|
||||
serde_json::json!("client1.2000000000.sig123"),
|
||||
);
|
||||
|
||||
let diff = diff_global_settings(¤t, &desired, ApplyMode::Merge);
|
||||
assert_eq!(diff.upserts.len(), 1);
|
||||
assert_eq!(
|
||||
diff.upserts["license_key"],
|
||||
serde_json::json!("client1.2000000000.sig123")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_license_key_updates_different_client() {
|
||||
let mut current = BTreeMap::new();
|
||||
current.insert(
|
||||
"license_key".to_string(),
|
||||
serde_json::json!("client1.2000000000.sig123"),
|
||||
);
|
||||
|
||||
let mut desired = BTreeMap::new();
|
||||
desired.insert(
|
||||
"license_key".to_string(),
|
||||
serde_json::json!("client2.1000000000.sig456"),
|
||||
);
|
||||
|
||||
let diff = diff_global_settings(¤t, &desired, ApplyMode::Merge);
|
||||
assert_eq!(
|
||||
diff.upserts.len(),
|
||||
1,
|
||||
"Different client ID means different key, should always update"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_license_key_non_string_always_updates() {
|
||||
let mut current = BTreeMap::new();
|
||||
current.insert(
|
||||
"license_key".to_string(),
|
||||
serde_json::json!({"envRef": "LIC_KEY"}),
|
||||
);
|
||||
|
||||
let mut desired = BTreeMap::new();
|
||||
desired.insert(
|
||||
"license_key".to_string(),
|
||||
serde_json::json!({"envRef": "NEW_LIC_KEY"}),
|
||||
);
|
||||
|
||||
let diff = diff_global_settings(¤t, &desired, ApplyMode::Merge);
|
||||
assert_eq!(
|
||||
diff.upserts.len(),
|
||||
1,
|
||||
"Non-string license keys should always be updated when different"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
531
backend/windmill-native-triggers/src/google/external.rs
Normal file
531
backend/windmill-native-triggers/src/google/external.rs
Normal file
@@ -0,0 +1,531 @@
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Method;
|
||||
use serde_json::value::RawValue;
|
||||
use sqlx::PgConnection;
|
||||
use std::collections::HashMap;
|
||||
use windmill_common::{
|
||||
error::{Error, Result},
|
||||
worker::to_raw_value,
|
||||
BASE_URL, DB,
|
||||
};
|
||||
use windmill_queue::PushArgsOwned;
|
||||
|
||||
use crate::{
|
||||
generate_webhook_service_url, get_token_by_prefix,
|
||||
sync::{SyncAction, SyncError, TriggerSyncInfo},
|
||||
update_native_trigger_error, update_native_trigger_service_config, External, NativeTrigger,
|
||||
NativeTriggerData, ServiceName,
|
||||
};
|
||||
|
||||
use super::{
|
||||
endpoints, routes, CreateWatchResponse, Google, GoogleOAuthData, GoogleServiceConfig,
|
||||
GoogleTriggerType, StopChannelRequest, WatchRequest,
|
||||
};
|
||||
|
||||
#[async_trait]
|
||||
impl External for Google {
|
||||
type ServiceConfig = GoogleServiceConfig;
|
||||
// Google has no "get channel" API, so TriggerData is never constructed.
|
||||
// The trait default for get() returns Ok(None).
|
||||
type TriggerData = ();
|
||||
type OAuthData = GoogleOAuthData;
|
||||
type CreateResponse = CreateWatchResponse;
|
||||
|
||||
const SERVICE_NAME: ServiceName = ServiceName::Google;
|
||||
const DISPLAY_NAME: &'static str = "Google";
|
||||
const SUPPORT_WEBHOOK: bool = true;
|
||||
const TOKEN_ENDPOINT: &'static str = "https://oauth2.googleapis.com/token";
|
||||
const REFRESH_ENDPOINT: &'static str = "https://oauth2.googleapis.com/token";
|
||||
const AUTH_ENDPOINT: &'static str = "https://accounts.google.com/o/oauth2/v2/auth";
|
||||
|
||||
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> {
|
||||
let channel_id = uuid::Uuid::new_v4().to_string();
|
||||
self.create_watch_channel(w_id, &channel_id, webhook_token, data, db)
|
||||
.await
|
||||
}
|
||||
|
||||
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<serde_json::Value> {
|
||||
// Google doesn't support updating watch channels — delete old, create new.
|
||||
let _ = self.delete(w_id, oauth_data, external_id, db, tx).await;
|
||||
|
||||
// Reuse the same channel ID so external_id stays permanent
|
||||
let resp = self
|
||||
.create_watch_channel(w_id, external_id, webhook_token, data, db)
|
||||
.await?;
|
||||
|
||||
self.service_config_from_create_response(data, &resp)
|
||||
.ok_or_else(|| {
|
||||
Error::InternalErr(
|
||||
"Failed to build service_config from create response".to_string(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
w_id: &str,
|
||||
_oauth_data: &Self::OAuthData,
|
||||
external_id: &str,
|
||||
db: &DB,
|
||||
tx: &mut PgConnection,
|
||||
) -> Result<()> {
|
||||
// Get the stored trigger to find the google_resource_id and trigger_type
|
||||
let trigger = sqlx::query_scalar!(
|
||||
r#"
|
||||
SELECT service_config
|
||||
FROM native_trigger
|
||||
WHERE external_id = $1 AND service_name = $2 AND workspace_id = $3
|
||||
"#,
|
||||
external_id,
|
||||
ServiceName::Google as ServiceName,
|
||||
w_id
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let config = trigger.flatten();
|
||||
if config.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let config = config.unwrap();
|
||||
|
||||
let (google_resource_id, url) = super::parse_stop_channel_params(&config);
|
||||
|
||||
if !google_resource_id.is_empty() {
|
||||
let stop_request =
|
||||
StopChannelRequest { id: external_id.to_string(), resource_id: google_resource_id };
|
||||
|
||||
// Stop the channel (ignore errors - channel may have already expired)
|
||||
let result: std::result::Result<serde_json::Value, _> = self
|
||||
.http_client_request(&url, Method::POST, w_id, db, None, Some(&stop_request))
|
||||
.await;
|
||||
|
||||
if let Err(e) = result {
|
||||
tracing::warn!("Failed to stop Google channel {}: {}", external_id, e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn maintain_triggers(
|
||||
&self,
|
||||
db: &DB,
|
||||
workspace_id: &str,
|
||||
triggers: &[NativeTrigger],
|
||||
_oauth_data: &Self::OAuthData,
|
||||
synced: &mut Vec<TriggerSyncInfo>,
|
||||
errors: &mut Vec<SyncError>,
|
||||
) {
|
||||
renew_expiring_channels(self, db, workspace_id, triggers, synced, errors).await;
|
||||
}
|
||||
|
||||
async fn prepare_webhook(
|
||||
&self,
|
||||
_db: &DB,
|
||||
_w_id: &str,
|
||||
headers: HashMap<String, String>,
|
||||
_body: String,
|
||||
_script_path: &str,
|
||||
_is_flow: bool,
|
||||
) -> Result<PushArgsOwned> {
|
||||
// Google sends notification info in headers (same format for Drive and Calendar)
|
||||
let payload = serde_json::json!({
|
||||
"channel_id": headers.get("x-goog-channel-id").cloned().unwrap_or_default(),
|
||||
"resource_id": headers.get("x-goog-resource-id").cloned().unwrap_or_default(),
|
||||
"resource_state": headers.get("x-goog-resource-state").cloned().unwrap_or_default(),
|
||||
"resource_uri": headers.get("x-goog-resource-uri").cloned().unwrap_or_default(),
|
||||
"message_number": headers.get("x-goog-message-number").cloned().unwrap_or_default(),
|
||||
"channel_expiration": headers.get("x-goog-channel-expiration").cloned().unwrap_or_default(),
|
||||
"changed": headers.get("x-goog-changed").cloned().unwrap_or_default(),
|
||||
"channel_token": headers.get("x-goog-channel-token").cloned().unwrap_or_default(),
|
||||
});
|
||||
|
||||
let mut args: HashMap<String, Box<RawValue>> = HashMap::new();
|
||||
args.insert("payload".to_string(), to_raw_value(&payload));
|
||||
|
||||
Ok(PushArgsOwned { extra: None, args })
|
||||
}
|
||||
|
||||
fn external_id_and_metadata_from_response(
|
||||
&self,
|
||||
resp: &Self::CreateResponse,
|
||||
) -> (String, Option<serde_json::Value>) {
|
||||
let metadata = serde_json::json!({
|
||||
"googleResourceId": resp.resource_id,
|
||||
"expiration": resp.expiration,
|
||||
});
|
||||
(resp.id.clone(), Some(metadata))
|
||||
}
|
||||
|
||||
fn service_config_from_create_response(
|
||||
&self,
|
||||
data: &NativeTriggerData<Self::ServiceConfig>,
|
||||
resp: &Self::CreateResponse,
|
||||
) -> Option<serde_json::Value> {
|
||||
let mut config = data.service_config.clone();
|
||||
config.google_resource_id = Some(resp.resource_id.clone());
|
||||
config.expiration = Some(resp.expiration.clone());
|
||||
serde_json::to_value(&config).ok()
|
||||
}
|
||||
|
||||
fn additional_routes(&self) -> axum::Router {
|
||||
routes::google_routes(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
// Helper methods for creating trigger type-specific watches
|
||||
impl Google {
|
||||
/// Build a webhook URL and watch request, then register the channel with Google.
|
||||
/// Used by both `create()` (new UUID) and `update()` (reuse existing external_id).
|
||||
async fn create_watch_channel(
|
||||
&self,
|
||||
w_id: &str,
|
||||
channel_id: &str,
|
||||
webhook_token: &str,
|
||||
data: &NativeTriggerData<GoogleServiceConfig>,
|
||||
db: &DB,
|
||||
) -> Result<CreateWatchResponse> {
|
||||
let base_url = &*BASE_URL.read().await;
|
||||
let webhook_url = generate_webhook_service_url(
|
||||
base_url,
|
||||
w_id,
|
||||
&data.script_path,
|
||||
data.is_flow,
|
||||
Some(channel_id),
|
||||
ServiceName::Google,
|
||||
webhook_token,
|
||||
);
|
||||
|
||||
tracing::info!(
|
||||
"Creating Google {} watch channel '{}' with webhook URL: {}",
|
||||
data.service_config.trigger_type,
|
||||
channel_id,
|
||||
webhook_url
|
||||
);
|
||||
|
||||
let expiration_ms = chrono::Utc::now().timestamp_millis()
|
||||
+ (data.service_config.max_expiration_hours() as i64 * 3600 * 1000);
|
||||
let mut watch_request = WatchRequest::new(channel_id.to_string(), webhook_url);
|
||||
watch_request.expiration = Some(expiration_ms);
|
||||
|
||||
match data.service_config.trigger_type {
|
||||
GoogleTriggerType::Drive => {
|
||||
self.create_drive_watch(w_id, &data.service_config, &watch_request, db)
|
||||
.await
|
||||
}
|
||||
GoogleTriggerType::Calendar => {
|
||||
self.create_calendar_watch(w_id, &data.service_config, &watch_request, db)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_drive_watch(
|
||||
&self,
|
||||
w_id: &str,
|
||||
config: &GoogleServiceConfig,
|
||||
watch_request: &WatchRequest,
|
||||
db: &DB,
|
||||
) -> Result<CreateWatchResponse> {
|
||||
match config.resource_id.as_deref().filter(|s| !s.is_empty()) {
|
||||
Some(resource_id) => {
|
||||
// Specific file: use files.watch
|
||||
let url = format!("{}/files/{}/watch", endpoints::DRIVE_API_BASE, resource_id);
|
||||
|
||||
self.http_client_request(&url, Method::POST, w_id, db, None, Some(watch_request))
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
// All changes: use changes.watch
|
||||
let token_url = format!("{}/changes/startPageToken", endpoints::DRIVE_API_BASE);
|
||||
let token_response: serde_json::Value = self
|
||||
.http_client_request::<_, ()>(&token_url, Method::GET, w_id, db, None, None)
|
||||
.await?;
|
||||
|
||||
let start_page_token = token_response
|
||||
.get("startPageToken")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
Error::InternalErr("Failed to get startPageToken".to_string())
|
||||
})?;
|
||||
|
||||
let watch_body = serde_json::to_value(watch_request)?;
|
||||
let watch_url = format!(
|
||||
"{}/changes/watch?pageToken={}",
|
||||
endpoints::DRIVE_API_BASE,
|
||||
start_page_token
|
||||
);
|
||||
|
||||
self.http_client_request(
|
||||
&watch_url,
|
||||
Method::POST,
|
||||
w_id,
|
||||
db,
|
||||
None,
|
||||
Some(&watch_body),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_calendar_watch(
|
||||
&self,
|
||||
w_id: &str,
|
||||
config: &GoogleServiceConfig,
|
||||
watch_request: &WatchRequest,
|
||||
db: &DB,
|
||||
) -> Result<CreateWatchResponse> {
|
||||
let calendar_id = config.calendar_id.as_ref().ok_or_else(|| {
|
||||
Error::BadRequest("calendar_id is required for Calendar triggers".into())
|
||||
})?;
|
||||
|
||||
let url = format!(
|
||||
"{}/calendars/{}/events/watch",
|
||||
endpoints::CALENDAR_API_BASE,
|
||||
urlencoding::encode(calendar_id)
|
||||
);
|
||||
|
||||
self.http_client_request(&url, Method::POST, w_id, db, None, Some(watch_request))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Renew an expiring Google watch channel.
|
||||
/// Stops the old channel and creates a new one with the same channel ID.
|
||||
/// Returns the updated service_config with new expiration.
|
||||
pub async fn renew_channel(
|
||||
&self,
|
||||
w_id: &str,
|
||||
trigger: &NativeTrigger,
|
||||
db: &DB,
|
||||
) -> Result<serde_json::Value> {
|
||||
let config: GoogleServiceConfig = trigger
|
||||
.service_config
|
||||
.as_ref()
|
||||
.map(|v| serde_json::from_value(v.clone()))
|
||||
.transpose()?
|
||||
.ok_or_else(|| Error::InternalErr("Missing service config".to_string()))?;
|
||||
|
||||
let webhook_token = get_token_by_prefix(db, &trigger.webhook_token_prefix)
|
||||
.await?
|
||||
.ok_or_else(|| Error::InternalErr("Webhook token not found".to_string()))?;
|
||||
|
||||
let base_url = &*BASE_URL.read().await;
|
||||
// Reuse the same channel ID so external_id stays permanent
|
||||
let channel_id = trigger.external_id.clone();
|
||||
let webhook_url = generate_webhook_service_url(
|
||||
base_url,
|
||||
w_id,
|
||||
&trigger.script_path,
|
||||
trigger.is_flow,
|
||||
Some(&channel_id),
|
||||
ServiceName::Google,
|
||||
&webhook_token,
|
||||
);
|
||||
|
||||
tracing::info!(
|
||||
"Renewing Google {} watch channel '{}' with webhook URL: {}",
|
||||
config.trigger_type,
|
||||
channel_id,
|
||||
webhook_url
|
||||
);
|
||||
|
||||
let expiration_ms = chrono::Utc::now().timestamp_millis()
|
||||
+ (config.max_expiration_hours() as i64 * 3600 * 1000);
|
||||
let mut watch_request = WatchRequest::new(channel_id.clone(), webhook_url);
|
||||
watch_request.expiration = Some(expiration_ms);
|
||||
|
||||
// Best-effort stop old channel before creating a new one
|
||||
let old_google_resource_id = trigger
|
||||
.service_config
|
||||
.as_ref()
|
||||
.and_then(|c| c.get("googleResourceId"))
|
||||
.and_then(|r| r.as_str())
|
||||
.unwrap_or_default();
|
||||
|
||||
if !old_google_resource_id.is_empty() {
|
||||
let stop_request = StopChannelRequest {
|
||||
id: channel_id.clone(),
|
||||
resource_id: old_google_resource_id.to_string(),
|
||||
};
|
||||
let url = match config.trigger_type {
|
||||
GoogleTriggerType::Calendar => {
|
||||
format!("{}/channels/stop", endpoints::CALENDAR_API_BASE)
|
||||
}
|
||||
GoogleTriggerType::Drive => {
|
||||
format!("{}/channels/stop", endpoints::DRIVE_API_BASE)
|
||||
}
|
||||
};
|
||||
let result: std::result::Result<serde_json::Value, _> = self
|
||||
.http_client_request(&url, Method::POST, w_id, db, None, Some(&stop_request))
|
||||
.await;
|
||||
if let Err(e) = result {
|
||||
tracing::warn!(
|
||||
"Failed to stop old Google channel {} during renewal: {}",
|
||||
channel_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create new watch channel with the same channel ID
|
||||
let resp = match config.trigger_type {
|
||||
GoogleTriggerType::Drive => {
|
||||
self.create_drive_watch(w_id, &config, &watch_request, db)
|
||||
.await?
|
||||
}
|
||||
GoogleTriggerType::Calendar => {
|
||||
self.create_calendar_watch(w_id, &config, &watch_request, db)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
// Build the updated service_config with new expiration
|
||||
let mut new_config = config;
|
||||
new_config.google_resource_id = Some(resp.resource_id);
|
||||
new_config.expiration = Some(resp.expiration);
|
||||
|
||||
serde_json::to_value(&new_config)
|
||||
.map_err(|e| Error::internal_err(format!("Failed to serialize config: {}", e)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Renewal window: renew Drive channels with <1 hour remaining, Calendar with <1 day remaining.
|
||||
pub fn should_renew_channel(service_config: &serde_json::Value) -> bool {
|
||||
let expiration_ms = service_config
|
||||
.get("expiration")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
if expiration_ms == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
let remaining_ms = expiration_ms - now_ms;
|
||||
|
||||
let trigger_type = service_config
|
||||
.get("triggerType")
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("drive");
|
||||
|
||||
let renewal_window_ms: i64 = match trigger_type {
|
||||
"calendar" => 24 * 60 * 60 * 1000, // 1 day for Calendar (7 day expiry)
|
||||
_ => 60 * 60 * 1000, // 1 hour for Drive (24h expiry)
|
||||
};
|
||||
|
||||
remaining_ms < renewal_window_ms
|
||||
}
|
||||
|
||||
async fn renew_expiring_channels(
|
||||
handler: &Google,
|
||||
db: &DB,
|
||||
workspace_id: &str,
|
||||
triggers: &[NativeTrigger],
|
||||
synced: &mut Vec<TriggerSyncInfo>,
|
||||
errors: &mut Vec<SyncError>,
|
||||
) {
|
||||
for trigger in triggers {
|
||||
let Some(config) = &trigger.service_config else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if !should_renew_channel(config) {
|
||||
continue;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"Renewing expiring Google channel {} for script_path '{}' in workspace '{}'",
|
||||
trigger.external_id,
|
||||
trigger.script_path,
|
||||
workspace_id
|
||||
);
|
||||
|
||||
match handler.renew_channel(workspace_id, trigger, db).await {
|
||||
Ok(new_config) => {
|
||||
match update_native_trigger_service_config(
|
||||
db,
|
||||
workspace_id,
|
||||
ServiceName::Google,
|
||||
&trigger.external_id,
|
||||
&new_config,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
tracing::info!(
|
||||
"Renewed Google channel {} for '{}'",
|
||||
trigger.external_id,
|
||||
trigger.script_path
|
||||
);
|
||||
synced.push(TriggerSyncInfo {
|
||||
external_id: trigger.external_id.clone(),
|
||||
script_path: trigger.script_path.clone(),
|
||||
action: SyncAction::ConfigUpdated,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"Failed to update DB after renewing Google channel {}: {}",
|
||||
trigger.external_id,
|
||||
e
|
||||
);
|
||||
errors.push(SyncError {
|
||||
resource_path: format!("workspace:{}", workspace_id),
|
||||
error_message: format!(
|
||||
"Failed to update DB after channel renewal for {}: {}",
|
||||
trigger.external_id, e
|
||||
),
|
||||
error_type: "channel_renewal_error".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"Failed to renew Google channel {} for '{}': {}",
|
||||
trigger.external_id,
|
||||
trigger.script_path,
|
||||
e
|
||||
);
|
||||
|
||||
let _ = update_native_trigger_error(
|
||||
db,
|
||||
workspace_id,
|
||||
ServiceName::Google,
|
||||
&trigger.external_id,
|
||||
Some(&format!("Channel renewal failed: {}", e)),
|
||||
)
|
||||
.await;
|
||||
|
||||
errors.push(SyncError {
|
||||
resource_path: format!("workspace:{}", workspace_id),
|
||||
error_message: format!(
|
||||
"Channel renewal failed for {}: {}",
|
||||
trigger.external_id, e
|
||||
),
|
||||
error_type: "channel_renewal_error".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
241
backend/windmill-native-triggers/src/google/mod.rs
Normal file
241
backend/windmill-native-triggers/src/google/mod.rs
Normal file
@@ -0,0 +1,241 @@
|
||||
//! Google Native Trigger Module
|
||||
//!
|
||||
//! This module provides integration with Google services (Drive, Calendar)
|
||||
//! push notification system to trigger Windmill scripts/flows when changes occur.
|
||||
//!
|
||||
//! ## Unified Architecture
|
||||
//! A single "google" native trigger service handles both Drive and Calendar triggers.
|
||||
//! The `trigger_type` field in `GoogleServiceConfig` determines which service to use.
|
||||
//!
|
||||
//! ## How it works:
|
||||
//! 1. User configures a trigger with trigger_type (drive/calendar) and service-specific settings
|
||||
//! 2. Windmill creates a "watch channel" via the appropriate Google API
|
||||
//! 3. Google sends push notifications to Windmill's webhook when changes occur
|
||||
//! 4. The webhook triggers the configured script/flow
|
||||
//!
|
||||
//! ## Important notes:
|
||||
//! - Drive watch channels expire after max 24 hours
|
||||
//! - Calendar watch channels expire after max 7 days
|
||||
//! - Background sync job renews channels before expiration
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod external;
|
||||
pub mod routes;
|
||||
|
||||
pub use external::should_renew_channel;
|
||||
|
||||
/// Extracts `(google_resource_id, stop_url)` from a native trigger's service_config JSON.
|
||||
/// Used by the `delete` method and tested independently.
|
||||
pub fn parse_stop_channel_params(config: &serde_json::Value) -> (String, String) {
|
||||
let google_resource_id = config
|
||||
.get("googleResourceId")
|
||||
.and_then(|r| r.as_str())
|
||||
.map(String::from)
|
||||
.unwrap_or_default();
|
||||
|
||||
let trigger_type = config
|
||||
.get("triggerType")
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("drive");
|
||||
|
||||
let stop_url = match trigger_type {
|
||||
"calendar" => format!("{}/channels/stop", endpoints::CALENDAR_API_BASE),
|
||||
_ => format!("{}/channels/stop", endpoints::DRIVE_API_BASE),
|
||||
};
|
||||
|
||||
(google_resource_id, stop_url)
|
||||
}
|
||||
|
||||
/// Handler struct for Google triggers (stateless, used for routing)
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct Google;
|
||||
|
||||
/// Type of Google trigger
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GoogleTriggerType {
|
||||
Drive,
|
||||
Calendar,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for GoogleTriggerType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
GoogleTriggerType::Drive => write!(f, "drive"),
|
||||
GoogleTriggerType::Calendar => write!(f, "calendar"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// User-provided configuration for a Google trigger.
|
||||
/// The trigger_type determines which service-specific config is used.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GoogleServiceConfig {
|
||||
/// The type of trigger (drive or calendar)
|
||||
pub trigger_type: GoogleTriggerType,
|
||||
|
||||
// Drive-specific fields (only used when trigger_type = drive)
|
||||
/// The file ID to watch, or None for all changes (Drive only)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resource_id: Option<String>,
|
||||
/// Human-readable name/path for display purposes (Drive only)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resource_name: Option<String>,
|
||||
|
||||
// Calendar-specific fields (only used when trigger_type = calendar)
|
||||
/// The calendar ID to watch (Calendar only, e.g., "primary")
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub calendar_id: Option<String>,
|
||||
/// Human-readable calendar name (Calendar only)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub calendar_name: Option<String>,
|
||||
|
||||
// Metadata from Google watch channel (set after creation, used for renewal/deletion)
|
||||
/// The resource ID assigned by Google for the watch channel
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub google_resource_id: Option<String>,
|
||||
/// Channel expiration time (Unix timestamp in milliseconds, as string)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expiration: Option<String>,
|
||||
}
|
||||
|
||||
impl GoogleServiceConfig {
|
||||
/// Returns the expiration duration for this trigger type in hours
|
||||
pub fn max_expiration_hours(&self) -> u64 {
|
||||
match self.trigger_type {
|
||||
GoogleTriggerType::Drive => 24, // Google Drive: max 24 hours
|
||||
GoogleTriggerType::Calendar => 168, // Google Calendar: max 7 days
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// OAuth data structure shared by all Google services.
|
||||
/// Stored encrypted in workspace_integrations table with service_name = 'google'.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct GoogleOAuthData {
|
||||
/// The OAuth access token for API requests
|
||||
pub access_token: String,
|
||||
/// The OAuth refresh token for obtaining new access tokens
|
||||
pub refresh_token: Option<String>,
|
||||
/// When the access token expires
|
||||
pub token_expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
/// Google API endpoints
|
||||
pub mod endpoints {
|
||||
/// Google Drive API v3 base URL
|
||||
pub const DRIVE_API_BASE: &str = "https://www.googleapis.com/drive/v3";
|
||||
/// Google Calendar API v3 base URL
|
||||
pub const CALENDAR_API_BASE: &str = "https://www.googleapis.com/calendar/v3";
|
||||
/// Google OAuth2 token endpoint
|
||||
pub const TOKEN_ENDPOINT: &str = "https://oauth2.googleapis.com/token";
|
||||
/// Google OAuth2 authorization endpoint
|
||||
pub const AUTH_ENDPOINT: &str = "https://accounts.google.com/o/oauth2/v2/auth";
|
||||
}
|
||||
|
||||
/// OAuth scopes for Google services
|
||||
pub mod scopes {
|
||||
/// Read-only access to Google Drive files
|
||||
pub const DRIVE_READONLY: &str = "https://www.googleapis.com/auth/drive.readonly";
|
||||
/// Read-only access to Google Calendar
|
||||
pub const CALENDAR_READONLY: &str = "https://www.googleapis.com/auth/calendar.readonly";
|
||||
/// Events access to Google Calendar
|
||||
pub const CALENDAR_EVENTS: &str = "https://www.googleapis.com/auth/calendar.events";
|
||||
|
||||
/// Returns all scopes needed for Google triggers (both Drive and Calendar)
|
||||
pub fn all_scopes() -> Vec<&'static str> {
|
||||
vec![DRIVE_READONLY, CALENDAR_READONLY, CALENDAR_EVENTS]
|
||||
}
|
||||
}
|
||||
|
||||
/// Common response wrapper for Google API errors
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GoogleApiError {
|
||||
pub error: GoogleErrorDetails,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GoogleErrorDetails {
|
||||
pub code: i32,
|
||||
pub message: String,
|
||||
#[serde(default)]
|
||||
pub errors: Vec<GoogleErrorItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GoogleErrorItem {
|
||||
pub domain: Option<String>,
|
||||
pub reason: Option<String>,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
/// Google Watch Channel response (used by Drive and Calendar push notifications)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WatchChannel {
|
||||
/// Unique channel ID (we generate this as UUID)
|
||||
pub id: String,
|
||||
/// Resource ID assigned by Google
|
||||
pub resource_id: String,
|
||||
/// Resource URI being watched
|
||||
pub resource_uri: Option<String>,
|
||||
/// Channel expiration time (Unix timestamp in milliseconds)
|
||||
pub expiration: i64,
|
||||
/// Token for validation (optional)
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
/// Response from Google API when creating a watch channel
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateWatchResponse {
|
||||
/// The channel ID we provided
|
||||
pub id: String,
|
||||
/// Resource ID assigned by Google
|
||||
pub resource_id: String,
|
||||
/// Resource URI being watched
|
||||
pub resource_uri: Option<String>,
|
||||
/// Channel expiration (Unix timestamp in milliseconds)
|
||||
pub expiration: String,
|
||||
}
|
||||
|
||||
/// Request body for creating a watch channel
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WatchRequest {
|
||||
/// Unique channel ID (UUID)
|
||||
pub id: String,
|
||||
/// Type of delivery mechanism (always "web_hook")
|
||||
#[serde(rename = "type")]
|
||||
pub channel_type: String,
|
||||
/// The URL to receive notifications
|
||||
pub address: String,
|
||||
/// Optional token for validation
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub token: Option<String>,
|
||||
/// Optional expiration time in milliseconds (Google may adjust this)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expiration: Option<i64>,
|
||||
}
|
||||
|
||||
impl WatchRequest {
|
||||
pub fn new(channel_id: String, webhook_url: String) -> Self {
|
||||
Self {
|
||||
id: channel_id,
|
||||
channel_type: "web_hook".to_string(),
|
||||
address: webhook_url,
|
||||
token: None,
|
||||
expiration: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Request body for stopping a watch channel
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StopChannelRequest {
|
||||
pub id: String,
|
||||
pub resource_id: String,
|
||||
}
|
||||
220
backend/windmill-native-triggers/src/google/routes.rs
Normal file
220
backend/windmill-native-triggers/src/google/routes.rs
Normal file
@@ -0,0 +1,220 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::{Path, Query},
|
||||
routing::get,
|
||||
Extension, Json, Router,
|
||||
};
|
||||
use http::Method;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use windmill_common::{error::JsonResult, DB};
|
||||
|
||||
use crate::{get_workspace_integration, External, ServiceName};
|
||||
|
||||
use super::Google;
|
||||
|
||||
fn escape_drive_query(s: &str) -> String {
|
||||
s.replace('\\', "\\\\").replace('\'', "\\'")
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct GoogleCalendarEntry {
|
||||
pub id: String,
|
||||
pub summary: String,
|
||||
#[serde(default)]
|
||||
pub primary: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GoogleCalendarListResponse {
|
||||
#[serde(default)]
|
||||
items: Vec<GoogleCalendarListItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GoogleCalendarListItem {
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
summary: String,
|
||||
#[serde(default)]
|
||||
primary: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct GoogleDriveFile {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub mime_type: String,
|
||||
#[serde(default)]
|
||||
pub is_folder: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct GoogleDriveFilesResponse {
|
||||
pub files: Vec<GoogleDriveFile>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub next_page_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DriveApiResponse {
|
||||
#[serde(default)]
|
||||
files: Vec<DriveApiFile>,
|
||||
next_page_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DriveApiFile {
|
||||
id: String,
|
||||
name: String,
|
||||
mime_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DriveFilesQuery {
|
||||
pub q: Option<String>,
|
||||
pub parent_id: Option<String>,
|
||||
pub page_token: Option<String>,
|
||||
#[serde(default)]
|
||||
pub shared_with_me: bool,
|
||||
}
|
||||
|
||||
async fn list_calendars(
|
||||
Extension(handler): Extension<Arc<Google>>,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(workspace_id): Path<String>,
|
||||
) -> JsonResult<Vec<GoogleCalendarEntry>> {
|
||||
get_workspace_integration(&db, &workspace_id, ServiceName::Google).await?;
|
||||
|
||||
let url = format!(
|
||||
"{}/users/me/calendarList",
|
||||
super::endpoints::CALENDAR_API_BASE
|
||||
);
|
||||
|
||||
let response: GoogleCalendarListResponse = handler
|
||||
.http_client_request::<_, ()>(&url, Method::GET, &workspace_id, &db, None, None)
|
||||
.await?;
|
||||
|
||||
let calendars = response
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|item| GoogleCalendarEntry {
|
||||
id: item.id,
|
||||
summary: item.summary,
|
||||
primary: item.primary,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(calendars))
|
||||
}
|
||||
|
||||
async fn list_drive_files(
|
||||
Extension(handler): Extension<Arc<Google>>,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(workspace_id): Path<String>,
|
||||
Query(query): Query<DriveFilesQuery>,
|
||||
) -> JsonResult<GoogleDriveFilesResponse> {
|
||||
get_workspace_integration(&db, &workspace_id, ServiceName::Google).await?;
|
||||
|
||||
let drive_query = if query.shared_with_me {
|
||||
"sharedWithMe = true and trashed = false".to_string()
|
||||
} else if let Some(ref parent_id) = query.parent_id {
|
||||
format!(
|
||||
"'{}' in parents and trashed = false",
|
||||
escape_drive_query(parent_id)
|
||||
)
|
||||
} else if let Some(ref search) = query.q {
|
||||
format!(
|
||||
"name contains '{}' and trashed = false",
|
||||
escape_drive_query(search)
|
||||
)
|
||||
} else {
|
||||
"'root' in parents and trashed = false".to_string()
|
||||
};
|
||||
|
||||
let mut url = format!(
|
||||
"{}/files?q={}&fields=files(id,name,mimeType),nextPageToken&pageSize=50&orderBy=folder,name&supportsAllDrives=true&includeItemsFromAllDrives=true",
|
||||
super::endpoints::DRIVE_API_BASE,
|
||||
urlencoding::encode(&drive_query)
|
||||
);
|
||||
|
||||
if let Some(ref page_token) = query.page_token {
|
||||
url.push_str(&format!("&pageToken={}", urlencoding::encode(page_token)));
|
||||
}
|
||||
|
||||
let response: DriveApiResponse = handler
|
||||
.http_client_request::<_, ()>(&url, Method::GET, &workspace_id, &db, None, None)
|
||||
.await?;
|
||||
|
||||
let files = response
|
||||
.files
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
let is_folder = f.mime_type == "application/vnd.google-apps.folder";
|
||||
GoogleDriveFile { id: f.id, name: f.name, mime_type: f.mime_type, is_folder }
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(GoogleDriveFilesResponse {
|
||||
files,
|
||||
next_page_token: response.next_page_token,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SharedDriveEntry {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SharedDrivesApiResponse {
|
||||
#[serde(default)]
|
||||
drives: Vec<SharedDriveApiEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SharedDriveApiEntry {
|
||||
id: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
async fn list_shared_drives(
|
||||
Extension(handler): Extension<Arc<Google>>,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(workspace_id): Path<String>,
|
||||
) -> JsonResult<Vec<SharedDriveEntry>> {
|
||||
get_workspace_integration(&db, &workspace_id, ServiceName::Google).await?;
|
||||
|
||||
let url = format!(
|
||||
"{}/drives?pageSize=100&fields=drives(id,name)",
|
||||
super::endpoints::DRIVE_API_BASE
|
||||
);
|
||||
|
||||
let response: SharedDrivesApiResponse = handler
|
||||
.http_client_request::<_, ()>(&url, Method::GET, &workspace_id, &db, None, None)
|
||||
.await?;
|
||||
|
||||
let drives = response
|
||||
.drives
|
||||
.into_iter()
|
||||
.map(|d| SharedDriveEntry { id: d.id, name: d.name })
|
||||
.collect();
|
||||
|
||||
Ok(Json(drives))
|
||||
}
|
||||
|
||||
pub fn google_routes(service: Google) -> Router {
|
||||
let service = Arc::new(service);
|
||||
Router::new()
|
||||
.route("/calendars", get(list_calendars))
|
||||
.route("/drive/files", get(list_drive_files))
|
||||
.route("/drive/shared_drives", get(list_shared_drives))
|
||||
.layer(Extension(service))
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
use crate::{
|
||||
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,
|
||||
decrypt_oauth_data, delete_native_trigger, delete_token_by_prefix, get_native_trigger,
|
||||
get_token_by_prefix, list_native_triggers, store_native_trigger, update_native_trigger_error,
|
||||
External, NativeTrigger, NativeTriggerConfig, NativeTriggerData, ServiceName,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, Query},
|
||||
@@ -65,7 +64,7 @@ pub struct ListQuery {
|
||||
pub struct FullTriggerResponse<T: Serialize> {
|
||||
#[serde(flatten)]
|
||||
pub windmill_data: NativeTrigger,
|
||||
pub external_data: T,
|
||||
pub external_data: Option<T>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -132,15 +131,9 @@ async fn create_native_trigger<T: External>(
|
||||
)
|
||||
.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 integration_service = service_name.integration_service();
|
||||
let oauth_data: T::OAuthData =
|
||||
decrypt_oauth_data(&db, &workspace_id, integration_service).await?;
|
||||
|
||||
let resp = handler
|
||||
.create(
|
||||
@@ -155,24 +148,25 @@ async fn create_native_trigger<T: External>(
|
||||
|
||||
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)?;
|
||||
// Some services (e.g. Google) can build service_config directly from the create response,
|
||||
// while others (e.g. Nextcloud) need an update+get cycle to correct the webhook URL
|
||||
// with the external_id assigned by the remote service.
|
||||
let service_config =
|
||||
if let Some(config) = handler.service_config_from_create_response(&data, &resp) {
|
||||
config
|
||||
} else {
|
||||
handler
|
||||
.update(
|
||||
&workspace_id,
|
||||
&oauth_data,
|
||||
&external_id,
|
||||
&webhook_token,
|
||||
&data,
|
||||
&db,
|
||||
&mut tx,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
|
||||
let config = NativeTriggerConfig {
|
||||
script_path: data.script_path.clone(),
|
||||
@@ -227,22 +221,32 @@ async fn update_native_trigger_handler<T: External>(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let integration_service = service_name.integration_service();
|
||||
let oauth_data: T::OAuthData =
|
||||
decrypt_oauth_data(&db, &workspace_id, integration_service).await?;
|
||||
|
||||
let mut tx = user_db.clone().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 runnable_changed =
|
||||
existing.script_path != data.script_path || existing.is_flow != data.is_flow;
|
||||
|
||||
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(
|
||||
Some(token) if !runnable_changed => token,
|
||||
existing_token => {
|
||||
if let Some(_) = existing_token {
|
||||
delete_token_by_prefix(&db, &existing.webhook_token_prefix).await?;
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"Webhook token not found for trigger {} (prefix: {}), recreating token",
|
||||
external_id,
|
||||
existing.webhook_token_prefix
|
||||
);
|
||||
}
|
||||
let token = new_webhook_token(
|
||||
&mut *tx,
|
||||
&db,
|
||||
&authed,
|
||||
@@ -251,21 +255,14 @@ async fn update_native_trigger_handler<T: External>(
|
||||
&workspace_id,
|
||||
service_name,
|
||||
)
|
||||
.await?
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
tx = user_db.begin(&authed).await?;
|
||||
token
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
let service_config = handler
|
||||
.update(
|
||||
&workspace_id,
|
||||
&oauth_data,
|
||||
@@ -277,12 +274,6 @@ async fn update_native_trigger_handler<T: External>(
|
||||
)
|
||||
.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,
|
||||
@@ -341,22 +332,16 @@ async fn get_native_trigger_handler<T: External>(
|
||||
)
|
||||
.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 integration_service = service_name.integration_service();
|
||||
let oauth_data: T::OAuthData =
|
||||
decrypt_oauth_data(&db, &workspace_id, integration_service).await?;
|
||||
|
||||
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) => {
|
||||
let external_data = match native_trigger {
|
||||
Ok(Some(native_cfg)) => {
|
||||
// Clear error if it was set
|
||||
if windmill_trigger.error.is_some() {
|
||||
update_native_trigger_error(
|
||||
@@ -368,8 +353,9 @@ async fn get_native_trigger_handler<T: External>(
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
native_cfg
|
||||
Some(native_cfg)
|
||||
}
|
||||
Ok(None) => None,
|
||||
Err(Error::NotFound(_)) => {
|
||||
let error_msg = "Trigger no longer exists on external service".to_string();
|
||||
tracing::warn!(
|
||||
@@ -396,10 +382,7 @@ async fn get_native_trigger_handler<T: External>(
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
|
||||
let full_resp = Json(FullTriggerResponse {
|
||||
windmill_data: windmill_trigger,
|
||||
external_data: native_trigger_config,
|
||||
});
|
||||
let full_resp = Json(FullTriggerResponse { windmill_data: windmill_trigger, external_data });
|
||||
|
||||
Ok(full_resp)
|
||||
}
|
||||
@@ -430,15 +413,9 @@ async fn delete_native_trigger_handler<T: External>(
|
||||
)
|
||||
.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 integration_service = service_name.integration_service();
|
||||
let oauth_data: T::OAuthData =
|
||||
decrypt_oauth_data(&db, &workspace_id, integration_service).await?;
|
||||
|
||||
handler
|
||||
.delete(&workspace_id, &oauth_data, &external_id, &db, &mut tx)
|
||||
@@ -476,34 +453,6 @@ async fn delete_native_trigger_handler<T: External>(
|
||||
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,
|
||||
@@ -543,10 +492,6 @@ pub fn service_routes<T: External + 'static>(handler: T) -> Router {
|
||||
.route(
|
||||
"/delete/:external_id",
|
||||
delete(delete_native_trigger_handler::<T>),
|
||||
)
|
||||
.route(
|
||||
"/exists/:external_id",
|
||||
get(exists_native_trigger_handler::<T>),
|
||||
);
|
||||
|
||||
standard_routes
|
||||
@@ -562,15 +507,12 @@ pub fn generate_native_trigger_routers() -> Router {
|
||||
|
||||
#[cfg(feature = "native_trigger")]
|
||||
{
|
||||
use crate::google::Google;
|
||||
use crate::nextcloud::NextCloud;
|
||||
|
||||
// Register all service routes here
|
||||
// When adding a new service:
|
||||
// 1. Import the handler: use crate::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))
|
||||
return router
|
||||
.nest("/nextcloud", service_routes(NextCloud))
|
||||
.nest("/google", service_routes(Google));
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "native_trigger"))]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user