mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-06 16:02:23 +00:00
Merge branch 'main' into claude/issue-7902-20260211-1113
This commit is contained in:
@@ -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).
|
||||
+10
-4
@@ -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
@@ -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"
|
||||
}
|
||||
+5
-5
@@ -46,11 +46,11 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
|
||||
+2
-1
@@ -30,7 +30,8 @@
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -24,7 +24,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -122,7 +122,8 @@
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -24,7 +24,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -40,7 +40,8 @@
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-3
@@ -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"
|
||||
}
|
||||
+9
-3
@@ -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"
|
||||
}
|
||||
+4
-2
@@ -34,7 +34,8 @@
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -67,7 +68,8 @@
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -24,7 +24,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -40,7 +40,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+25
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
+2
-1
@@ -24,7 +24,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -30,7 +30,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+14
@@ -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"
|
||||
}
|
||||
+2
-1
@@ -37,7 +37,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -32,7 +32,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -70,7 +71,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -245,7 +245,8 @@
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+22
@@ -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"
|
||||
}
|
||||
+2
-1
@@ -35,7 +35,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+19
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
+2
-1
@@ -29,7 +29,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -24,7 +24,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+11
-5
@@ -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"
|
||||
}
|
||||
+2
-1
@@ -40,7 +40,8 @@
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+23
@@ -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
@@ -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"
|
||||
}
|
||||
+2
-1
@@ -27,7 +27,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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
@@ -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"
|
||||
}
|
||||
+2
-1
@@ -24,7 +24,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+39
@@ -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"
|
||||
}
|
||||
+2
-1
@@ -35,7 +35,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+16
@@ -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"
|
||||
}
|
||||
+5
-5
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
+1
-1
@@ -26,7 +26,7 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "9052b7cd438ff029a37bd489190d98d365acec09f2f102b7de71dcc9d356900e"
|
||||
|
||||
+22
@@ -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"
|
||||
}
|
||||
+2
-1
@@ -24,7 +24,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+33
@@ -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"
|
||||
}
|
||||
+2
-1
@@ -32,7 +32,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -30,7 +30,8 @@
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -155,7 +155,8 @@
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -185,7 +185,8 @@
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -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"
|
||||
}
|
||||
+8
-2
@@ -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
@@ -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"
|
||||
}
|
||||
+2
-1
@@ -160,7 +160,8 @@
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -24,7 +24,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+24
@@ -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"
|
||||
}
|
||||
+2
-1
@@ -105,7 +105,8 @@
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+29
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
+2
-1
@@ -31,7 +31,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -24,7 +24,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -105,7 +105,8 @@
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -25,7 +25,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+33
@@ -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
@@ -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"
|
||||
}
|
||||
+2
-1
@@ -185,7 +185,8 @@
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -31,7 +31,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+16
@@ -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"
|
||||
}
|
||||
+2
-1
@@ -24,7 +24,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -24,7 +24,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+16
@@ -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"
|
||||
}
|
||||
Generated
+2
@@ -16160,8 +16160,10 @@ dependencies = [
|
||||
"sqlx",
|
||||
"tokio",
|
||||
"uuid",
|
||||
"windmill-api-auth",
|
||||
"windmill-api-client",
|
||||
"windmill-common",
|
||||
"windmill-native-triggers",
|
||||
"windmill-test-utils",
|
||||
]
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
c927593b0b49867284fc64d50c59daba6c70da84
|
||||
8c214ec5039be5353f5fea920e27b0c6af61e1fd
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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, "");
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -3676,6 +3676,8 @@ paths:
|
||||
type: boolean
|
||||
nextcloud_used:
|
||||
type: boolean
|
||||
google_used:
|
||||
type: boolean
|
||||
required:
|
||||
- http_routes_used
|
||||
- websocket_used
|
||||
@@ -3687,6 +3689,7 @@ paths:
|
||||
- sqs_used
|
||||
- email_used
|
||||
- nextcloud_used
|
||||
- google_used
|
||||
/w/{workspace}/users/list:
|
||||
get:
|
||||
summary: list users
|
||||
@@ -12338,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
|
||||
@@ -12359,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
|
||||
@@ -12372,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
|
||||
@@ -12628,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
|
||||
@@ -20068,6 +20208,7 @@ components:
|
||||
- mqtt
|
||||
- sqs
|
||||
- gcp
|
||||
- google
|
||||
|
||||
TriggerMode:
|
||||
description: job trigger mode
|
||||
@@ -20531,6 +20672,8 @@ components:
|
||||
type: number
|
||||
nextcloud_count:
|
||||
type: number
|
||||
google_count:
|
||||
type: number
|
||||
|
||||
WebsocketTrigger:
|
||||
allOf:
|
||||
@@ -23531,6 +23674,7 @@ components:
|
||||
type: string
|
||||
enum:
|
||||
- nextcloud
|
||||
- google
|
||||
|
||||
NativeTrigger:
|
||||
type: object
|
||||
@@ -23613,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
|
||||
|
||||
@@ -23734,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,
|
||||
|
||||
@@ -176,10 +176,7 @@ fn opaque_json_schema(_: &mut schemars::gen::SchemaGenerator) -> schemars::schem
|
||||
metadata: Some(Box::default()),
|
||||
extensions: {
|
||||
let mut m = schemars::Map::new();
|
||||
m.insert(
|
||||
"nullable".to_string(),
|
||||
serde_json::Value::Bool(true),
|
||||
);
|
||||
m.insert("nullable".to_string(), serde_json::Value::Bool(true));
|
||||
m.insert(
|
||||
"x-kubernetes-preserve-unknown-fields".to_string(),
|
||||
serde_json::Value::Bool(true),
|
||||
@@ -292,7 +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"))]
|
||||
#[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>>,
|
||||
@@ -305,13 +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"))]
|
||||
#[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"))]
|
||||
#[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"))]
|
||||
#[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.
|
||||
@@ -399,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.
|
||||
@@ -1941,6 +1952,7 @@ mod tests {
|
||||
allowed_domains: None,
|
||||
connect_config: None,
|
||||
login_config: None,
|
||||
share_with_workspaces: None,
|
||||
},
|
||||
);
|
||||
m
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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"))]
|
||||
|
||||
@@ -36,7 +36,7 @@ use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use http::StatusCode;
|
||||
use itertools::Itertools;
|
||||
use reqwest::{Client, Method};
|
||||
use reqwest::Method;
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use serde_json::value::RawValue;
|
||||
@@ -47,6 +47,7 @@ use tokio::task;
|
||||
use windmill_common::{
|
||||
error::{to_anyhow, Error, Result},
|
||||
triggers::TriggerKind,
|
||||
utils::HTTP_CLIENT,
|
||||
variables::{build_crypt, decrypt, encrypt},
|
||||
DB,
|
||||
};
|
||||
@@ -62,9 +63,9 @@ pub mod workspace_integrations;
|
||||
|
||||
// Service modules - add new services here:
|
||||
#[cfg(feature = "native_trigger")]
|
||||
pub mod google;
|
||||
#[cfg(feature = "native_trigger")]
|
||||
pub mod nextcloud;
|
||||
// #[cfg(feature = "native_trigger")]
|
||||
// pub mod newservice;
|
||||
|
||||
/// Enum of all supported native trigger services.
|
||||
/// When adding a new service, add a variant here (e.g., `NewService`).
|
||||
@@ -73,17 +74,15 @@ pub mod nextcloud;
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ServiceName {
|
||||
Nextcloud,
|
||||
// Add new services here:
|
||||
// NewService,
|
||||
Google,
|
||||
}
|
||||
|
||||
impl TryFrom<String> for ServiceName {
|
||||
type Error = Error;
|
||||
fn try_from(value: String) -> std::result::Result<Self, Self::Error> {
|
||||
// Add new service match arms here:
|
||||
let service = match value.as_str() {
|
||||
"nextcloud" => ServiceName::Nextcloud,
|
||||
// "newservice" => ServiceName::NewService,
|
||||
"google" => ServiceName::Google,
|
||||
_ => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Unknown service, currently supported services are: [{}]",
|
||||
@@ -99,49 +98,73 @@ impl TryFrom<String> for ServiceName {
|
||||
|
||||
impl ServiceName {
|
||||
/// Returns the lowercase string identifier for this service.
|
||||
/// Add new service match arms here.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ServiceName::Nextcloud => "nextcloud",
|
||||
// ServiceName::NewService => "newservice",
|
||||
ServiceName::Google => "google",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the corresponding TriggerKind for this service.
|
||||
/// Requires adding the variant to TriggerKind in windmill_common.
|
||||
pub fn as_trigger_kind(&self) -> TriggerKind {
|
||||
match self {
|
||||
ServiceName::Nextcloud => TriggerKind::Nextcloud,
|
||||
// ServiceName::NewService => TriggerKind::NewService,
|
||||
ServiceName::Google => TriggerKind::Google,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the corresponding JobTriggerKind for this service.
|
||||
/// Requires adding the variant to JobTriggerKind in windmill_common.
|
||||
pub fn as_job_trigger_kind(&self) -> windmill_common::jobs::JobTriggerKind {
|
||||
match self {
|
||||
ServiceName::Nextcloud => windmill_common::jobs::JobTriggerKind::Nextcloud,
|
||||
// ServiceName::NewService => windmill_common::jobs::JobTriggerKind::NewService,
|
||||
ServiceName::Google => windmill_common::jobs::JobTriggerKind::Google,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the OAuth token endpoint path for this service.
|
||||
/// Used for building OAuth clients dynamically.
|
||||
pub fn token_endpoint(&self) -> &'static str {
|
||||
match self {
|
||||
ServiceName::Nextcloud => "/apps/oauth2/api/v1/token",
|
||||
// ServiceName::NewService => "/oauth/token",
|
||||
ServiceName::Google => "https://oauth2.googleapis.com/token",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the OAuth authorization endpoint path for this service.
|
||||
/// Used for building OAuth authorization URLs.
|
||||
pub fn auth_endpoint(&self) -> &'static str {
|
||||
match self {
|
||||
ServiceName::Nextcloud => "/apps/oauth2/authorize",
|
||||
// ServiceName::NewService => "/oauth/authorize",
|
||||
ServiceName::Google => "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the OAuth scopes for this service's authorization flow.
|
||||
pub fn oauth_scopes(&self) -> &'static str {
|
||||
match self {
|
||||
ServiceName::Nextcloud => "read write",
|
||||
ServiceName::Google => "https://www.googleapis.com/auth/drive.readonly https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/calendar.events",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the resource type used for storing OAuth tokens.
|
||||
pub fn resource_type(&self) -> &'static str {
|
||||
match self {
|
||||
ServiceName::Nextcloud => "nextcloud",
|
||||
ServiceName::Google => "gworkspace",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns extra OAuth authorization parameters required by this service.
|
||||
pub fn extra_auth_params(&self) -> &[(&'static str, &'static str)] {
|
||||
match self {
|
||||
ServiceName::Google => &[("access_type", "offline"), ("prompt", "consent")],
|
||||
ServiceName::Nextcloud => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the integration service name for workspace_integrations lookup.
|
||||
pub fn integration_service(&self) -> ServiceName {
|
||||
*self
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ServiceName {
|
||||
@@ -150,6 +173,16 @@ impl std::fmt::Display for ServiceName {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves an endpoint URL. If the endpoint is already an absolute URL (starts with http),
|
||||
/// returns it as-is. Otherwise, prepends the base_url.
|
||||
pub fn resolve_endpoint(base_url: &str, endpoint: &str) -> String {
|
||||
if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
|
||||
endpoint.to_string()
|
||||
} else {
|
||||
format!("{}{}", base_url, endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
|
||||
pub struct NativeTrigger {
|
||||
pub external_id: String,
|
||||
@@ -183,6 +216,7 @@ pub struct WorkspaceIntegration {
|
||||
pub workspace_id: String,
|
||||
pub service_name: ServiceName,
|
||||
pub oauth_data: serde_json::Value,
|
||||
pub resource_path: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub created_by: String,
|
||||
@@ -200,6 +234,7 @@ pub trait External: Send + Sync + 'static {
|
||||
const DISPLAY_NAME: &'static str;
|
||||
const TOKEN_ENDPOINT: &'static str;
|
||||
const REFRESH_ENDPOINT: &'static str;
|
||||
const AUTH_ENDPOINT: &'static str;
|
||||
|
||||
async fn create(
|
||||
&self,
|
||||
@@ -211,6 +246,10 @@ pub trait External: Send + Sync + 'static {
|
||||
tx: &mut PgConnection,
|
||||
) -> Result<Self::CreateResponse>;
|
||||
|
||||
/// Update a trigger on the external service and return the resolved service_config to store.
|
||||
/// Each service is responsible for resolving the final config:
|
||||
/// - Services that re-create the resource (e.g. Google) build config from request data + response metadata.
|
||||
/// - Services that modify in-place (e.g. Nextcloud) fetch back the updated state and extract config.
|
||||
async fn update(
|
||||
&self,
|
||||
w_id: &str,
|
||||
@@ -220,16 +259,21 @@ pub trait External: Send + Sync + 'static {
|
||||
data: &NativeTriggerData<Self::ServiceConfig>,
|
||||
db: &DB,
|
||||
tx: &mut PgConnection,
|
||||
) -> Result<()>;
|
||||
) -> Result<serde_json::Value>;
|
||||
|
||||
/// Fetch the trigger's state from the external service.
|
||||
/// Returns `Ok(None)` (default) when the service has no "get" API (e.g. Google).
|
||||
/// Services that can fetch state (e.g. Nextcloud) override to return `Ok(Some(data))`.
|
||||
async fn get(
|
||||
&self,
|
||||
w_id: &str,
|
||||
oauth_data: &Self::OAuthData,
|
||||
external_id: &str,
|
||||
db: &DB,
|
||||
tx: &mut PgConnection,
|
||||
) -> Result<Self::TriggerData>;
|
||||
_w_id: &str,
|
||||
_oauth_data: &Self::OAuthData,
|
||||
_external_id: &str,
|
||||
_db: &DB,
|
||||
_tx: &mut PgConnection,
|
||||
) -> Result<Option<Self::TriggerData>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
@@ -240,23 +284,19 @@ pub trait External: Send + Sync + 'static {
|
||||
tx: &mut PgConnection,
|
||||
) -> Result<()>;
|
||||
|
||||
#[allow(unused)]
|
||||
async fn exists(
|
||||
/// Periodic background maintenance for triggers in a workspace.
|
||||
/// Each service implements its own logic:
|
||||
/// - Nextcloud: lists external triggers and reconciles with DB state
|
||||
/// - Google: renews expiring watch channels
|
||||
async fn maintain_triggers(
|
||||
&self,
|
||||
w_id: &str,
|
||||
oauth_data: &Self::OAuthData,
|
||||
external_id: &str,
|
||||
db: &DB,
|
||||
tx: &mut PgConnection,
|
||||
) -> Result<bool>;
|
||||
|
||||
async fn list_all(
|
||||
&self,
|
||||
w_id: &str,
|
||||
workspace_id: &str,
|
||||
triggers: &[NativeTrigger],
|
||||
oauth_data: &Self::OAuthData,
|
||||
db: &DB,
|
||||
tx: &mut PgConnection,
|
||||
) -> Result<Vec<Self::TriggerData>>;
|
||||
synced: &mut Vec<crate::sync::TriggerSyncInfo>,
|
||||
errors: &mut Vec<crate::sync::SyncError>,
|
||||
);
|
||||
|
||||
async fn prepare_webhook(
|
||||
&self,
|
||||
@@ -275,19 +315,18 @@ pub trait External: Send + Sync + 'static {
|
||||
resp: &Self::CreateResponse,
|
||||
) -> (String, Option<serde_json::Value>);
|
||||
|
||||
fn get_external_id_from_trigger_data(&self, data: &Self::TriggerData) -> String;
|
||||
|
||||
/// Extracts the service-specific config from trigger data (from external service).
|
||||
/// Used for comparison during sync to detect config drift.
|
||||
/// Default implementation converts the trigger data to a JSON value
|
||||
/// If you need to exclude some fields, skip serializing attributes on the TriggerData struct or override this method.
|
||||
fn extract_service_config_from_trigger_data(
|
||||
/// Build the service_config directly from the create response and input data,
|
||||
/// skipping the update+get cycle after creation.
|
||||
/// Return `None` (default) to use the update+get pattern (e.g. Nextcloud needs to
|
||||
/// correct the webhook URL with the external_id assigned by the remote service).
|
||||
/// Return `Some(config)` to skip update+get entirely (e.g. Google already includes
|
||||
/// the channel_id in the webhook URL from the start).
|
||||
fn service_config_from_create_response(
|
||||
&self,
|
||||
data: &Self::TriggerData,
|
||||
) -> Result<serde_json::Value> {
|
||||
serde_json::to_value(data).map_err(|e| {
|
||||
Error::internal_err(format!("Failed to convert trigger data to JSON: {}", e))
|
||||
})
|
||||
_data: &NativeTriggerData<Self::ServiceConfig>,
|
||||
_resp: &Self::CreateResponse,
|
||||
) -> Option<serde_json::Value> {
|
||||
None
|
||||
}
|
||||
|
||||
fn additional_routes(&self) -> axum::Router {
|
||||
@@ -299,13 +338,12 @@ pub trait External: Send + Sync + 'static {
|
||||
url: &str,
|
||||
method: Method,
|
||||
workspace_id: &str,
|
||||
tx: &mut PgConnection,
|
||||
db: &DB,
|
||||
headers: Option<HashMap<String, String>>,
|
||||
body: Option<&B>,
|
||||
) -> Result<T> {
|
||||
let oauth_config: OAuthConfig =
|
||||
decrypt_oauth_data(tx, db, workspace_id, Self::SERVICE_NAME).await?;
|
||||
decrypt_oauth_data(db, workspace_id, Self::SERVICE_NAME).await?;
|
||||
|
||||
let result = make_http_request(
|
||||
url,
|
||||
@@ -327,19 +365,26 @@ pub trait External: Send + Sync + 'static {
|
||||
err.status().unwrap()
|
||||
);
|
||||
|
||||
let refreshed_oauth_config =
|
||||
refresh_oauth_tokens(&oauth_config, Self::REFRESH_ENDPOINT).await?;
|
||||
let refreshed_oauth_config = refresh_oauth_tokens(
|
||||
&oauth_config,
|
||||
Self::REFRESH_ENDPOINT,
|
||||
Self::AUTH_ENDPOINT,
|
||||
)
|
||||
.await?;
|
||||
|
||||
task::spawn({
|
||||
let db_clone = db.clone();
|
||||
let workspace_id_clone = workspace_id.to_string();
|
||||
let refreshed_json = oauth_config_to_json(&refreshed_oauth_config);
|
||||
let service_name = Self::SERVICE_NAME;
|
||||
let new_access_token = refreshed_oauth_config.access_token.clone();
|
||||
let new_refresh_token = refreshed_oauth_config.refresh_token.clone();
|
||||
async move {
|
||||
update_workspace_integration_tokens_helper(
|
||||
db_clone,
|
||||
workspace_id_clone,
|
||||
Self::SERVICE_NAME,
|
||||
refreshed_json,
|
||||
update_oauth_token_resource(
|
||||
&db_clone,
|
||||
&workspace_id_clone,
|
||||
service_name,
|
||||
&new_access_token,
|
||||
new_refresh_token.as_deref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -376,8 +421,8 @@ pub async fn make_http_request<T: DeserializeOwned + Send, B: Serialize>(
|
||||
headers: Option<HashMap<String, String>>,
|
||||
body: Option<&B>,
|
||||
access_token: &str,
|
||||
) -> std::result::Result<T, reqwest::Error> {
|
||||
let client = Client::new();
|
||||
) -> std::result::Result<T, HttpRequestError> {
|
||||
let client = &*HTTP_CLIENT;
|
||||
let mut request = client.request(method, url);
|
||||
|
||||
request = request
|
||||
@@ -400,91 +445,148 @@ pub async fn make_http_request<T: DeserializeOwned + Send, B: Serialize>(
|
||||
|
||||
let response = request.send().await?.error_for_status()?;
|
||||
|
||||
let response_json = response.json().await?;
|
||||
|
||||
Ok(response_json)
|
||||
// Handle empty responses (e.g. 204 No Content from Google channels/stop)
|
||||
let bytes = response.bytes().await?;
|
||||
if bytes.is_empty() {
|
||||
serde_json::from_str("null").map_err(HttpRequestError::Json)
|
||||
} else {
|
||||
serde_json::from_slice(&bytes).map_err(HttpRequestError::Json)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn decrypt_oauth_data<
|
||||
'c,
|
||||
E: sqlx::Executor<'c, Database = Postgres>,
|
||||
T: DeserializeOwned,
|
||||
>(
|
||||
tx: E,
|
||||
#[derive(Debug)]
|
||||
pub enum HttpRequestError {
|
||||
Reqwest(reqwest::Error),
|
||||
Json(serde_json::Error),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for HttpRequestError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
HttpRequestError::Reqwest(e) => write!(f, "{}", e),
|
||||
HttpRequestError::Json(e) => write!(f, "JSON decode error: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for HttpRequestError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
HttpRequestError::Reqwest(e) => Some(e),
|
||||
HttpRequestError::Json(e) => Some(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for HttpRequestError {
|
||||
fn from(e: reqwest::Error) -> Self {
|
||||
HttpRequestError::Reqwest(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl HttpRequestError {
|
||||
pub fn status(&self) -> Option<StatusCode> {
|
||||
match self {
|
||||
HttpRequestError::Reqwest(e) => e.status(),
|
||||
HttpRequestError::Json(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read OAuth client_id and client_secret from instance-level global settings.
|
||||
/// Used when a workspace integration has `instance_shared: true`.
|
||||
async fn get_instance_oauth_credentials(
|
||||
db: &DB,
|
||||
service_name: ServiceName,
|
||||
) -> Result<(String, String)> {
|
||||
windmill_common::global_settings::get_instance_oauth_credentials(
|
||||
db,
|
||||
service_name.resource_type(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn decrypt_oauth_data<T: DeserializeOwned>(
|
||||
db: &DB,
|
||||
workspace_id: &str,
|
||||
service_name: ServiceName,
|
||||
) -> Result<T> {
|
||||
let integration = get_workspace_integration(tx, workspace_id, service_name).await?;
|
||||
let integration = get_workspace_integration(db, workspace_id, service_name).await?;
|
||||
let oauth_data = integration.oauth_data;
|
||||
|
||||
let resource_path = integration.resource_path.as_deref().ok_or_else(|| {
|
||||
Error::InternalErr(format!(
|
||||
"No resource_path in {} integration config. Please reconnect the integration.",
|
||||
service_name
|
||||
))
|
||||
})?;
|
||||
|
||||
let mc = build_crypt(db, workspace_id).await?;
|
||||
let mut oauth_data: serde_json::Value = integration.oauth_data;
|
||||
|
||||
if let Some(encrypted_access_token) = oauth_data.get("access_token").and_then(|v| v.as_str()) {
|
||||
let decrypted_access_token = decrypt(&mc, encrypted_access_token.to_string())
|
||||
.map_err(|e| Error::InternalErr(format!("Failed to decrypt access token: {}", e)))?;
|
||||
oauth_data["access_token"] = serde_json::Value::String(decrypted_access_token);
|
||||
}
|
||||
let var_row = sqlx::query!(
|
||||
"SELECT value, account FROM variable WHERE workspace_id = $1 AND path = $2",
|
||||
workspace_id,
|
||||
resource_path,
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
Error::InternalErr(format!(
|
||||
"Variable at {} not found for {} integration",
|
||||
resource_path, service_name
|
||||
))
|
||||
})?;
|
||||
|
||||
if let Some(encrypted_refresh_token) = oauth_data.get("refresh_token").and_then(|v| v.as_str())
|
||||
let access_token = decrypt(&mc, var_row.value)
|
||||
.map_err(|e| Error::InternalErr(format!("Failed to decrypt access token: {}", e)))?;
|
||||
|
||||
let refresh_token = if let Some(account_id) = var_row.account {
|
||||
sqlx::query_scalar!(
|
||||
"SELECT refresh_token FROM account WHERE workspace_id = $1 AND id = $2",
|
||||
workspace_id,
|
||||
account_id,
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let (client_id, client_secret) = if oauth_data
|
||||
.get("instance_shared")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let decrypted_refresh_token = decrypt(&mc, encrypted_refresh_token.to_string())
|
||||
.map_err(|e| Error::InternalErr(format!("Failed to decrypt refresh token: {}", e)))?;
|
||||
oauth_data["refresh_token"] = serde_json::Value::String(decrypted_refresh_token);
|
||||
}
|
||||
// Read credentials from instance-level global settings instead of workspace_integrations
|
||||
let (id, secret) = get_instance_oauth_credentials(db, service_name)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
Error::InternalErr(format!(
|
||||
"Failed to read instance OAuth credentials for {}: {}",
|
||||
service_name, e
|
||||
))
|
||||
})?;
|
||||
(id, secret)
|
||||
} else {
|
||||
(
|
||||
oauth_data["client_id"].as_str().unwrap_or("").to_string(),
|
||||
oauth_data["client_secret"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
)
|
||||
};
|
||||
|
||||
serde_json::from_value(oauth_data)
|
||||
.map_err(|e| Error::InternalErr(format!("Failed to deserialize OAuth data: {}", e)))
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
pub fn oauth_data_to_config(oauth_data: &serde_json::Value) -> Result<OAuthConfig> {
|
||||
let base_url = oauth_data
|
||||
.get("base_url")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| Error::InternalErr("No base_url in OAuth data".to_string()))?
|
||||
.to_string();
|
||||
|
||||
let access_token = oauth_data
|
||||
.get("access_token")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| Error::InternalErr("No access_token in OAuth data".to_string()))?
|
||||
.to_string();
|
||||
|
||||
let refresh_token = oauth_data
|
||||
.get("refresh_token")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let client_id = oauth_data
|
||||
.get("client_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| Error::InternalErr("No client_id in OAuth data".to_string()))?
|
||||
.to_string();
|
||||
|
||||
let client_secret = oauth_data
|
||||
.get("client_secret")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| Error::InternalErr("No client_secret in OAuth data".to_string()))?
|
||||
.to_string();
|
||||
|
||||
Ok(OAuthConfig { base_url, access_token, refresh_token, client_id, client_secret })
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn oauth_config_to_json(config: &OAuthConfig) -> serde_json::Value {
|
||||
let mut json = json!({
|
||||
"base_url": config.base_url,
|
||||
"access_token": config.access_token,
|
||||
"client_id": config.client_id,
|
||||
"client_secret": config.client_secret,
|
||||
let assembled = json!({
|
||||
"base_url": oauth_data["base_url"].as_str().unwrap_or(""),
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
});
|
||||
|
||||
if let Some(refresh_token) = &config.refresh_token {
|
||||
json["refresh_token"] = serde_json::Value::String(refresh_token.clone());
|
||||
}
|
||||
|
||||
json
|
||||
serde_json::from_value(assembled)
|
||||
.map_err(|e| Error::InternalErr(format!("Failed to deserialize OAuth data: {}", e)))
|
||||
}
|
||||
|
||||
/// Token refresh response
|
||||
@@ -500,6 +602,7 @@ struct RefreshTokenResponse {
|
||||
pub async fn refresh_oauth_tokens(
|
||||
oauth_config: &OAuthConfig,
|
||||
refresh_endpoint: &str,
|
||||
auth_endpoint: &str,
|
||||
) -> Result<OAuthConfig> {
|
||||
let refresh_token_str = oauth_config
|
||||
.refresh_token
|
||||
@@ -508,9 +611,9 @@ pub async fn refresh_oauth_tokens(
|
||||
|
||||
// Build OAuth client for token refresh
|
||||
// Auth URL is not used for refresh, but required by the client constructor
|
||||
let auth_url = Url::parse(&format!("{}/oauth/authorize", oauth_config.base_url))
|
||||
let auth_url = Url::parse(&resolve_endpoint(&oauth_config.base_url, auth_endpoint))
|
||||
.map_err(|e| Error::InternalErr(format!("Invalid auth URL: {}", e)))?;
|
||||
let token_url = Url::parse(&format!("{}{}", oauth_config.base_url, refresh_endpoint))
|
||||
let token_url = Url::parse(&resolve_endpoint(&oauth_config.base_url, refresh_endpoint))
|
||||
.map_err(|e| Error::InternalErr(format!("Invalid token URL: {}", e)))?;
|
||||
|
||||
let mut client = OClient::new(oauth_config.client_id.clone(), auth_url, token_url);
|
||||
@@ -539,62 +642,80 @@ pub async fn refresh_oauth_tokens(
|
||||
pub async fn refresh_oauth_tokens(
|
||||
_oauth_config: &OAuthConfig,
|
||||
_refresh_endpoint: &str,
|
||||
_auth_endpoint: &str,
|
||||
) -> Result<OAuthConfig> {
|
||||
Err(Error::InternalErr(
|
||||
"Native triggers feature is not enabled".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn update_workspace_integration_tokens_helper(
|
||||
db: DB,
|
||||
workspace_id: String,
|
||||
async fn update_oauth_token_resource(
|
||||
db: &DB,
|
||||
workspace_id: &str,
|
||||
service_name: ServiceName,
|
||||
oauth_data: serde_json::Value,
|
||||
new_access_token: &str,
|
||||
new_refresh_token: Option<&str>,
|
||||
) {
|
||||
let result = async {
|
||||
let mut tx = db.begin().await?;
|
||||
let mc = build_crypt(&db, &workspace_id).await?;
|
||||
let mut encrypted_oauth_data = oauth_data;
|
||||
let integration = get_workspace_integration(db, workspace_id, service_name).await?;
|
||||
let resource_path = integration.resource_path.ok_or_else(|| {
|
||||
Error::InternalErr(format!(
|
||||
"No resource_path in {} integration config",
|
||||
service_name
|
||||
))
|
||||
})?;
|
||||
|
||||
if let Some(access_token) = encrypted_oauth_data
|
||||
.get("access_token")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
let encrypted_access_token = encrypt(&mc, access_token);
|
||||
encrypted_oauth_data["access_token"] =
|
||||
serde_json::Value::String(encrypted_access_token);
|
||||
}
|
||||
|
||||
if let Some(refresh_token) = encrypted_oauth_data
|
||||
.get("refresh_token")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
let encrypted_refresh_token = encrypt(&mc, refresh_token);
|
||||
encrypted_oauth_data["refresh_token"] =
|
||||
serde_json::Value::String(encrypted_refresh_token);
|
||||
}
|
||||
let mc = build_crypt(db, workspace_id).await?;
|
||||
let encrypted_token = encrypt(&mc, new_access_token);
|
||||
|
||||
sqlx::query!(
|
||||
r#"
|
||||
UPDATE workspace_integrations
|
||||
SET oauth_data = $1, updated_at = now()
|
||||
WHERE workspace_id = $2 AND service_name = $3
|
||||
"#,
|
||||
encrypted_oauth_data,
|
||||
"UPDATE variable SET value = $1 WHERE workspace_id = $2 AND path = $3",
|
||||
encrypted_token,
|
||||
workspace_id,
|
||||
service_name as ServiceName,
|
||||
resource_path,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
if let Some(refresh_token) = new_refresh_token {
|
||||
sqlx::query!(
|
||||
"UPDATE account SET
|
||||
refresh_token = $1,
|
||||
expires_at = now() + interval '1 hour',
|
||||
refresh_error = NULL
|
||||
WHERE workspace_id = $2 AND client = $3 AND is_workspace_integration = true",
|
||||
refresh_token,
|
||||
workspace_id,
|
||||
service_name.as_str(),
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
} else {
|
||||
// Even without a new refresh token, update expires_at to prevent
|
||||
// the background refresh from re-refreshing immediately
|
||||
sqlx::query!(
|
||||
"UPDATE account SET
|
||||
expires_at = now() + interval '1 hour',
|
||||
refresh_error = NULL
|
||||
WHERE workspace_id = $1 AND client = $2 AND is_workspace_integration = true",
|
||||
workspace_id,
|
||||
service_name.as_str(),
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok::<(), Error>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Err(e) = result {
|
||||
tracing::error!("Critical error: Failed to update workspace integration tokens for {} in workspace {}: {}",
|
||||
service_name, workspace_id, e);
|
||||
tracing::error!(
|
||||
"Failed to update OAuth tokens for {} in workspace {}: {}",
|
||||
service_name,
|
||||
workspace_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -939,6 +1060,7 @@ pub async fn store_workspace_integration(
|
||||
workspace_id: &str,
|
||||
service_name: ServiceName,
|
||||
oauth_data: serde_json::Value,
|
||||
resource_path: Option<&str>,
|
||||
) -> Result<()> {
|
||||
sqlx::query!(
|
||||
r#"
|
||||
@@ -946,20 +1068,23 @@ pub async fn store_workspace_integration(
|
||||
workspace_id,
|
||||
service_name,
|
||||
oauth_data,
|
||||
resource_path,
|
||||
created_by,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, now(), now()
|
||||
$1, $2, $3, $4, $5, now(), now()
|
||||
)
|
||||
ON CONFLICT (workspace_id, service_name)
|
||||
DO UPDATE SET
|
||||
oauth_data = $3,
|
||||
resource_path = $4,
|
||||
updated_at = now()
|
||||
"#,
|
||||
workspace_id,
|
||||
service_name as ServiceName,
|
||||
oauth_data,
|
||||
resource_path,
|
||||
authed.username,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
@@ -980,6 +1105,7 @@ pub async fn get_workspace_integration<'c, E: sqlx::Executor<'c, Database = Post
|
||||
workspace_id,
|
||||
service_name AS "service_name!: ServiceName",
|
||||
oauth_data,
|
||||
resource_path,
|
||||
created_at,
|
||||
updated_at,
|
||||
created_by
|
||||
@@ -1051,3 +1177,43 @@ pub fn generate_webhook_service_url(
|
||||
|
||||
url
|
||||
}
|
||||
|
||||
/// Process incoming webhook request for a native trigger service.
|
||||
/// Dispatches to the service-specific `prepare_webhook` to transform headers/body into args.
|
||||
/// Returns `None` if the service doesn't need special processing (standard body parsing is used).
|
||||
#[cfg(feature = "native_trigger")]
|
||||
pub async fn prepare_native_trigger_args(
|
||||
service_name: ServiceName,
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
headers: &http::HeaderMap,
|
||||
body: String,
|
||||
) -> Result<Option<PushArgsOwned>> {
|
||||
let headers_map: HashMap<String, String> = headers
|
||||
.iter()
|
||||
.filter_map(|(k, v)| v.to_str().ok().map(|v| (k.to_string(), v.to_string())))
|
||||
.collect();
|
||||
|
||||
match service_name {
|
||||
ServiceName::Google => {
|
||||
let handler = google::Google;
|
||||
let args = handler
|
||||
.prepare_webhook(db, w_id, headers_map, body, "", false)
|
||||
.await?;
|
||||
Ok(Some(args))
|
||||
}
|
||||
ServiceName::Nextcloud => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback when native_trigger feature is disabled
|
||||
#[cfg(not(feature = "native_trigger"))]
|
||||
pub async fn prepare_native_trigger_args(
|
||||
_service_name: ServiceName,
|
||||
_db: &DB,
|
||||
_w_id: &str,
|
||||
_headers: &http::HeaderMap,
|
||||
_body: String,
|
||||
) -> Result<Option<PushArgsOwned>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
@@ -102,6 +102,7 @@ impl External for NextCloud {
|
||||
const SUPPORT_WEBHOOK: bool = true;
|
||||
const TOKEN_ENDPOINT: &'static str = "/apps/oauth2/api/v1/token";
|
||||
const REFRESH_ENDPOINT: &'static str = "/apps/oauth2/api/v1/token";
|
||||
const AUTH_ENDPOINT: &'static str = "/oauth/authorize";
|
||||
|
||||
async fn create(
|
||||
&self,
|
||||
@@ -110,7 +111,7 @@ impl External for NextCloud {
|
||||
webhook_token: &str,
|
||||
data: &NativeTriggerData<Self::ServiceConfig>,
|
||||
db: &DB,
|
||||
tx: &mut PgConnection,
|
||||
_tx: &mut PgConnection,
|
||||
) -> Result<Self::CreateResponse> {
|
||||
// During create, we don't have external_id yet (it comes from NextCloud's response)
|
||||
let full_nextcloud_payload =
|
||||
@@ -129,7 +130,6 @@ impl External for NextCloud {
|
||||
&url,
|
||||
Method::POST,
|
||||
w_id,
|
||||
tx,
|
||||
db,
|
||||
Some(headers),
|
||||
Some(&full_nextcloud_payload),
|
||||
@@ -148,7 +148,7 @@ impl External for NextCloud {
|
||||
data: &NativeTriggerData<Self::ServiceConfig>,
|
||||
db: &DB,
|
||||
tx: &mut PgConnection,
|
||||
) -> Result<()> {
|
||||
) -> Result<serde_json::Value> {
|
||||
// During update, we have the external_id so include it in the webhook URL
|
||||
let full_nextcloud_payload =
|
||||
FullNextcloudPayload::new(w_id, Some(external_id), webhook_token, data).await;
|
||||
@@ -166,14 +166,25 @@ impl External for NextCloud {
|
||||
&url,
|
||||
Method::POST,
|
||||
w_id,
|
||||
tx,
|
||||
db,
|
||||
Some(headers),
|
||||
Some(&full_nextcloud_payload),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
// Fetch back the updated state and convert to JSON config
|
||||
let trigger_data = self
|
||||
.get(w_id, oauth_data, external_id, db, tx)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
Error::InternalErr(format!(
|
||||
"Failed to fetch back trigger {} after update",
|
||||
external_id
|
||||
))
|
||||
})?;
|
||||
serde_json::to_value(&trigger_data).map_err(|e| {
|
||||
Error::internal_err(format!("Failed to convert trigger data to JSON: {}", e))
|
||||
})
|
||||
}
|
||||
|
||||
async fn get(
|
||||
@@ -182,8 +193,8 @@ impl External for NextCloud {
|
||||
oauth_data: &Self::OAuthData,
|
||||
external_id: &str,
|
||||
db: &DB,
|
||||
tx: &mut PgConnection,
|
||||
) -> Result<Self::TriggerData> {
|
||||
_tx: &mut PgConnection,
|
||||
) -> Result<Option<Self::TriggerData>> {
|
||||
let url = format!(
|
||||
"{}/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks/{}",
|
||||
oauth_data.base_url, external_id
|
||||
@@ -193,10 +204,10 @@ impl External for NextCloud {
|
||||
headers.insert("OCS-APIRequest".to_string(), "true".to_string());
|
||||
|
||||
let ocs_response: OcsResponse<NextCloudTriggerData> = self
|
||||
.http_client_request::<_, ()>(&url, Method::GET, w_id, tx, db, Some(headers), None)
|
||||
.http_client_request::<_, ()>(&url, Method::GET, w_id, db, Some(headers), None)
|
||||
.await?;
|
||||
|
||||
Ok(ocs_response.ocs.data)
|
||||
Ok(Some(ocs_response.ocs.data))
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
@@ -205,7 +216,7 @@ impl External for NextCloud {
|
||||
oauth_data: &Self::OAuthData,
|
||||
external_id: &str,
|
||||
db: &DB,
|
||||
tx: &mut PgConnection,
|
||||
_tx: &mut PgConnection,
|
||||
) -> Result<()> {
|
||||
let url = format!(
|
||||
"{}/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks/{}",
|
||||
@@ -216,7 +227,7 @@ impl External for NextCloud {
|
||||
headers.insert("OCS-APIRequest".to_string(), "true".to_string());
|
||||
|
||||
let _: serde_json::Value = self
|
||||
.http_client_request::<_, ()>(&url, Method::DELETE, w_id, tx, db, Some(headers), None)
|
||||
.http_client_request::<_, ()>(&url, Method::DELETE, w_id, db, Some(headers), None)
|
||||
.await
|
||||
.or_else(|e| match &e {
|
||||
Error::InternalErr(msg) if msg.contains("404") => Ok(serde_json::Value::Null),
|
||||
@@ -226,44 +237,72 @@ impl External for NextCloud {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn exists(
|
||||
async fn maintain_triggers(
|
||||
&self,
|
||||
w_id: &str,
|
||||
oauth_data: &Self::OAuthData,
|
||||
external_id: &str,
|
||||
db: &DB,
|
||||
tx: &mut PgConnection,
|
||||
) -> Result<bool> {
|
||||
let url = format!(
|
||||
"{}/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks/{}",
|
||||
oauth_data.base_url, external_id
|
||||
);
|
||||
workspace_id: &str,
|
||||
triggers: &[crate::NativeTrigger],
|
||||
oauth_data: &Self::OAuthData,
|
||||
synced: &mut Vec<crate::sync::TriggerSyncInfo>,
|
||||
errors: &mut Vec<crate::sync::SyncError>,
|
||||
) {
|
||||
let external_triggers = match self.list_all(workspace_id, oauth_data, db).await {
|
||||
Ok(triggers) => triggers,
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"Failed to fetch external triggers for {}: {}",
|
||||
workspace_id,
|
||||
e
|
||||
);
|
||||
errors.push(crate::sync::SyncError {
|
||||
resource_path: format!("workspace:{}", workspace_id),
|
||||
error_message: format!("Failed to fetch external triggers: {}", e),
|
||||
error_type: "external_service_error".to_string(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert("OCS-APIRequest".to_string(), "true".to_string());
|
||||
// Convert to (external_id, config_json) pairs for reconciliation
|
||||
let external_pairs: Vec<(String, serde_json::Value)> = external_triggers
|
||||
.iter()
|
||||
.filter_map(|data| {
|
||||
let config = serde_json::to_value(data).ok()?;
|
||||
Some((data.id.to_string(), config))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let _ = self
|
||||
.http_client_request::<serde_json::Value, ()>(
|
||||
&url,
|
||||
Method::GET,
|
||||
w_id,
|
||||
tx,
|
||||
db,
|
||||
Some(headers),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(true)
|
||||
crate::sync::reconcile_with_external_state(
|
||||
db,
|
||||
workspace_id,
|
||||
ServiceName::Nextcloud,
|
||||
triggers,
|
||||
&external_pairs,
|
||||
synced,
|
||||
errors,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
fn external_id_and_metadata_from_response(
|
||||
&self,
|
||||
resp: &Self::CreateResponse,
|
||||
) -> (String, Option<serde_json::Value>) {
|
||||
(resp.id.to_string(), None)
|
||||
}
|
||||
|
||||
fn additional_routes(&self) -> axum::Router {
|
||||
routes::nextcloud_routes(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl NextCloud {
|
||||
async fn list_all(
|
||||
&self,
|
||||
w_id: &str,
|
||||
oauth_data: &Self::OAuthData,
|
||||
oauth_data: &NextCloudOAuthData,
|
||||
db: &DB,
|
||||
tx: &mut PgConnection,
|
||||
) -> Result<Vec<Self::TriggerData>> {
|
||||
) -> Result<Vec<NextCloudTriggerData>> {
|
||||
let url = format!(
|
||||
"{}/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks",
|
||||
oauth_data.base_url
|
||||
@@ -277,7 +316,6 @@ impl External for NextCloud {
|
||||
&url,
|
||||
Method::GET,
|
||||
w_id,
|
||||
tx,
|
||||
db,
|
||||
Some(headers),
|
||||
None,
|
||||
@@ -286,19 +324,4 @@ impl External for NextCloud {
|
||||
|
||||
Ok(ocs_response.ocs.data)
|
||||
}
|
||||
|
||||
fn external_id_and_metadata_from_response(
|
||||
&self,
|
||||
resp: &Self::CreateResponse,
|
||||
) -> (String, Option<serde_json::Value>) {
|
||||
(resp.id.to_string(), None)
|
||||
}
|
||||
|
||||
fn get_external_id_from_trigger_data(&self, data: &Self::TriggerData) -> String {
|
||||
data.id.to_string()
|
||||
}
|
||||
|
||||
fn additional_routes(&self) -> axum::Router {
|
||||
routes::nextcloud_routes(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ use std::{collections::HashMap, sync::Arc};
|
||||
use axum::{extract::Path, routing::get, Extension, Json, Router};
|
||||
use http::Method;
|
||||
use windmill_common::{
|
||||
db::UserDB,
|
||||
error::{Error, JsonResult},
|
||||
DB,
|
||||
};
|
||||
@@ -11,27 +10,25 @@ use windmill_common::{
|
||||
use crate::{
|
||||
get_workspace_integration,
|
||||
nextcloud::{NextCloudEventType, OcsResponse},
|
||||
External, OAuthConfig, ServiceName,
|
||||
External, ServiceName,
|
||||
};
|
||||
use windmill_api_auth::ApiAuthed;
|
||||
|
||||
async fn list_available_events<T: External>(
|
||||
authed: ApiAuthed,
|
||||
Extension(handler): Extension<Arc<T>>,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path(workspace_id): Path<String>,
|
||||
) -> JsonResult<Vec<NextCloudEventType>> {
|
||||
let mut tx = user_db.clone().begin(&authed).await?;
|
||||
let integration =
|
||||
get_workspace_integration(&mut *tx, &workspace_id, ServiceName::Nextcloud).await?;
|
||||
let integration = get_workspace_integration(&db, &workspace_id, ServiceName::Nextcloud).await?;
|
||||
|
||||
let auth = serde_json::from_value::<OAuthConfig>(integration.oauth_data)
|
||||
.map_err(|e| Error::InternalErr(format!("Failed to parse NextCloud OAuth data: {}", e)))?;
|
||||
let base_url = integration
|
||||
.oauth_data
|
||||
.get("base_url")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let url = format!(
|
||||
"{}/ocs/v2.php/apps/integration_windmill/api/v1/list/events",
|
||||
&auth.base_url,
|
||||
base_url,
|
||||
);
|
||||
|
||||
let mut headers = HashMap::new();
|
||||
@@ -42,13 +39,11 @@ async fn list_available_events<T: External>(
|
||||
&url,
|
||||
Method::GET,
|
||||
&workspace_id,
|
||||
&mut *tx,
|
||||
&db,
|
||||
Some(headers),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
let events = serde_json::from_str(&ocs_response.ocs.data)
|
||||
.map_err(|e| Error::InternalErr(format!("Failed to parse NextCloud events data: {}", e)))?;
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::ServiceName;
|
||||
#[cfg(feature = "native_trigger")]
|
||||
use crate::{
|
||||
decrypt_oauth_data, list_native_triggers, update_native_trigger_error,
|
||||
update_native_trigger_service_config, External,
|
||||
update_native_trigger_service_config, External, NativeTrigger,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -60,19 +60,20 @@ pub async fn sync_all_triggers(db: &DB) -> Result<BackgroundSyncResult> {
|
||||
// Each service only syncs workspaces that have the corresponding integration configured
|
||||
#[cfg(feature = "native_trigger")]
|
||||
{
|
||||
use crate::google::Google;
|
||||
use crate::nextcloud::NextCloud;
|
||||
|
||||
// Nextcloud sync
|
||||
let (service_name, result) = sync_service_triggers(db, NextCloud).await;
|
||||
total_synced += result.synced_triggers.len();
|
||||
total_errors += result.errors.len();
|
||||
service_results.insert(service_name, result);
|
||||
|
||||
// Add new services here:
|
||||
// use crate::newservice::NewService;
|
||||
// let (service_name, result) = sync_service_triggers(db, NewService).await;
|
||||
// total_synced += result.synced_triggers.len();
|
||||
// total_errors += result.errors.len();
|
||||
// service_results.insert(service_name, result);
|
||||
// Google sync (handles both Drive and Calendar triggers)
|
||||
let (service_name, result) = sync_service_triggers(db, Google).await;
|
||||
total_synced += result.synced_triggers.len();
|
||||
total_errors += result.errors.len();
|
||||
service_results.insert(service_name, result);
|
||||
}
|
||||
|
||||
// Count unique workspaces processed across all services
|
||||
@@ -105,6 +106,9 @@ async fn sync_service_triggers<T: External>(
|
||||
let mut all_synced_triggers = Vec::new();
|
||||
let mut all_errors = Vec::new();
|
||||
|
||||
// Use the integration service for lookup (e.g., GoogleDrive/GoogleCalendar -> Google)
|
||||
let integration_service = T::SERVICE_NAME.integration_service();
|
||||
|
||||
// Only sync workspaces that have the corresponding integration configured
|
||||
let workspaces_with_integration = match sqlx::query_scalar!(
|
||||
r#"
|
||||
@@ -115,7 +119,7 @@ async fn sync_service_triggers<T: External>(
|
||||
AND wi.oauth_data IS NOT NULL
|
||||
AND w.deleted = false
|
||||
"#,
|
||||
T::SERVICE_NAME as ServiceName
|
||||
integration_service as ServiceName
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await
|
||||
@@ -210,11 +214,14 @@ pub async fn sync_workspace_triggers<T: External>(
|
||||
return Ok((Vec::new(), Vec::new()));
|
||||
}
|
||||
|
||||
let mut all_synced_triggers = Vec::new();
|
||||
let mut all_sync_errors = Vec::new();
|
||||
let mut synced = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
|
||||
// Use the integration service for OAuth lookup (e.g., GoogleDrive/GoogleCalendar -> Google)
|
||||
let integration_service = T::SERVICE_NAME.integration_service();
|
||||
|
||||
let oauth_data = {
|
||||
match decrypt_oauth_data(db, db, workspace_id, T::SERVICE_NAME).await {
|
||||
match decrypt_oauth_data(db, workspace_id, integration_service).await {
|
||||
Ok(oauth_data) => oauth_data,
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
@@ -222,46 +229,58 @@ pub async fn sync_workspace_triggers<T: External>(
|
||||
workspace_id,
|
||||
e
|
||||
);
|
||||
all_sync_errors.push(SyncError {
|
||||
errors.push(SyncError {
|
||||
resource_path: format!("workspace:{}", workspace_id),
|
||||
error_message: format!("Failed to get workspace integration OAuth data: {}", e),
|
||||
error_type: "oauth_error".to_string(),
|
||||
});
|
||||
return Ok((Vec::new(), all_sync_errors));
|
||||
return Ok((Vec::new(), errors));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
let external_triggers = match handler
|
||||
.list_all(workspace_id, &oauth_data, db, &mut tx)
|
||||
.await
|
||||
{
|
||||
Ok(triggers) => triggers,
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"Failed to fetch external triggers for {}: {}",
|
||||
workspace_id,
|
||||
e
|
||||
);
|
||||
all_sync_errors.push(SyncError {
|
||||
resource_path: format!("workspace:{}", workspace_id),
|
||||
error_message: format!("Failed to fetch external triggers: {}", e),
|
||||
error_type: "external_service_error".to_string(),
|
||||
});
|
||||
return Ok((Vec::new(), all_sync_errors));
|
||||
}
|
||||
};
|
||||
tx.commit().await?;
|
||||
handler
|
||||
.maintain_triggers(
|
||||
db,
|
||||
workspace_id,
|
||||
&windmill_triggers,
|
||||
&oauth_data,
|
||||
&mut synced,
|
||||
&mut errors,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Build a map of external trigger IDs to their data
|
||||
let mut external_trigger_map: HashMap<String, &T::TriggerData> = HashMap::new();
|
||||
for external_trigger in &external_triggers {
|
||||
let external_id = handler.get_external_id_from_trigger_data(external_trigger);
|
||||
external_trigger_map.insert(external_id, external_trigger);
|
||||
tracing::info!(
|
||||
"Sync completed for {} in workspace '{}'. Updated: {}, Errors: {}",
|
||||
T::SERVICE_NAME.as_str(),
|
||||
workspace_id,
|
||||
synced.len(),
|
||||
errors.len()
|
||||
);
|
||||
|
||||
Ok((synced, errors))
|
||||
}
|
||||
|
||||
/// Reusable reconciliation logic for services with real external state (e.g. Nextcloud).
|
||||
/// Compares external triggers with DB triggers: sets errors for missing ones,
|
||||
/// clears errors and updates config for existing ones.
|
||||
#[cfg(feature = "native_trigger")]
|
||||
pub async fn reconcile_with_external_state(
|
||||
db: &DB,
|
||||
workspace_id: &str,
|
||||
service_name: ServiceName,
|
||||
windmill_triggers: &[NativeTrigger],
|
||||
external_triggers: &[(String, serde_json::Value)],
|
||||
synced: &mut Vec<TriggerSyncInfo>,
|
||||
errors: &mut Vec<SyncError>,
|
||||
) {
|
||||
// Build a map of external trigger IDs to their config
|
||||
let mut external_trigger_map: HashMap<String, &serde_json::Value> = HashMap::new();
|
||||
for (external_id, config) in external_triggers {
|
||||
external_trigger_map.insert(external_id.clone(), config);
|
||||
}
|
||||
|
||||
for trigger in &windmill_triggers {
|
||||
for trigger in windmill_triggers {
|
||||
if !external_trigger_map.contains_key(&trigger.external_id) {
|
||||
// Trigger no longer exists on external service - set error
|
||||
let error_msg = "Trigger no longer exists on external service".to_string();
|
||||
@@ -276,14 +295,14 @@ pub async fn sync_workspace_triggers<T: External>(
|
||||
match update_native_trigger_error(
|
||||
db,
|
||||
workspace_id,
|
||||
T::SERVICE_NAME,
|
||||
service_name,
|
||||
&trigger.external_id,
|
||||
Some(&error_msg),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
all_synced_triggers.push(TriggerSyncInfo {
|
||||
synced.push(TriggerSyncInfo {
|
||||
external_id: trigger.external_id.clone(),
|
||||
script_path: trigger.script_path.clone(),
|
||||
action: SyncAction::ErrorSet(error_msg),
|
||||
@@ -295,7 +314,7 @@ pub async fn sync_workspace_triggers<T: External>(
|
||||
trigger.external_id,
|
||||
e
|
||||
);
|
||||
all_sync_errors.push(SyncError {
|
||||
errors.push(SyncError {
|
||||
resource_path: format!("workspace:{}", workspace_id),
|
||||
error_message: format!(
|
||||
"Failed to update error for trigger (external_id: '{}'): {}",
|
||||
@@ -308,7 +327,7 @@ pub async fn sync_workspace_triggers<T: External>(
|
||||
}
|
||||
} else {
|
||||
// Trigger exists on external service
|
||||
let external_trigger_data = external_trigger_map.get(&trigger.external_id).unwrap();
|
||||
let external_service_config = external_trigger_map.get(&trigger.external_id).unwrap();
|
||||
|
||||
// Clear error if it was set
|
||||
if trigger.error.is_some() {
|
||||
@@ -321,14 +340,14 @@ pub async fn sync_workspace_triggers<T: External>(
|
||||
match update_native_trigger_error(
|
||||
db,
|
||||
workspace_id,
|
||||
T::SERVICE_NAME,
|
||||
service_name,
|
||||
&trigger.external_id,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
all_synced_triggers.push(TriggerSyncInfo {
|
||||
synced.push(TriggerSyncInfo {
|
||||
external_id: trigger.external_id.clone(),
|
||||
script_path: trigger.script_path.clone(),
|
||||
action: SyncAction::ErrorCleared,
|
||||
@@ -340,7 +359,7 @@ pub async fn sync_workspace_triggers<T: External>(
|
||||
trigger.external_id,
|
||||
e
|
||||
);
|
||||
all_sync_errors.push(SyncError {
|
||||
errors.push(SyncError {
|
||||
resource_path: format!("workspace:{}", workspace_id),
|
||||
error_message: format!(
|
||||
"Failed to clear error for trigger (external_id: '{}'): {}",
|
||||
@@ -353,14 +372,12 @@ pub async fn sync_workspace_triggers<T: External>(
|
||||
}
|
||||
|
||||
// Compare service_config and update if different
|
||||
let external_service_config =
|
||||
handler.extract_service_config_from_trigger_data(external_trigger_data)?;
|
||||
let stored_service_config = trigger
|
||||
.service_config
|
||||
.clone()
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
|
||||
if external_service_config != stored_service_config {
|
||||
if **external_service_config != stored_service_config {
|
||||
tracing::info!(
|
||||
"Trigger (external_id: '{}', script_path: '{}') config differs from external service, updating local config",
|
||||
trigger.external_id,
|
||||
@@ -370,14 +387,14 @@ pub async fn sync_workspace_triggers<T: External>(
|
||||
match update_native_trigger_service_config(
|
||||
db,
|
||||
workspace_id,
|
||||
T::SERVICE_NAME,
|
||||
service_name,
|
||||
&trigger.external_id,
|
||||
&external_service_config,
|
||||
external_service_config,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
all_synced_triggers.push(TriggerSyncInfo {
|
||||
synced.push(TriggerSyncInfo {
|
||||
external_id: trigger.external_id.clone(),
|
||||
script_path: trigger.script_path.clone(),
|
||||
action: SyncAction::ConfigUpdated,
|
||||
@@ -389,7 +406,7 @@ pub async fn sync_workspace_triggers<T: External>(
|
||||
trigger.external_id,
|
||||
e
|
||||
);
|
||||
all_sync_errors.push(SyncError {
|
||||
errors.push(SyncError {
|
||||
resource_path: format!("workspace:{}", workspace_id),
|
||||
error_message: format!(
|
||||
"Failed to update config for trigger (external_id: '{}'): {}",
|
||||
@@ -408,14 +425,4 @@ pub async fn sync_workspace_triggers<T: External>(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"Sync completed for {} in workspace '{}'. Updated: {}, Errors: {}",
|
||||
T::SERVICE_NAME.as_str(),
|
||||
workspace_id,
|
||||
all_synced_triggers.len(),
|
||||
all_sync_errors.len()
|
||||
);
|
||||
|
||||
Ok((all_synced_triggers, all_sync_errors))
|
||||
}
|
||||
|
||||
@@ -23,7 +23,8 @@ use windmill_audit::{audit_oss::audit_log, ActionKind};
|
||||
use windmill_common::{
|
||||
db::UserDB,
|
||||
error::{Error, JsonResult, Result},
|
||||
utils::require_admin,
|
||||
global_settings::{load_value_from_global_settings, OAUTH_SETTING},
|
||||
utils::{require_admin, HTTP_CLIENT},
|
||||
variables::{build_crypt, encrypt},
|
||||
DB,
|
||||
};
|
||||
@@ -32,10 +33,10 @@ use windmill_common::{
|
||||
use windmill_api_auth::ApiAuthed;
|
||||
|
||||
#[cfg(feature = "native_trigger")]
|
||||
use crate::ServiceName;
|
||||
|
||||
#[cfg(feature = "native_trigger")]
|
||||
use crate::{delete_workspace_integration, store_workspace_integration};
|
||||
use crate::{
|
||||
decrypt_oauth_data, delete_token_by_prefix, delete_workspace_integration, resolve_endpoint,
|
||||
store_workspace_integration, ServiceName,
|
||||
};
|
||||
|
||||
#[cfg(feature = "native_trigger")]
|
||||
use windmill_oauth::{OClient, Url, OAUTH_HTTP_CLIENT};
|
||||
@@ -45,6 +46,8 @@ use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
#[cfg(feature = "native_trigger")]
|
||||
use hmac::{Hmac, Mac};
|
||||
#[cfg(feature = "native_trigger")]
|
||||
use serde_json::json;
|
||||
#[cfg(feature = "native_trigger")]
|
||||
use sha2::Sha256;
|
||||
|
||||
#[cfg(feature = "native_trigger")]
|
||||
@@ -173,10 +176,14 @@ pub struct ConnectIntegrationResponse {
|
||||
#[cfg(feature = "native_trigger")]
|
||||
#[derive(FromRow, Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkspaceOAuthConfig {
|
||||
#[serde(default)]
|
||||
pub client_id: String,
|
||||
#[serde(default)]
|
||||
pub client_secret: String,
|
||||
#[serde(default)]
|
||||
pub base_url: String,
|
||||
pub access_token: Option<String>,
|
||||
#[serde(default)]
|
||||
pub instance_shared: bool,
|
||||
}
|
||||
|
||||
#[cfg(feature = "native_trigger")]
|
||||
@@ -201,20 +208,112 @@ async fn generate_connect_url(
|
||||
|
||||
// Generate a signed state that is cluster-safe
|
||||
let state = generate_signed_state(&db, &workspace_id, service_name).await?;
|
||||
let auth_url = build_authorization_url(&oauth_config, &state, &redirect_uri);
|
||||
let auth_url = build_authorization_url(&oauth_config, service_name, &state, &redirect_uri);
|
||||
Ok(Json(auth_url))
|
||||
}
|
||||
|
||||
#[cfg(feature = "native_trigger")]
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct BasicOAuthData {
|
||||
base_url: String,
|
||||
access_token: String,
|
||||
}
|
||||
|
||||
#[cfg(feature = "native_trigger")]
|
||||
async fn try_delete_nextcloud_webhook(base_url: &str, access_token: &str, external_id: &str) {
|
||||
let url = format!(
|
||||
"{}/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks/{}",
|
||||
base_url, external_id
|
||||
);
|
||||
let _ = HTTP_CLIENT
|
||||
.delete(&url)
|
||||
.bearer_auth(access_token)
|
||||
.header("OCS-APIRequest", "true")
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Delete all native triggers for a workspace+service, including remote webhook cleanup.
|
||||
/// This is best-effort: errors during remote cleanup or token deletion are logged but ignored.
|
||||
#[cfg(feature = "native_trigger")]
|
||||
async fn delete_triggers_for_service(db: &DB, workspace_id: &str, service_name: ServiceName) {
|
||||
let triggers = sqlx::query!(
|
||||
"SELECT external_id, webhook_token_prefix FROM native_trigger WHERE workspace_id = $1 AND service_name = $2",
|
||||
workspace_id,
|
||||
service_name as ServiceName
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await;
|
||||
|
||||
let triggers = match triggers {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to fetch native triggers for service {service_name:?} in workspace {workspace_id}: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if triggers.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// For Nextcloud: try to delete webhooks on the remote instance (best-effort)
|
||||
if service_name == ServiceName::Nextcloud {
|
||||
if let Ok(oauth_data) =
|
||||
decrypt_oauth_data::<BasicOAuthData>(db, workspace_id, service_name).await
|
||||
{
|
||||
for trigger in &triggers {
|
||||
try_delete_nextcloud_webhook(
|
||||
&oauth_data.base_url,
|
||||
&oauth_data.access_token,
|
||||
&trigger.external_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
// For Google: skip remote cleanup (watch channels expire naturally)
|
||||
|
||||
// Bulk delete all triggers
|
||||
if let Err(e) = sqlx::query!(
|
||||
"DELETE FROM native_trigger WHERE workspace_id = $1 AND service_name = $2",
|
||||
workspace_id,
|
||||
service_name as ServiceName
|
||||
)
|
||||
.execute(db)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Failed to delete native triggers for service {service_name:?} in workspace {workspace_id}: {e}");
|
||||
}
|
||||
|
||||
// Delete all associated webhook tokens
|
||||
for trigger in &triggers {
|
||||
if let Err(e) = delete_token_by_prefix(db, &trigger.webhook_token_prefix).await {
|
||||
tracing::error!(
|
||||
"Failed to delete webhook token with prefix {}: {e}",
|
||||
trigger.webhook_token_prefix
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "native_trigger")]
|
||||
async fn delete_integration(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((workspace_id, service_name)): Path<(String, ServiceName)>,
|
||||
) -> JsonResult<String> {
|
||||
require_admin(authed.is_admin, &workspace_id)?;
|
||||
|
||||
// Delete triggers first (needs OAuth data that cleanup_oauth_resource will remove)
|
||||
delete_triggers_for_service(&db, &workspace_id, service_name).await;
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
// Clean up account+variable+resource
|
||||
cleanup_oauth_resource(&mut *tx, &workspace_id, service_name).await;
|
||||
|
||||
let deleted = delete_workspace_integration(&mut *tx, &workspace_id, service_name).await?;
|
||||
|
||||
if !deleted {
|
||||
@@ -248,6 +347,7 @@ async fn delete_integration(
|
||||
struct WorkspaceIntegrations {
|
||||
service_name: ServiceName,
|
||||
oauth_data: Option<sqlx::types::Json<WorkspaceOAuthConfig>>,
|
||||
resource_path: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "native_trigger")]
|
||||
@@ -263,8 +363,9 @@ async fn list_integrations(
|
||||
WorkspaceIntegrations,
|
||||
r#"
|
||||
SELECT
|
||||
oauth_data as "oauth_data!: sqlx::types::Json<WorkspaceOAuthConfig>",
|
||||
service_name as "service_name!: ServiceName"
|
||||
oauth_data as "oauth_data: sqlx::types::Json<WorkspaceOAuthConfig>",
|
||||
service_name as "service_name!: ServiceName",
|
||||
resource_path
|
||||
FROM
|
||||
workspace_integrations
|
||||
WHERE
|
||||
@@ -277,14 +378,23 @@ async fn list_integrations(
|
||||
|
||||
let key_value = integrations
|
||||
.into_iter()
|
||||
.map(|integration| (integration.service_name, integration.oauth_data))
|
||||
.map(|integration| {
|
||||
(
|
||||
integration.service_name,
|
||||
(integration.oauth_data, integration.resource_path),
|
||||
)
|
||||
})
|
||||
.collect::<std::collections::HashMap<_, _>>();
|
||||
|
||||
use strum::IntoEnumIterator;
|
||||
let integrations = ServiceName::iter()
|
||||
.map(|service_name| WorkspaceIntegrations {
|
||||
service_name: service_name,
|
||||
oauth_data: key_value.get(&service_name).cloned().flatten(),
|
||||
.map(|service_name| {
|
||||
let (oauth_data, resource_path) = key_value
|
||||
.get(&service_name)
|
||||
.cloned()
|
||||
.map(|(od, rp)| (od, rp))
|
||||
.unwrap_or((None, None));
|
||||
WorkspaceIntegrations { service_name, oauth_data, resource_path }
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
@@ -304,10 +414,10 @@ async fn integration_exist(
|
||||
r#"
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM workspace_integrations
|
||||
WHERE workspace_id = $1
|
||||
AND service_name = $2
|
||||
AND oauth_data IS NOT NULL
|
||||
FROM workspace_integrations wi
|
||||
WHERE wi.workspace_id = $1
|
||||
AND wi.service_name = $2
|
||||
AND wi.oauth_data IS NOT NULL
|
||||
)
|
||||
"#,
|
||||
workspace_id,
|
||||
@@ -326,18 +436,26 @@ struct RedirectUri {
|
||||
redirect_uri: String,
|
||||
}
|
||||
|
||||
#[cfg(feature = "native_trigger")]
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OAuthCallbackBody {
|
||||
redirect_uri: String,
|
||||
code: String,
|
||||
state: String,
|
||||
resource_path: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "native_trigger")]
|
||||
async fn oauth_callback(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((workspace_id, service_name, code, state)): Path<(String, ServiceName, String, String)>,
|
||||
Json(RedirectUri { redirect_uri }): Json<RedirectUri>,
|
||||
Path((workspace_id, service_name)): Path<(String, ServiceName)>,
|
||||
Json(body): Json<OAuthCallbackBody>,
|
||||
) -> JsonResult<String> {
|
||||
require_admin(authed.is_admin, &workspace_id)?;
|
||||
|
||||
// Validate the signed state (cluster-safe, no DB storage needed)
|
||||
let state_was_valid = validate_signed_state(&db, &state, &workspace_id).await?;
|
||||
let state_was_valid = validate_signed_state(&db, &body.state, &workspace_id).await?;
|
||||
|
||||
if !state_was_valid {
|
||||
return Err(Error::BadRequest(
|
||||
@@ -345,31 +463,122 @@ async fn oauth_callback(
|
||||
));
|
||||
}
|
||||
|
||||
let oauth_config =
|
||||
get_workspace_oauth_config::<WorkspaceOAuthConfig>(&db, &workspace_id, service_name)
|
||||
.await?;
|
||||
// Check if this integration uses instance-shared credentials
|
||||
let existing_oauth_data = sqlx::query_scalar!(
|
||||
r#"SELECT oauth_data FROM workspace_integrations
|
||||
WHERE workspace_id = $1 AND service_name = $2"#,
|
||||
workspace_id,
|
||||
service_name as ServiceName
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.flatten();
|
||||
|
||||
let is_instance_shared = existing_oauth_data
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("instance_shared"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
let oauth_config = if is_instance_shared {
|
||||
get_instance_oauth_config(&db, service_name).await?
|
||||
} else {
|
||||
get_workspace_oauth_config::<WorkspaceOAuthConfig>(&db, &workspace_id, service_name).await?
|
||||
};
|
||||
|
||||
let token_response =
|
||||
exchange_code_for_token(&oauth_config, service_name, &code, &redirect_uri).await?;
|
||||
exchange_code_for_token(&oauth_config, service_name, &body.code, &body.redirect_uri)
|
||||
.await?;
|
||||
|
||||
let resource_path = body
|
||||
.resource_path
|
||||
.filter(|p| !p.is_empty())
|
||||
.unwrap_or_else(|| {
|
||||
format!(
|
||||
"u/{}/native_{}",
|
||||
authed.username,
|
||||
service_name.resource_type()
|
||||
)
|
||||
});
|
||||
|
||||
let expires_in = token_response.expires_in.unwrap_or(3600);
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
// Clean up any previous account+variable+resource for this integration
|
||||
cleanup_oauth_resource(&mut *tx, &workspace_id, service_name).await;
|
||||
|
||||
// 1. Create account record for token refresh
|
||||
let account_id = sqlx::query_scalar!(
|
||||
"INSERT INTO account (workspace_id, client, expires_at, refresh_token, is_workspace_integration)
|
||||
VALUES ($1, $2, now() + ($3 || ' seconds')::interval, $4, true)
|
||||
RETURNING id",
|
||||
workspace_id,
|
||||
service_name.as_str(),
|
||||
expires_in.to_string(),
|
||||
token_response.refresh_token.as_deref().unwrap_or(""),
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| Error::InternalErr(format!("Failed to create account: {}", e)))?;
|
||||
|
||||
// 2. Create variable with encrypted access token
|
||||
let mc = build_crypt(&db, &workspace_id).await?;
|
||||
let mut oauth_data = serde_json::to_value(oauth_config).unwrap();
|
||||
|
||||
let encrypted_access_token = encrypt(&mc, &token_response.access_token);
|
||||
oauth_data["access_token"] = serde_json::Value::String(encrypted_access_token);
|
||||
|
||||
if let Some(refresh_token) = token_response.refresh_token {
|
||||
let encrypted_refresh_token = encrypt(&mc, &refresh_token);
|
||||
oauth_data["refresh_token"] = serde_json::Value::String(encrypted_refresh_token);
|
||||
}
|
||||
if let Some(expires_in) = token_response.expires_in {
|
||||
let expires_at = chrono::Utc::now() + chrono::Duration::seconds(expires_in as i64);
|
||||
oauth_data["token_expires_at"] = serde_json::Value::String(expires_at.to_rfc3339());
|
||||
}
|
||||
sqlx::query!(
|
||||
"INSERT INTO variable (workspace_id, path, value, is_secret, description, account, is_oauth)
|
||||
VALUES ($1, $2, $3, true, $4, $5, true)
|
||||
ON CONFLICT (workspace_id, path) DO UPDATE
|
||||
SET value = EXCLUDED.value, account = EXCLUDED.account",
|
||||
workspace_id,
|
||||
resource_path,
|
||||
encrypted_access_token,
|
||||
format!("OAuth token for {} workspace integration", service_name),
|
||||
account_id,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| Error::InternalErr(format!("Failed to create variable: {}", e)))?;
|
||||
|
||||
store_workspace_integration(&mut *tx, &authed, &workspace_id, service_name, oauth_data).await?;
|
||||
// 3. Create resource pointing to the variable
|
||||
let resource_value = json!({ "token": format!("$var:{}", resource_path) });
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO resource (workspace_id, path, value, resource_type, description, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (workspace_id, path) DO UPDATE
|
||||
SET value = EXCLUDED.value, resource_type = EXCLUDED.resource_type",
|
||||
workspace_id,
|
||||
resource_path,
|
||||
resource_value,
|
||||
service_name.resource_type(),
|
||||
format!("{} workspace integration", service_name),
|
||||
authed.username,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| Error::InternalErr(format!("Failed to create resource: {}", e)))?;
|
||||
|
||||
// 4. Store config + resource_path in workspace_integrations (no tokens).
|
||||
// For instance-shared integrations, store the flag instead of credentials.
|
||||
let stored_data = if is_instance_shared {
|
||||
json!({
|
||||
"instance_shared": true,
|
||||
"base_url": "",
|
||||
})
|
||||
} else {
|
||||
to_value(&oauth_config).unwrap()
|
||||
};
|
||||
store_workspace_integration(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
&workspace_id,
|
||||
service_name,
|
||||
stored_data,
|
||||
Some(&resource_path),
|
||||
)
|
||||
.await?;
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
@@ -407,16 +616,14 @@ fn build_native_oauth_client(
|
||||
service_name: ServiceName,
|
||||
redirect_uri: &str,
|
||||
) -> Result<OClient> {
|
||||
let auth_url = Url::parse(&format!(
|
||||
"{}{}",
|
||||
config.base_url,
|
||||
service_name.auth_endpoint()
|
||||
let auth_url = Url::parse(&resolve_endpoint(
|
||||
&config.base_url,
|
||||
service_name.auth_endpoint(),
|
||||
))
|
||||
.map_err(|e| Error::InternalErr(format!("Invalid auth URL: {}", e)))?;
|
||||
let token_url = Url::parse(&format!(
|
||||
"{}{}",
|
||||
config.base_url,
|
||||
service_name.token_endpoint()
|
||||
let token_url = Url::parse(&resolve_endpoint(
|
||||
&config.base_url,
|
||||
service_name.token_endpoint(),
|
||||
))
|
||||
.map_err(|e| Error::InternalErr(format!("Invalid token URL: {}", e)))?;
|
||||
let redirect = Url::parse(redirect_uri).map_err(|e| {
|
||||
@@ -459,7 +666,7 @@ async fn get_workspace_oauth_config<T: DeserializeOwned>(
|
||||
workspace_id: &str,
|
||||
service_name: ServiceName,
|
||||
) -> Result<T> {
|
||||
let oauth_configs = sqlx::query_scalar!(
|
||||
let oauth_data = sqlx::query_scalar!(
|
||||
r#"
|
||||
SELECT
|
||||
oauth_data
|
||||
@@ -474,15 +681,14 @@ async fn get_workspace_oauth_config<T: DeserializeOwned>(
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.flatten()
|
||||
.ok_or(Error::NotFound(format!(
|
||||
"Integration for service {} not found",
|
||||
service_name.as_str()
|
||||
)))?;
|
||||
|
||||
let config = serde_json::from_value::<T>(oauth_configs)
|
||||
.map_err(|e| Error::InternalErr(format!("Failed to parse OAuth config: {}", e)))?;
|
||||
|
||||
Ok(config)
|
||||
serde_json::from_value::<T>(oauth_data)
|
||||
.map_err(|e| Error::InternalErr(format!("Failed to parse OAuth config: {}", e)))
|
||||
}
|
||||
|
||||
#[cfg(feature = "native_trigger")]
|
||||
@@ -502,6 +708,7 @@ pub async fn create_workspace_integration(
|
||||
&workspace_id,
|
||||
service_name,
|
||||
to_value(oauth_data).unwrap(),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -523,24 +730,193 @@ async fn get_workspace_oauth_config_as_oauth_config(
|
||||
#[cfg(feature = "native_trigger")]
|
||||
fn build_authorization_url(
|
||||
config: &WorkspaceOAuthConfig,
|
||||
service_name: ServiceName,
|
||||
state: &str,
|
||||
redirect_uri: &str,
|
||||
) -> String {
|
||||
let params = [
|
||||
let base_auth_url = resolve_endpoint(&config.base_url, service_name.auth_endpoint());
|
||||
|
||||
let mut params = vec![
|
||||
("response_type", "code"),
|
||||
("client_id", &config.client_id),
|
||||
("client_id", config.client_id.as_str()),
|
||||
("redirect_uri", redirect_uri),
|
||||
("state", state),
|
||||
("scope", "read write"),
|
||||
("scope", service_name.oauth_scopes()),
|
||||
];
|
||||
|
||||
for &(key, value) in service_name.extra_auth_params() {
|
||||
params.push((key, value));
|
||||
}
|
||||
|
||||
let query_string = params
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
|
||||
format!("{}/apps/oauth2/authorize?{}", config.base_url, query_string)
|
||||
format!("{}?{}", base_auth_url, query_string)
|
||||
}
|
||||
|
||||
#[cfg(feature = "native_trigger")]
|
||||
pub async fn cleanup_oauth_resource(
|
||||
tx: &mut sqlx::PgConnection,
|
||||
workspace_id: &str,
|
||||
service_name: ServiceName,
|
||||
) {
|
||||
// Look up the stored resource_path from workspace_integrations
|
||||
let stored_resource_path: Option<String> = sqlx::query_scalar!(
|
||||
r#"SELECT resource_path FROM workspace_integrations WHERE workspace_id = $1 AND service_name = $2"#,
|
||||
workspace_id,
|
||||
service_name as ServiceName,
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.flatten();
|
||||
|
||||
// Find and delete any existing account+variable+resource for this integration
|
||||
let account_ids: Vec<i32> = sqlx::query_scalar!(
|
||||
"DELETE FROM account WHERE workspace_id = $1 AND client = $2 AND is_workspace_integration = true RETURNING id",
|
||||
workspace_id,
|
||||
service_name.as_str(),
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
if !account_ids.is_empty() {
|
||||
// Delete variables linked to these accounts
|
||||
let _ = sqlx::query!(
|
||||
"DELETE FROM variable WHERE workspace_id = $1 AND account = ANY($2)",
|
||||
workspace_id,
|
||||
&account_ids,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Delete resource by exact stored path, or fall back to legacy pattern
|
||||
let resource_type = service_name.resource_type();
|
||||
if let Some(ref path) = stored_resource_path {
|
||||
let _ = sqlx::query!(
|
||||
"DELETE FROM resource WHERE workspace_id = $1 AND path = $2",
|
||||
workspace_id,
|
||||
path,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await;
|
||||
} else {
|
||||
// Legacy fallback for integrations created before user-chosen paths
|
||||
let _ = sqlx::query!(
|
||||
"DELETE FROM resource WHERE workspace_id = $1 AND resource_type = $2 AND path LIKE 'u/%/native_%'",
|
||||
workspace_id,
|
||||
resource_type,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the instance admin has enabled sharing of OAuth credentials for a given service.
|
||||
/// Currently only supported for Google (gworkspace).
|
||||
#[cfg(feature = "native_trigger")]
|
||||
async fn is_instance_sharing_enabled(db: &DB, service_name: ServiceName) -> Result<bool> {
|
||||
// Only Google supports instance sharing for now
|
||||
if service_name != ServiceName::Google {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let oauths_value = match load_value_from_global_settings(db, OAUTH_SETTING).await? {
|
||||
Some(v) => v,
|
||||
None => return Ok(false),
|
||||
};
|
||||
|
||||
let key = service_name.resource_type(); // "gworkspace"
|
||||
let entry = match oauths_value.get(key) {
|
||||
Some(v) => v,
|
||||
None => return Ok(false),
|
||||
};
|
||||
|
||||
let id = entry.get("id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let secret = entry.get("secret").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let share = entry
|
||||
.get("share_with_workspaces")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
Ok(!id.is_empty() && !secret.is_empty() && share)
|
||||
}
|
||||
|
||||
/// Read instance-level OAuth credentials for a service (when sharing is enabled).
|
||||
#[cfg(feature = "native_trigger")]
|
||||
async fn get_instance_oauth_config(
|
||||
db: &DB,
|
||||
service_name: ServiceName,
|
||||
) -> Result<WorkspaceOAuthConfig> {
|
||||
if !is_instance_sharing_enabled(db, service_name).await? {
|
||||
return Err(Error::BadRequest(
|
||||
"Instance credential sharing is not enabled for this service".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let (client_id, client_secret) =
|
||||
windmill_common::global_settings::get_instance_oauth_credentials(
|
||||
db,
|
||||
service_name.resource_type(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(WorkspaceOAuthConfig {
|
||||
client_id,
|
||||
client_secret,
|
||||
base_url: String::new(), // Google uses absolute URLs
|
||||
instance_shared: false,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(feature = "native_trigger")]
|
||||
async fn check_instance_sharing_available(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((workspace_id, service_name)): Path<(String, ServiceName)>,
|
||||
) -> JsonResult<bool> {
|
||||
require_admin(authed.is_admin, &workspace_id)?;
|
||||
let available = is_instance_sharing_enabled(&db, service_name).await?;
|
||||
Ok(Json(available))
|
||||
}
|
||||
|
||||
#[cfg(feature = "native_trigger")]
|
||||
async fn generate_instance_connect_url(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((workspace_id, service_name)): Path<(String, ServiceName)>,
|
||||
Json(RedirectUri { redirect_uri }): Json<RedirectUri>,
|
||||
) -> JsonResult<String> {
|
||||
require_admin(authed.is_admin, &workspace_id)?;
|
||||
|
||||
let instance_config = get_instance_oauth_config(&db, service_name).await?;
|
||||
|
||||
// Store a marker in workspace_integrations — NOT the actual credentials.
|
||||
// The callback and token refresh will read credentials from global settings
|
||||
// when they see instance_shared=true.
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
crate::store_workspace_integration(
|
||||
&mut tx,
|
||||
&authed,
|
||||
&workspace_id,
|
||||
service_name,
|
||||
json!({ "instance_shared": true, "base_url": "" }),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
// Generate signed state and build authorization URL
|
||||
let state = generate_signed_state(&db, &workspace_id, service_name).await?;
|
||||
let auth_url = build_authorization_url(&instance_config, service_name, &state, &redirect_uri);
|
||||
Ok(Json(auth_url))
|
||||
}
|
||||
|
||||
#[cfg(feature = "native_trigger")]
|
||||
@@ -553,8 +929,16 @@ pub fn workspaced_service() -> Router {
|
||||
"/:service_name/generate_connect_url",
|
||||
post(generate_connect_url),
|
||||
)
|
||||
.route(
|
||||
"/:service_name/instance_sharing_available",
|
||||
get(check_instance_sharing_available),
|
||||
)
|
||||
.route(
|
||||
"/:service_name/generate_instance_connect_url",
|
||||
post(generate_instance_connect_url),
|
||||
)
|
||||
.route("/:service_name/delete", delete(delete_integration))
|
||||
.route("/:service_name/callback/:code/:state", post(oauth_callback));
|
||||
.route("/:service_name/callback", post(oauth_callback));
|
||||
|
||||
Router::new().nest("/integrations", router)
|
||||
}
|
||||
|
||||
@@ -1064,6 +1064,15 @@ async fn update_resource(
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_integrations SET resource_path = $1 WHERE workspace_id = $2 AND resource_path = $3",
|
||||
npath,
|
||||
w_id,
|
||||
path
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -803,6 +803,15 @@ async fn update_variable(
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_integrations SET resource_path = $1 WHERE workspace_id = $2 AND resource_path = $3",
|
||||
npath,
|
||||
w_id,
|
||||
path
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user