Webhook trigger update by column (#832)

Add functionality to filter webhooks based on a column or columns.
pull/935/head
CamilleLegeron 2 months ago committed by GitHub
parent 73a02fdebd
commit 76ef4d54f8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -37,7 +37,7 @@ const WEBHOOK_COLUMNS = [
id: 'vt_webhook_fc1', id: 'vt_webhook_fc1',
colId: 'tableId', colId: 'tableId',
type: 'Choice', type: 'Choice',
label: 'Table', label: t('Table'),
// widgetOptions are configured later, since the choices depend // widgetOptions are configured later, since the choices depend
// on the user tables in the document. // on the user tables in the document.
}, },
@ -45,13 +45,13 @@ const WEBHOOK_COLUMNS = [
id: 'vt_webhook_fc2', id: 'vt_webhook_fc2',
colId: 'url', colId: 'url',
type: 'Text', type: 'Text',
label: 'URL', label: t('URL'),
}, },
{ {
id: 'vt_webhook_fc3', id: 'vt_webhook_fc3',
colId: 'eventTypes', colId: 'eventTypes',
type: 'ChoiceList', type: 'ChoiceList',
label: 'Event Types', label: t('Event Types'),
widgetOptions: JSON.stringify({ widgetOptions: JSON.stringify({
widget: 'TextBox', widget: 'TextBox',
alignment: 'left', alignment: 'left',
@ -59,11 +59,17 @@ const WEBHOOK_COLUMNS = [
choiceOptions: {}, choiceOptions: {},
}), }),
}, },
{
id: 'vt_webhook_fc10',
colId: 'watchedColIdsText',
type: 'Text',
label: t('Filter for changes in these columns (semicolon-separated ids)'),
},
{ {
id: 'vt_webhook_fc4', id: 'vt_webhook_fc4',
colId: 'enabled', colId: 'enabled',
type: 'Bool', type: 'Bool',
label: 'Enabled', label: t('Enabled'),
widgetOptions: JSON.stringify({ widgetOptions: JSON.stringify({
widget: 'Switch', widget: 'Switch',
}), }),
@ -72,31 +78,31 @@ const WEBHOOK_COLUMNS = [
id: 'vt_webhook_fc5', id: 'vt_webhook_fc5',
colId: 'isReadyColumn', colId: 'isReadyColumn',
type: 'Text', type: 'Text',
label: 'Ready Column', label: t('Ready Column'),
}, },
{ {
id: 'vt_webhook_fc6', id: 'vt_webhook_fc6',
colId: 'webhookId', colId: 'webhookId',
type: 'Text', type: 'Text',
label: 'Webhook Id', label: t('Webhook Id'),
}, },
{ {
id: 'vt_webhook_fc7', id: 'vt_webhook_fc7',
colId: 'name', colId: 'name',
type: 'Text', type: 'Text',
label: 'Name', label: t('Name'),
}, },
{ {
id: 'vt_webhook_fc8', id: 'vt_webhook_fc8',
colId: 'memo', colId: 'memo',
type: 'Text', type: 'Text',
label: 'Memo', label: t('Memo'),
}, },
{ {
id: 'vt_webhook_fc9', id: 'vt_webhook_fc9',
colId: 'status', colId: 'status',
type: 'Text', type: 'Text',
label: 'Status', label: t('Status'),
}, },
] as const; ] as const;
@ -107,8 +113,8 @@ const WEBHOOK_VIEW_FIELDS: Array<(typeof WEBHOOK_COLUMNS)[number]['colId']> = [
'name', 'memo', 'name', 'memo',
'eventTypes', 'url', 'eventTypes', 'url',
'tableId', 'isReadyColumn', 'tableId', 'isReadyColumn',
'webhookId', 'enabled', 'watchedColIdsText', 'webhookId',
'status' 'enabled', 'status'
]; ];
/** /**
@ -127,9 +133,9 @@ class WebhookExternalTable implements IExternalTable {
public name = 'GristHidden_WebhookTable'; public name = 'GristHidden_WebhookTable';
public initialActions = _prepareWebhookInitialActions(this.name); public initialActions = _prepareWebhookInitialActions(this.name);
public saveableFields = [ public saveableFields = [
'tableId', 'url', 'eventTypes', 'enabled', 'name', 'memo', 'isReadyColumn', 'tableId', 'watchedColIdsText', 'url', 'eventTypes', 'enabled', 'name', 'memo', 'isReadyColumn',
]; ];
public webhooks: ObservableArray<WebhookSummary> = observableArray<WebhookSummary>([]); public webhooks: ObservableArray<UIWebhookSummary> = observableArray<UIWebhookSummary>([]);
public constructor(private _docApi: DocAPI) { public constructor(private _docApi: DocAPI) {
} }
@ -151,7 +157,7 @@ class WebhookExternalTable implements IExternalTable {
} }
const colIds = new Set(getColIdsFromDocAction(d) || []); const colIds = new Set(getColIdsFromDocAction(d) || []);
if (colIds.has('webhookId') || colIds.has('status')) { if (colIds.has('webhookId') || colIds.has('status')) {
throw new Error(`Sorry, not all fields can be edited.`); throw new Error(t(`Sorry, not all fields can be edited.`));
} }
} }
} }
@ -162,7 +168,7 @@ class WebhookExternalTable implements IExternalTable {
continue; continue;
} }
await this._removeWebhook(rec); await this._removeWebhook(rec);
reportMessage(`Removed webhook.`); reportMessage(t(`Removed webhook.`));
} }
const updates = new Set(delta.updateRows); const updates = new Set(delta.updateRows);
const t2 = editor; const t2 = editor;
@ -227,6 +233,7 @@ class WebhookExternalTable implements IExternalTable {
for (const webhook of webhooks) { for (const webhook of webhooks) {
const values = _mapWebhookValues(webhook); const values = _mapWebhookValues(webhook);
const rowId = rowMap.get(webhook.id); const rowId = rowMap.get(webhook.id);
if (rowId) { if (rowId) {
toRemove.delete(rowId); toRemove.delete(rowId);
actions.push( actions.push(
@ -269,7 +276,12 @@ class WebhookExternalTable implements IExternalTable {
private _initalizeWebhookList(webhooks: WebhookSummary[]){ private _initalizeWebhookList(webhooks: WebhookSummary[]){
this.webhooks.removeAll(); this.webhooks.removeAll();
this.webhooks.push(...webhooks); this.webhooks.push(
...webhooks.map(webhook => {
const uiWebhook: UIWebhookSummary = {...webhook};
uiWebhook.fields.watchedColIdsText = webhook.fields.watchedColIds ? webhook.fields.watchedColIds.join(";") : "";
return uiWebhook;
}));
} }
private _getErrorString(e: ApiError): string { private _getErrorString(e: ApiError): string {
@ -308,6 +320,9 @@ class WebhookExternalTable implements IExternalTable {
if (fields.eventTypes) { if (fields.eventTypes) {
fields.eventTypes = without(fields.eventTypes, 'L'); fields.eventTypes = without(fields.eventTypes, 'L');
} }
fields.watchedColIds = fields.watchedColIdsText
? fields.watchedColIdsText.split(";").filter((colId: string) => colId.trim() !== "")
: [];
return fields; return fields;
} }
} }
@ -355,12 +370,12 @@ export class WebhookPage extends DisposableWithEvents {
public async reset() { public async reset() {
await this.docApi.flushWebhooks(); await this.docApi.flushWebhooks();
reportSuccess('Cleared webhook queue.'); reportSuccess(t('Cleared webhook queue.'));
} }
public async resetSelected(id: string) { public async resetSelected(id: string) {
await this.docApi.flushWebhook(id); await this.docApi.flushWebhook(id);
reportSuccess(`Cleared webhook ${id} queue.`); reportSuccess(t(`Cleared webhook ${id} queue.`));
} }
} }
@ -440,16 +455,21 @@ function _prepareWebhookInitialActions(tableId: string): DocAction[] {
/** /**
* Map a webhook summary to a webhook table raw record. The main * Map a webhook summary to a webhook table raw record. The main
* difference is that `eventTypes` is tweaked to be in a cell format, * difference is that `eventTypes` is tweaked to be in a cell format,
* and `status` is converted to a string. * `status` is converted to a string,
* and `watchedColIdsText` is converted to list in a cell format.
*/ */
function _mapWebhookValues(webhookSummary: WebhookSummary): Partial<WebhookSchemaType> { function _mapWebhookValues(webhookSummary: UIWebhookSummary): Partial<WebhookSchemaType> {
const fields = webhookSummary.fields; const fields = webhookSummary.fields;
const {eventTypes} = fields; const {eventTypes, watchedColIdsText} = fields;
const watchedColIds = watchedColIdsText
? watchedColIdsText.split(";").filter(colId => colId.trim() !== "")
: [];
return { return {
...fields, ...fields,
webhookId: webhookSummary.id, webhookId: webhookSummary.id,
status: JSON.stringify(webhookSummary.usage), status: JSON.stringify(webhookSummary.usage),
eventTypes: [GristObjCode.List, ...eventTypes], eventTypes: [GristObjCode.List, ...eventTypes],
watchedColIds: [GristObjCode.List, ...watchedColIds],
}; };
} }
@ -457,6 +477,11 @@ type WebhookSchemaType = {
[prop in keyof WebhookSummary['fields']]: WebhookSummary['fields'][prop] [prop in keyof WebhookSummary['fields']]: WebhookSummary['fields'][prop]
} & { } & {
eventTypes: [GristObjCode, ...unknown[]]; eventTypes: [GristObjCode, ...unknown[]];
watchedColIds: [GristObjCode, ...unknown[]];
status: string; status: string;
webhookId: string; webhookId: string;
} }
type UIWebhookSummary = WebhookSummary & {
fields: {watchedColIdsText?: string;}
}

@ -16,6 +16,7 @@ export const WebhookFields = t.iface([], {
"url": "string", "url": "string",
"eventTypes": t.array(t.union(t.lit("add"), t.lit("update"))), "eventTypes": t.array(t.union(t.lit("add"), t.lit("update"))),
"tableId": "string", "tableId": "string",
"watchedColIds": t.opt(t.array("string")),
"enabled": t.opt("boolean"), "enabled": t.opt("boolean"),
"isReadyColumn": t.opt(t.union("string", "null")), "isReadyColumn": t.opt(t.union("string", "null")),
"name": t.opt("string"), "name": t.opt("string"),
@ -29,6 +30,7 @@ export const WebhookStatus = t.union(t.lit('idle'), t.lit('sending'), t.lit('ret
export const WebhookSubscribe = t.iface([], { export const WebhookSubscribe = t.iface([], {
"url": "string", "url": "string",
"eventTypes": t.array(t.union(t.lit("add"), t.lit("update"))), "eventTypes": t.array(t.union(t.lit("add"), t.lit("update"))),
"watchedColIds": t.opt(t.array("string")),
"enabled": t.opt("boolean"), "enabled": t.opt("boolean"),
"isReadyColumn": t.opt(t.union("string", "null")), "isReadyColumn": t.opt(t.union("string", "null")),
"name": t.opt("string"), "name": t.opt("string"),
@ -47,6 +49,7 @@ export const WebhookSummary = t.iface([], {
"eventTypes": t.array("string"), "eventTypes": t.array("string"),
"isReadyColumn": t.union("string", "null"), "isReadyColumn": t.union("string", "null"),
"tableId": "string", "tableId": "string",
"watchedColIds": t.opt(t.array("string")),
"enabled": "boolean", "enabled": "boolean",
"name": "string", "name": "string",
"memo": "string", "memo": "string",
@ -63,6 +66,7 @@ export const WebhookPatch = t.iface([], {
"url": t.opt("string"), "url": t.opt("string"),
"eventTypes": t.opt(t.array(t.union(t.lit("add"), t.lit("update")))), "eventTypes": t.opt(t.array(t.union(t.lit("add"), t.lit("update")))),
"tableId": t.opt("string"), "tableId": t.opt("string"),
"watchedColIds": t.opt(t.array("string")),
"enabled": t.opt("boolean"), "enabled": t.opt("boolean"),
"isReadyColumn": t.opt(t.union("string", "null")), "isReadyColumn": t.opt(t.union("string", "null")),
"name": t.opt("string"), "name": t.opt("string"),

@ -10,6 +10,7 @@ export interface WebhookFields {
url: string; url: string;
eventTypes: Array<"add"|"update">; eventTypes: Array<"add"|"update">;
tableId: string; tableId: string;
watchedColIds?: string[];
enabled?: boolean; enabled?: boolean;
isReadyColumn?: string|null; isReadyColumn?: string|null;
name?: string; name?: string;
@ -26,6 +27,7 @@ export type WebhookStatus = 'idle'|'sending'|'retrying'|'postponed'|'error'|'inv
export interface WebhookSubscribe { export interface WebhookSubscribe {
url: string; url: string;
eventTypes: Array<"add"|"update">; eventTypes: Array<"add"|"update">;
watchedColIds?: string[];
enabled?: boolean; enabled?: boolean;
isReadyColumn?: string|null; isReadyColumn?: string|null;
name?: string; name?: string;
@ -44,6 +46,7 @@ export interface WebhookSummary {
eventTypes: string[]; eventTypes: string[];
isReadyColumn: string|null; isReadyColumn: string|null;
tableId: string; tableId: string;
watchedColIds?: string[];
enabled: boolean; enabled: boolean;
name: string; name: string;
memo: string; memo: string;
@ -63,6 +66,7 @@ export interface WebhookPatch {
url?: string; url?: string;
eventTypes?: Array<"add"|"update">; eventTypes?: Array<"add"|"update">;
tableId?: string; tableId?: string;
watchedColIds?: string[];
enabled?: boolean; enabled?: boolean;
isReadyColumn?: string|null; isReadyColumn?: string|null;
name?: string; name?: string;

@ -4,7 +4,7 @@ import { GristObjCode } from "app/plugin/GristData";
// tslint:disable:object-literal-key-quotes // tslint:disable:object-literal-key-quotes
export const SCHEMA_VERSION = 41; export const SCHEMA_VERSION = 42;
export const schema = { export const schema = {
@ -167,6 +167,8 @@ export const schema = {
label : "Text", label : "Text",
memo : "Text", memo : "Text",
enabled : "Bool", enabled : "Bool",
watchedColRefList : "RefList:_grist_Tables_column",
options : "Text",
}, },
"_grist_ACLRules": { "_grist_ACLRules": {
@ -388,6 +390,8 @@ export interface SchemaTypes {
label: string; label: string;
memo: string; memo: string;
enabled: boolean; enabled: boolean;
watchedColRefList: [GristObjCode.List, ...number[]]|null;
options: string;
}; };
"_grist_ACLRules": { "_grist_ACLRules": {

@ -380,7 +380,7 @@ export class DocWorkerApi {
const tablesTable = activeDoc.docData!.getMetaTable("_grist_Tables"); const tablesTable = activeDoc.docData!.getMetaTable("_grist_Tables");
const trigger = webhookId ? activeDoc.triggers.getWebhookTriggerRecord(webhookId) : undefined; const trigger = webhookId ? activeDoc.triggers.getWebhookTriggerRecord(webhookId) : undefined;
let currentTableId = trigger ? tablesTable.getValue(trigger.tableRef, 'tableId')! : undefined; let currentTableId = trigger ? tablesTable.getValue(trigger.tableRef, 'tableId')! : undefined;
const {url, eventTypes, isReadyColumn, name} = webhook; const {url, eventTypes, watchedColIds, isReadyColumn, name} = webhook;
const tableId = await getRealTableId(req.params.tableId || webhook.tableId, {metaTables}); const tableId = await getRealTableId(req.params.tableId || webhook.tableId, {metaTables});
const fields: Partial<SchemaTypes['_grist_Triggers']> = {}; const fields: Partial<SchemaTypes['_grist_Triggers']> = {};
@ -397,6 +397,23 @@ export class DocWorkerApi {
} }
if (tableId !== undefined) { if (tableId !== undefined) {
if (watchedColIds) {
if (tableId !== currentTableId && currentTableId) {
// if the tableId changed, we need to reset the watchedColIds
fields.watchedColRefList = [GristObjCode.List];
} else {
if (!tableId) {
throw new ApiError(`Cannot find columns "${watchedColIds}" because table is not known`, 404);
}
fields.watchedColRefList = [GristObjCode.List, ...watchedColIds
.filter(colId => colId.trim() !== "")
.map(
colId => { return colIdToReference(metaTables, tableId, colId.trim().replace(/^\$/, '')); }
)];
}
} else {
fields.watchedColRefList = [GristObjCode.List];
}
fields.tableRef = tableIdToRef(metaTables, tableId); fields.tableRef = tableIdToRef(metaTables, tableId);
currentTableId = tableId; currentTableId = tableId;
} }
@ -898,7 +915,6 @@ export class DocWorkerApi {
const docId = activeDoc.docName; const docId = activeDoc.docName;
const webhookId = req.params.webhookId; const webhookId = req.params.webhookId;
const {fields, url} = await getWebhookSettings(activeDoc, req, webhookId, req.body); const {fields, url} = await getWebhookSettings(activeDoc, req, webhookId, req.body);
if (fields.enabled === false) { if (fields.enabled === false) {
await activeDoc.triggers.clearSingleWebhookQueue(webhookId); await activeDoc.triggers.clearSingleWebhookQueue(webhookId);
} }

@ -277,6 +277,7 @@ export class DocTriggers {
// Webhook might have been deleted in the mean time. // Webhook might have been deleted in the mean time.
continue; continue;
} }
const decodedWatchedColRefList = decodeObject(t.watchedColRefList) as number[] || [];
// Report some basic info and usage stats. // Report some basic info and usage stats.
const entry: WebhookSummary = { const entry: WebhookSummary = {
// Id of the webhook // Id of the webhook
@ -288,6 +289,7 @@ export class DocTriggers {
// Other fields used to register this webhook. // Other fields used to register this webhook.
eventTypes: decodeObject(t.eventTypes) as string[], eventTypes: decodeObject(t.eventTypes) as string[],
isReadyColumn: getColId(t.isReadyColRef) ?? null, isReadyColumn: getColId(t.isReadyColRef) ?? null,
watchedColIds: decodedWatchedColRefList.map((columnRef) => getColId(columnRef)),
tableId: getTableId(t.tableRef) ?? null, tableId: getTableId(t.tableRef) ?? null,
// For future use - for now every webhook is enabled. // For future use - for now every webhook is enabled.
enabled: t.enabled, enabled: t.enabled,
@ -509,6 +511,21 @@ export class DocTriggers {
} }
} }
if (trigger.watchedColRefList) {
for (const colRef of trigger.watchedColRefList.slice(1)) {
if (!this._validateColId(colRef as number, trigger.tableRef)) {
// column does not belong to table, let's ignore trigger and log stats
for (const action of webhookActions) {
const colId = this._getColId(colRef as number); // no validation
const tableId = this._getTableId(trigger.tableRef);
const error = `column is not valid: colId ${colId} does not belong to ${tableId}`;
this._stats.logInvalid(action.id, error).catch(e => log.error("Webhook stats failed to log", e));
}
continue;
}
}
}
// TODO: would be worth checking that the trigger's fields are valid (ie: eventTypes, url, // TODO: would be worth checking that the trigger's fields are valid (ie: eventTypes, url,
// ...) as there's no guarantee that they are. // ...) as there's no guarantee that they are.
@ -585,9 +602,21 @@ export class DocTriggers {
} }
} }
const colIdsToCheck: Array<string> = [];
if (trigger.watchedColRefList) {
for (const colRef of trigger.watchedColRefList.slice(1)) {
colIdsToCheck.push(this._getColId(colRef as number)!);
}
}
let eventType: EventType; let eventType: EventType;
if (readyBefore) { if (readyBefore) {
eventType = "update"; // check if any of the columns to check were changed to consider this an update
if (colIdsToCheck.length === 0 || colIdsToCheck.some(colId => tableDelta.columnDeltas[colId]?.[rowId])) {
eventType = "update";
} else {
return false;
}
// If we allow subscribing to deletion in the future // If we allow subscribing to deletion in the future
// if (recordDelta.existedAfter) { // if (recordDelta.existedAfter) {
// eventType = "update"; // eventType = "update";

@ -6,7 +6,7 @@ export const GRIST_DOC_SQL = `
PRAGMA foreign_keys=OFF; PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION; BEGIN TRANSACTION;
CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT ''); CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT '');
INSERT INTO _grist_DocInfo VALUES(1,'','','',41,'',''); INSERT INTO _grist_DocInfo VALUES(1,'','','',42,'','');
CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0, "recordCardViewSectionRef" INTEGER DEFAULT 0); CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0, "recordCardViewSectionRef" INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "description" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL); CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "description" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL);
CREATE TABLE IF NOT EXISTS "_grist_Imports" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "origFileName" TEXT DEFAULT '', "parseFormula" TEXT DEFAULT '', "delimiter" TEXT DEFAULT '', "doublequote" BOOLEAN DEFAULT 0, "escapechar" TEXT DEFAULT '', "quotechar" TEXT DEFAULT '', "skipinitialspace" BOOLEAN DEFAULT 0, "encoding" TEXT DEFAULT '', "hasHeaders" BOOLEAN DEFAULT 0); CREATE TABLE IF NOT EXISTS "_grist_Imports" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "origFileName" TEXT DEFAULT '', "parseFormula" TEXT DEFAULT '', "delimiter" TEXT DEFAULT '', "doublequote" BOOLEAN DEFAULT 0, "escapechar" TEXT DEFAULT '', "quotechar" TEXT DEFAULT '', "skipinitialspace" BOOLEAN DEFAULT 0, "encoding" TEXT DEFAULT '', "hasHeaders" BOOLEAN DEFAULT 0);
@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS "_grist_Views_section_field" (id INTEGER PRIMARY KEY,
CREATE TABLE IF NOT EXISTS "_grist_Validations" (id INTEGER PRIMARY KEY, "formula" TEXT DEFAULT '', "name" TEXT DEFAULT '', "tableRef" INTEGER DEFAULT 0); CREATE TABLE IF NOT EXISTS "_grist_Validations" (id INTEGER PRIMARY KEY, "formula" TEXT DEFAULT '', "name" TEXT DEFAULT '', "tableRef" INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_REPL_Hist" (id INTEGER PRIMARY KEY, "code" TEXT DEFAULT '', "outputText" TEXT DEFAULT '', "errorText" TEXT DEFAULT ''); CREATE TABLE IF NOT EXISTS "_grist_REPL_Hist" (id INTEGER PRIMARY KEY, "code" TEXT DEFAULT '', "outputText" TEXT DEFAULT '', "errorText" TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS "_grist_Attachments" (id INTEGER PRIMARY KEY, "fileIdent" TEXT DEFAULT '', "fileName" TEXT DEFAULT '', "fileType" TEXT DEFAULT '', "fileSize" INTEGER DEFAULT 0, "fileExt" TEXT DEFAULT '', "imageHeight" INTEGER DEFAULT 0, "imageWidth" INTEGER DEFAULT 0, "timeDeleted" DATETIME DEFAULT NULL, "timeUploaded" DATETIME DEFAULT NULL); CREATE TABLE IF NOT EXISTS "_grist_Attachments" (id INTEGER PRIMARY KEY, "fileIdent" TEXT DEFAULT '', "fileName" TEXT DEFAULT '', "fileType" TEXT DEFAULT '', "fileSize" INTEGER DEFAULT 0, "fileExt" TEXT DEFAULT '', "imageHeight" INTEGER DEFAULT 0, "imageWidth" INTEGER DEFAULT 0, "timeDeleted" DATETIME DEFAULT NULL, "timeUploaded" DATETIME DEFAULT NULL);
CREATE TABLE IF NOT EXISTS "_grist_Triggers" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "eventTypes" TEXT DEFAULT NULL, "isReadyColRef" INTEGER DEFAULT 0, "actions" TEXT DEFAULT '', "label" TEXT DEFAULT '', "memo" TEXT DEFAULT '', "enabled" BOOLEAN DEFAULT 0); CREATE TABLE IF NOT EXISTS "_grist_Triggers" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "eventTypes" TEXT DEFAULT NULL, "isReadyColRef" INTEGER DEFAULT 0, "actions" TEXT DEFAULT '', "label" TEXT DEFAULT '', "memo" TEXT DEFAULT '', "enabled" BOOLEAN DEFAULT 0, "watchedColRefList" TEXT DEFAULT NULL, "options" TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS "_grist_ACLRules" (id INTEGER PRIMARY KEY, "resource" INTEGER DEFAULT 0, "permissions" INTEGER DEFAULT 0, "principals" TEXT DEFAULT '', "aclFormula" TEXT DEFAULT '', "aclColumn" INTEGER DEFAULT 0, "aclFormulaParsed" TEXT DEFAULT '', "permissionsText" TEXT DEFAULT '', "rulePos" NUMERIC DEFAULT 1e999, "userAttributes" TEXT DEFAULT '', "memo" TEXT DEFAULT ''); CREATE TABLE IF NOT EXISTS "_grist_ACLRules" (id INTEGER PRIMARY KEY, "resource" INTEGER DEFAULT 0, "permissions" INTEGER DEFAULT 0, "principals" TEXT DEFAULT '', "aclFormula" TEXT DEFAULT '', "aclColumn" INTEGER DEFAULT 0, "aclFormulaParsed" TEXT DEFAULT '', "permissionsText" TEXT DEFAULT '', "rulePos" NUMERIC DEFAULT 1e999, "userAttributes" TEXT DEFAULT '', "memo" TEXT DEFAULT '');
INSERT INTO _grist_ACLRules VALUES(1,1,63,'[1]','',0,'','',1e999,'',''); INSERT INTO _grist_ACLRules VALUES(1,1,63,'[1]','',0,'','',1e999,'','');
CREATE TABLE IF NOT EXISTS "_grist_ACLResources" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "colIds" TEXT DEFAULT ''); CREATE TABLE IF NOT EXISTS "_grist_ACLResources" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "colIds" TEXT DEFAULT '');
@ -44,7 +44,7 @@ export const GRIST_DOC_WITH_TABLE1_SQL = `
PRAGMA foreign_keys=OFF; PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION; BEGIN TRANSACTION;
CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT ''); CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT '');
INSERT INTO _grist_DocInfo VALUES(1,'','','',41,'',''); INSERT INTO _grist_DocInfo VALUES(1,'','','',42,'','');
CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0, "recordCardViewSectionRef" INTEGER DEFAULT 0); CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0, "recordCardViewSectionRef" INTEGER DEFAULT 0);
INSERT INTO _grist_Tables VALUES(1,'Table1',1,0,0,2,3); INSERT INTO _grist_Tables VALUES(1,'Table1',1,0,0,2,3);
CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "description" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL); CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "description" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL);
@ -80,7 +80,7 @@ INSERT INTO _grist_Views_section_field VALUES(9,3,9,4,0,'',0,0,'',NULL);
CREATE TABLE IF NOT EXISTS "_grist_Validations" (id INTEGER PRIMARY KEY, "formula" TEXT DEFAULT '', "name" TEXT DEFAULT '', "tableRef" INTEGER DEFAULT 0); CREATE TABLE IF NOT EXISTS "_grist_Validations" (id INTEGER PRIMARY KEY, "formula" TEXT DEFAULT '', "name" TEXT DEFAULT '', "tableRef" INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_REPL_Hist" (id INTEGER PRIMARY KEY, "code" TEXT DEFAULT '', "outputText" TEXT DEFAULT '', "errorText" TEXT DEFAULT ''); CREATE TABLE IF NOT EXISTS "_grist_REPL_Hist" (id INTEGER PRIMARY KEY, "code" TEXT DEFAULT '', "outputText" TEXT DEFAULT '', "errorText" TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS "_grist_Attachments" (id INTEGER PRIMARY KEY, "fileIdent" TEXT DEFAULT '', "fileName" TEXT DEFAULT '', "fileType" TEXT DEFAULT '', "fileSize" INTEGER DEFAULT 0, "fileExt" TEXT DEFAULT '', "imageHeight" INTEGER DEFAULT 0, "imageWidth" INTEGER DEFAULT 0, "timeDeleted" DATETIME DEFAULT NULL, "timeUploaded" DATETIME DEFAULT NULL); CREATE TABLE IF NOT EXISTS "_grist_Attachments" (id INTEGER PRIMARY KEY, "fileIdent" TEXT DEFAULT '', "fileName" TEXT DEFAULT '', "fileType" TEXT DEFAULT '', "fileSize" INTEGER DEFAULT 0, "fileExt" TEXT DEFAULT '', "imageHeight" INTEGER DEFAULT 0, "imageWidth" INTEGER DEFAULT 0, "timeDeleted" DATETIME DEFAULT NULL, "timeUploaded" DATETIME DEFAULT NULL);
CREATE TABLE IF NOT EXISTS "_grist_Triggers" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "eventTypes" TEXT DEFAULT NULL, "isReadyColRef" INTEGER DEFAULT 0, "actions" TEXT DEFAULT '', "label" TEXT DEFAULT '', "memo" TEXT DEFAULT '', "enabled" BOOLEAN DEFAULT 0); CREATE TABLE IF NOT EXISTS "_grist_Triggers" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "eventTypes" TEXT DEFAULT NULL, "isReadyColRef" INTEGER DEFAULT 0, "actions" TEXT DEFAULT '', "label" TEXT DEFAULT '', "memo" TEXT DEFAULT '', "enabled" BOOLEAN DEFAULT 0, "watchedColRefList" TEXT DEFAULT NULL, "options" TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS "_grist_ACLRules" (id INTEGER PRIMARY KEY, "resource" INTEGER DEFAULT 0, "permissions" INTEGER DEFAULT 0, "principals" TEXT DEFAULT '', "aclFormula" TEXT DEFAULT '', "aclColumn" INTEGER DEFAULT 0, "aclFormulaParsed" TEXT DEFAULT '', "permissionsText" TEXT DEFAULT '', "rulePos" NUMERIC DEFAULT 1e999, "userAttributes" TEXT DEFAULT '', "memo" TEXT DEFAULT ''); CREATE TABLE IF NOT EXISTS "_grist_ACLRules" (id INTEGER PRIMARY KEY, "resource" INTEGER DEFAULT 0, "permissions" INTEGER DEFAULT 0, "principals" TEXT DEFAULT '', "aclFormula" TEXT DEFAULT '', "aclColumn" INTEGER DEFAULT 0, "aclFormulaParsed" TEXT DEFAULT '', "permissionsText" TEXT DEFAULT '', "rulePos" NUMERIC DEFAULT 1e999, "userAttributes" TEXT DEFAULT '', "memo" TEXT DEFAULT '');
INSERT INTO _grist_ACLRules VALUES(1,1,63,'[1]','',0,'','',1e999,'',''); INSERT INTO _grist_ACLRules VALUES(1,1,63,'[1]','',0,'','',1e999,'','');
CREATE TABLE IF NOT EXISTS "_grist_ACLResources" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "colIds" TEXT DEFAULT ''); CREATE TABLE IF NOT EXISTS "_grist_ACLResources" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "colIds" TEXT DEFAULT '');

@ -1307,3 +1307,13 @@ def migration41(tdset):
] ]
return tdset.apply_doc_actions(doc_actions) return tdset.apply_doc_actions(doc_actions)
@migration(schema_version=42)
def migration42(tdset):
"""
Adds column to register which table columns are triggered in webhooks.
"""
return tdset.apply_doc_actions([
add_column('_grist_Triggers', 'watchedColRefList', 'RefList:_grist_Tables_column'),
add_column('_grist_Triggers', 'options', 'Text'),
])

@ -15,7 +15,7 @@ import six
import actions import actions
SCHEMA_VERSION = 41 SCHEMA_VERSION = 42
def make_column(col_id, col_type, formula='', isFormula=False): def make_column(col_id, col_type, formula='', isFormula=False):
return { return {
@ -261,6 +261,8 @@ def schema_create_actions():
make_column("label", "Text"), make_column("label", "Text"),
make_column("memo", "Text"), make_column("memo", "Text"),
make_column("enabled", "Bool"), make_column("enabled", "Bool"),
make_column("watchedColRefList", "RefList:_grist_Tables_column"),
make_column("options", "Text"),
]), ]),
# All of the ACL rules. # All of the ACL rules.

@ -1169,7 +1169,21 @@
}, },
"WebhookPage": { "WebhookPage": {
"Clear Queue": "Clear Queue", "Clear Queue": "Clear Queue",
"Webhook Settings": "Webhook Settings" "Webhook Settings": "Webhook Settings",
"Cleared webhook queue.": "Cleared webhook queue.",
"Columns to check when update (separated by ;)": "Columns to check when update (separated by ;)",
"Enabled": "Enabled",
"Event Types": "Event Types",
"Memo": "Memo",
"Name": "Name",
"Ready Column": "Ready Column",
"Removed webhook.": "Removed webhook.",
"Sorry, not all fields can be edited.": "Sorry, not all fields can be edited.",
"Status": "Status",
"URL": "URL",
"Webhook Id": "Webhook Id",
"Table": "Table",
"Filter for changes in these columns (semicolon-separated ids)": "Filter for changes in these columns (semicolon-separated ids)"
}, },
"FormulaAssistant": { "FormulaAssistant": {
"Ask the bot.": "Ask the bot.", "Ask the bot.": "Ask the bot.",

@ -34,6 +34,7 @@ describe('WebhookOverflow', function () {
enabled: true, enabled: true,
name: 'test webhook', name: 'test webhook',
tableId: 'Table2', tableId: 'Table2',
watchedColIds: []
}; };
await docApi.addWebhook(webhookDetails); await docApi.addWebhook(webhookDetails);
await docApi.addWebhook(webhookDetails); await docApi.addWebhook(webhookDetails);

@ -55,6 +55,7 @@ describe('WebhookPage', function () {
'URL', 'URL',
'Table', 'Table',
'Ready Column', 'Ready Column',
'Filter for changes in these columns (semicolon-separated ids)',
'Webhook Id', 'Webhook Id',
'Enabled', 'Enabled',
'Status', 'Status',
@ -80,15 +81,17 @@ describe('WebhookPage', function () {
await gu.waitToPass(async () => { await gu.waitToPass(async () => {
assert.equal(await getField(1, 'Webhook Id'), id); assert.equal(await getField(1, 'Webhook Id'), id);
}); });
// Now other fields like name and memo are persisted. // Now other fields like name, memo and watchColIds are persisted.
await setField(1, 'Name', 'Test Webhook'); await setField(1, 'Name', 'Test Webhook');
await setField(1, 'Memo', 'Test Memo'); await setField(1, 'Memo', 'Test Memo');
await setField(1, 'Filter for changes in these columns (semicolon-separated ids)', 'A; B');
await gu.waitForServer(); await gu.waitForServer();
await driver.navigate().refresh(); await driver.navigate().refresh();
await waitForWebhookPage(); await waitForWebhookPage();
await gu.waitToPass(async () => { await gu.waitToPass(async () => {
assert.equal(await getField(1, 'Name'), 'Test Webhook'); assert.equal(await getField(1, 'Name'), 'Test Webhook');
assert.equal(await getField(1, 'Memo'), 'Test Memo'); assert.equal(await getField(1, 'Memo'), 'Test Memo');
assert.equal(await getField(1, 'Filter for changes in these columns (semicolon-separated ids)'), 'A;B');
}); });
// Make sure the webhook is actually working. // Make sure the webhook is actually working.
await docApi.addRows('Table1', {A: ['zig'], B: ['zag']}); await docApi.addRows('Table1', {A: ['zig'], B: ['zag']});

@ -3347,13 +3347,21 @@ function testDocApi() {
}); });
describe('webhooks related endpoints', async function () { describe('webhooks related endpoints', async function () {
/* const serving: Serving = await serveSomething(app => {
Regression test for old _subscribe endpoint. /docs/{did}/webhooks should be used instead to subscribe app.use(express.json());
*/ app.post('/200', ({body}, res) => {
async function oldSubscribeCheck(requestBody: any, status: number, ...errors: RegExp[]) { res.sendStatus(200);
const resp = await axios.post( res.end();
`${serverUrl}/api/docs/${docIds.Timesheets}/tables/Table1/_subscribe`, });
requestBody, chimpy }, webhooksTestPort);
/*
Regression test for old _subscribe endpoint. /docs/{did}/webhooks should be used instead to subscribe
*/
async function oldSubscribeCheck(requestBody: any, status: number, ...errors: RegExp[]) {
const resp = await axios.post(
`${serverUrl}/api/docs/${docIds.Timesheets}/tables/Table1/_subscribe`,
requestBody, chimpy
); );
assert.equal(resp.status, status); assert.equal(resp.status, status);
for (const error of errors) { for (const error of errors) {
@ -3430,7 +3438,15 @@ function testDocApi() {
await postWebhookCheck({webhooks:[{fields: {eventTypes: ["add"], url: "https://example.com"}}]}, await postWebhookCheck({webhooks:[{fields: {eventTypes: ["add"], url: "https://example.com"}}]},
400, /tableId is missing/); 400, /tableId is missing/);
await postWebhookCheck({}, 400, /webhooks is missing/); await postWebhookCheck({}, 400, /webhooks is missing/);
await postWebhookCheck({
webhooks: [{
fields: {
tableId: "Table1", eventTypes: ["update"], watchedColIds: ["notExisting"],
url: `${serving.url}/200`
}
}]
},
403, /Column not found notExisting/);
}); });
@ -3855,6 +3871,7 @@ function testDocApi() {
tableId?: string, tableId?: string,
isReadyColumn?: string | null, isReadyColumn?: string | null,
eventTypes?: string[] eventTypes?: string[]
watchedColIds?: string[],
}) { }) {
// Subscribe helper that returns a method to unsubscribe. // Subscribe helper that returns a method to unsubscribe.
const data = await subscribe(endpoint, docId, options); const data = await subscribe(endpoint, docId, options);
@ -3872,6 +3889,7 @@ function testDocApi() {
tableId?: string, tableId?: string,
isReadyColumn?: string|null, isReadyColumn?: string|null,
eventTypes?: string[], eventTypes?: string[],
watchedColIds?: string[],
name?: string, name?: string,
memo?: string, memo?: string,
enabled?: boolean, enabled?: boolean,
@ -3883,7 +3901,7 @@ function testDocApi() {
eventTypes: options?.eventTypes ?? ['add', 'update'], eventTypes: options?.eventTypes ?? ['add', 'update'],
url: `${serving.url}/${endpoint}`, url: `${serving.url}/${endpoint}`,
isReadyColumn: options?.isReadyColumn === undefined ? 'B' : options?.isReadyColumn, isReadyColumn: options?.isReadyColumn === undefined ? 'B' : options?.isReadyColumn,
...pick(options, 'name', 'memo', 'enabled'), ...pick(options, 'name', 'memo', 'enabled', 'watchedColIds'),
}, chimpy }, chimpy
); );
assert.equal(status, 200); assert.equal(status, 200);
@ -4407,6 +4425,72 @@ function testDocApi() {
await webhook1(); await webhook1();
}); });
it("should call to a webhook only when columns updated are in watchedColIds if not empty", async () => { // eslint-disable-line max-len
// Create a test document.
const ws1 = (await userApi.getOrgWorkspaces('current'))[0].id;
const docId = await userApi.newDoc({ name: 'testdoc5' }, ws1);
const doc = userApi.getDocAPI(docId);
await axios.post(`${serverUrl}/api/docs/${docId}/apply`, [
['ModifyColumn', 'Table1', 'B', { type: 'Bool' }],
], chimpy);
const modifyColumn = async (newValues: { [key: string]: any; } ) => {
await axios.post(`${serverUrl}/api/docs/${docId}/apply`, [
['UpdateRecord', 'Table1', newRowIds[0], newValues],
], chimpy);
await delay(100);
};
const assertSuccessNotCalled = async () => {
assert.isFalse(successCalled.called());
successCalled.reset();
};
const assertSuccessCalled = async () => {
assert.isTrue(successCalled.called());
await successCalled.waitAndReset();
};
// Webhook with only one watchedColId.
const webhook1 = await autoSubscribe('200', docId, {
watchedColIds: ['A'], eventTypes: ['add', 'update']
});
successCalled.reset();
// Create record, that will call the webhook.
const newRowIds = await doc.addRows("Table1", {
A: [2],
B: [true],
C: ['c1']
});
await delay(100);
assert.isTrue(successCalled.called());
await successCalled.waitAndReset();
await modifyColumn({ C: 'c2' });
await assertSuccessNotCalled();
await modifyColumn({ A: 19 });
await assertSuccessCalled();
await webhook1(); // Unsubscribe.
// Webhook with multiple watchedColIds
const webhook2 = await autoSubscribe('200', docId, {
watchedColIds: ['A', 'B'], eventTypes: ['update']
});
successCalled.reset();
await modifyColumn({ C: 'c3' });
await assertSuccessNotCalled();
await modifyColumn({ A: 20 });
await assertSuccessCalled();
await webhook2();
// Check that empty string in watchedColIds are ignored
const webhook3 = await autoSubscribe('200', docId, {
watchedColIds: ['A', ""], eventTypes: ['update']
});
await modifyColumn({ C: 'c4' });
await assertSuccessNotCalled();
await modifyColumn({ A: 21 });
await assertSuccessCalled();
await webhook3();
});
it("should return statistics", async () => { it("should return statistics", async () => {
await clearQueue(docId); await clearQueue(docId);
// Read stats, it should be empty. // Read stats, it should be empty.
@ -4427,6 +4511,7 @@ function testDocApi() {
tableId: 'Table1', tableId: 'Table1',
name: '', name: '',
memo: '', memo: '',
watchedColIds: [],
}, usage : { }, usage : {
status: 'idle', status: 'idle',
numWaiting: 0, numWaiting: 0,
@ -4444,6 +4529,7 @@ function testDocApi() {
tableId: 'Table1', tableId: 'Table1',
name: '', name: '',
memo: '', memo: '',
watchedColIds: [],
}, usage : { }, usage : {
status: 'idle', status: 'idle',
numWaiting: 0, numWaiting: 0,
@ -4775,42 +4861,53 @@ function testDocApi() {
describe('webhook update', function () { describe('webhook update', function () {
it('should work correctly', async function () { it('should work correctly', async function () {
async function check(fields: any, status: number, error?: RegExp | string, async function check(fields: any, status: number, error?: RegExp | string,
expectedFieldsCallback?: (fields: any) => any) { expectedFieldsCallback?: (fields: any) => any) {
let savedTableId = 'Table1';
const origFields = { const origFields = {
tableId: 'Table1', tableId: 'Table1',
eventTypes: ['add'], eventTypes: ['add'],
isReadyColumn: 'B', isReadyColumn: 'B',
name: 'My Webhook', name: 'My Webhook',
memo: 'Sync store', memo: 'Sync store',
watchedColIds: ['A']
}; };
// subscribe // subscribe
const webhook = await subscribe('foo', docId, origFields); const {data} = await axios.post(
`${serverUrl}/api/docs/${docId}/webhooks`,
{
webhooks: [{
fields: {
...origFields,
url: `${serving.url}/foo`
}
}]
}, chimpy
);
const webhooks = data;
const expectedFields = { const expectedFields = {
url: `${serving.url}/foo`, url: `${serving.url}/foo`,
unsubscribeKey: webhook.unsubscribeKey,
eventTypes: ['add'], eventTypes: ['add'],
isReadyColumn: 'B', isReadyColumn: 'B',
tableId: 'Table1', tableId: 'Table1',
enabled: true, enabled: true,
name: 'My Webhook', name: 'My Webhook',
memo: 'Sync store', memo: 'Sync store',
watchedColIds: ['A'],
}; };
let stats = await readStats(docId); let stats = await readStats(docId);
assert.equal(stats.length, 1, 'stats=' + JSON.stringify(stats)); assert.equal(stats.length, 1, 'stats=' + JSON.stringify(stats));
assert.equal(stats[0].id, webhook.webhookId); assert.equal(stats[0].id, webhooks.webhooks[0].id);
assert.deepEqual(stats[0].fields, expectedFields); // eslint-disable-next-line @typescript-eslint/no-unused-vars
const {unsubscribeKey, ...fieldsWithoutUnsubscribeKey} = stats[0].fields;
assert.deepEqual(fieldsWithoutUnsubscribeKey, expectedFields);
// update // update
const resp = await axios.patch( const resp = await axios.patch(
`${serverUrl}/api/docs/${docId}/webhooks/${webhook.webhookId}`, fields, chimpy `${serverUrl}/api/docs/${docId}/webhooks/${webhooks.webhooks[0].id}`, fields, chimpy
); );
// check resp // check resp
@ -4818,14 +4915,13 @@ function testDocApi() {
if (resp.status === 200) { if (resp.status === 200) {
stats = await readStats(docId); stats = await readStats(docId);
assert.equal(stats.length, 1); assert.equal(stats.length, 1);
assert.equal(stats[0].id, webhook.webhookId); assert.equal(stats[0].id, webhooks.webhooks[0].id);
if (expectedFieldsCallback) { if (expectedFieldsCallback) {
expectedFieldsCallback(expectedFields); expectedFieldsCallback(expectedFields);
} }
assert.deepEqual(stats[0].fields, {...expectedFields, ...fields}); // eslint-disable-next-line @typescript-eslint/no-unused-vars
if (fields.tableId) { const {unsubscribeKey, ...fieldsWithoutUnsubscribeKey} = stats[0].fields;
savedTableId = fields.tableId; assert.deepEqual(fieldsWithoutUnsubscribeKey, { ...expectedFields, ...fields });
}
} else { } else {
if (error instanceof RegExp) { if (error instanceof RegExp) {
assert.match(resp.data.details?.userError || resp.data.error, error); assert.match(resp.data.details?.userError || resp.data.error, error);
@ -4835,7 +4931,9 @@ function testDocApi() {
} }
// finally unsubscribe // finally unsubscribe
const unsubscribeResp = await unsubscribe(docId, webhook, savedTableId); const unsubscribeResp = await axios.delete(
`${serverUrl}/api/docs/${docId}/webhooks/${webhooks.webhooks[0].id}`, chimpy
);
assert.equal(unsubscribeResp.status, 200, JSON.stringify(pick(unsubscribeResp, ['data', 'status']))); assert.equal(unsubscribeResp.status, 200, JSON.stringify(pick(unsubscribeResp, ['data', 'status'])));
stats = await readStats(docId); stats = await readStats(docId);
assert.equal(stats.length, 0, 'stats=' + JSON.stringify(stats)); assert.equal(stats.length, 0, 'stats=' + JSON.stringify(stats));
@ -4846,11 +4944,13 @@ function testDocApi() {
await check({url: "http://example.com"}, 403, "Provided url is forbidden"); // not https await check({url: "http://example.com"}, 403, "Provided url is forbidden"); // not https
// changing table without changing the ready column should reset the latter // changing table without changing the ready column should reset the latter
await check({tableId: 'Table2'}, 200, '', expectedFields => expectedFields.isReadyColumn = null); await check({tableId: 'Table2'}, 200, '', expectedFields => {
expectedFields.isReadyColumn = null;
expectedFields.watchedColIds = [];
});
await check({tableId: 'Santa'}, 404, `Table not found "Santa"`); await check({tableId: 'Santa'}, 404, `Table not found "Santa"`);
await check({tableId: 'Table2', isReadyColumn: 'Foo'}, 200); await check({tableId: 'Table2', isReadyColumn: 'Foo', watchedColIds: []}, 200);
await check({eventTypes: ['add', 'update']}, 200); await check({eventTypes: ['add', 'update']}, 200);
await check({eventTypes: []}, 400, "eventTypes must be a non-empty array"); await check({eventTypes: []}, 400, "eventTypes must be a non-empty array");

Loading…
Cancel
Save