feat(workflow): support api hooks (#1591)
Co-authored-by: sealday <sealday@gmail.com> Co-authored-by: Toby <2287769986@qq.com> Reviewed-on: daoyoucloud/tachybase#1591
This commit is contained in:
parent
f458d590c3
commit
970264f86e
@ -261,6 +261,64 @@ const schema: ISchema = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
resourceName: {
|
||||||
|
type: 'void',
|
||||||
|
'x-component': 'Grid.Row',
|
||||||
|
properties: {
|
||||||
|
'5w93jhg5355': {
|
||||||
|
type: 'void',
|
||||||
|
'x-component': 'Grid.Col',
|
||||||
|
properties: {
|
||||||
|
resourceName: {
|
||||||
|
type: 'string',
|
||||||
|
'x-component': 'CollectionField',
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-collection-field': 'webhooks.resourceName',
|
||||||
|
'x-reactions': [
|
||||||
|
{
|
||||||
|
dependencies: ['.type'],
|
||||||
|
fulfill: {
|
||||||
|
state: {
|
||||||
|
hidden: '{{ $deps[0] !== "action" }}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'x-component-props': {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
actionName: {
|
||||||
|
type: 'void',
|
||||||
|
'x-component': 'Grid.Row',
|
||||||
|
properties: {
|
||||||
|
'5w93jhg5355': {
|
||||||
|
type: 'void',
|
||||||
|
'x-component': 'Grid.Col',
|
||||||
|
properties: {
|
||||||
|
actionName: {
|
||||||
|
type: 'string',
|
||||||
|
'x-component': 'CollectionField',
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-collection-field': 'webhooks.actionName',
|
||||||
|
'x-reactions': [
|
||||||
|
{
|
||||||
|
dependencies: ['.type'],
|
||||||
|
fulfill: {
|
||||||
|
state: {
|
||||||
|
hidden: '{{ $deps[0] !== "action" }}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'x-component-props': {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
'38fm61ehxvn': {
|
'38fm61ehxvn': {
|
||||||
type: 'void',
|
type: 'void',
|
||||||
'x-component': 'Grid.Row',
|
'x-component': 'Grid.Row',
|
||||||
@ -611,6 +669,64 @@ const schema: ISchema = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
resourceName: {
|
||||||
|
type: 'void',
|
||||||
|
'x-component': 'Grid.Row',
|
||||||
|
properties: {
|
||||||
|
'5w93jhg5355': {
|
||||||
|
type: 'void',
|
||||||
|
'x-component': 'Grid.Col',
|
||||||
|
properties: {
|
||||||
|
resourceName: {
|
||||||
|
type: 'string',
|
||||||
|
'x-component': 'CollectionField',
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-collection-field': 'webhooks.resourceName',
|
||||||
|
'x-reactions': [
|
||||||
|
{
|
||||||
|
dependencies: ['.type'],
|
||||||
|
fulfill: {
|
||||||
|
state: {
|
||||||
|
hidden: '{{ $deps[0] !== "action" }}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'x-component-props': {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
actionName: {
|
||||||
|
type: 'void',
|
||||||
|
'x-component': 'Grid.Row',
|
||||||
|
properties: {
|
||||||
|
'5w93jhg5355': {
|
||||||
|
type: 'void',
|
||||||
|
'x-component': 'Grid.Col',
|
||||||
|
properties: {
|
||||||
|
actionName: {
|
||||||
|
type: 'string',
|
||||||
|
'x-component': 'CollectionField',
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-collection-field': 'webhooks.actionName',
|
||||||
|
'x-reactions': [
|
||||||
|
{
|
||||||
|
dependencies: ['.type'],
|
||||||
|
fulfill: {
|
||||||
|
state: {
|
||||||
|
hidden: '{{ $deps[0] !== "action" }}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'x-component-props': {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
'38fm61ehxvn': {
|
'38fm61ehxvn': {
|
||||||
type: 'void',
|
type: 'void',
|
||||||
'x-component': 'Grid.Row',
|
'x-component': 'Grid.Row',
|
||||||
|
@ -62,12 +62,35 @@ export const dispatchers: CollectionOptions = {
|
|||||||
enum: [
|
enum: [
|
||||||
{ label: tval('HTTP endpoint'), value: 'code' },
|
{ label: tval('HTTP endpoint'), value: 'code' },
|
||||||
{ label: tval('Scheduler'), value: 'cron' },
|
{ label: tval('Scheduler'), value: 'cron' },
|
||||||
|
{ label: tval('API action'), value: 'action' },
|
||||||
],
|
],
|
||||||
'x-component': 'Radio.Group',
|
'x-component': 'Radio.Group',
|
||||||
'x-decorator': 'FormItem',
|
'x-decorator': 'FormItem',
|
||||||
default: 'code',
|
default: 'code',
|
||||||
} as ISchema,
|
} as ISchema,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
name: 'resourceName',
|
||||||
|
interface: 'input',
|
||||||
|
uiSchema: {
|
||||||
|
title: tval('Resource'),
|
||||||
|
type: 'string',
|
||||||
|
'x-component': 'Input',
|
||||||
|
required: true,
|
||||||
|
} as ISchema,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
name: 'actionName',
|
||||||
|
interface: 'input',
|
||||||
|
uiSchema: {
|
||||||
|
title: tval('Action'),
|
||||||
|
type: 'string',
|
||||||
|
'x-component': 'Input',
|
||||||
|
required: true,
|
||||||
|
} as ISchema,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
name: 'code',
|
name: 'code',
|
||||||
@ -78,7 +101,7 @@ export const dispatchers: CollectionOptions = {
|
|||||||
'x-component': 'CodeMirror',
|
'x-component': 'CodeMirror',
|
||||||
'x-component-props': {
|
'x-component-props': {
|
||||||
defaultValue:
|
defaultValue:
|
||||||
'// ctx.action.params can get user query\n// ctx.action.params.values can get user body\n// ctx.body to pass your data to workflow or to client who invoke this.',
|
'// ctx.action.params can get user query\n// ctx.action.params.values can get user body\n// const { changed, data, error } = await ctx.getChanged(); can get changed fields and raw data\n// ctx.body to pass your data to workflow or to client who invoke this.',
|
||||||
},
|
},
|
||||||
} as ISchema,
|
} as ISchema,
|
||||||
},
|
},
|
||||||
|
@ -289,5 +289,7 @@
|
|||||||
"Common": "Common",
|
"Common": "Common",
|
||||||
"ApprovalCarbonCopy": "Approval:CarbonCopy",
|
"ApprovalCarbonCopy": "Approval:CarbonCopy",
|
||||||
"Approval Launch": "Approval Launch",
|
"Approval Launch": "Approval Launch",
|
||||||
"needAuthorization": "needAuthorization"
|
"needAuthorization": "needAuthorization",
|
||||||
|
"API action": "API action",
|
||||||
|
"Resource": "Resource"
|
||||||
}
|
}
|
||||||
|
@ -367,5 +367,7 @@
|
|||||||
"CC'd to Me": "抄送我的",
|
"CC'd to Me": "抄送我的",
|
||||||
"My Pending Tasks": "我的代办",
|
"My Pending Tasks": "我的代办",
|
||||||
"Common": "通用",
|
"Common": "通用",
|
||||||
"needAuthorization": "是否携带权限校验"
|
"needAuthorization": "是否携带权限校验",
|
||||||
|
"API action": "api操作",
|
||||||
|
"Resource": "数据表"
|
||||||
}
|
}
|
||||||
|
@ -298,6 +298,14 @@ export default class PluginWorkflowServer extends Plugin {
|
|||||||
await this.executing;
|
await this.executing;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.db.on('webhooks.afterCreate', this.removeWebhooksCache.bind(this));
|
||||||
|
this.db.on('webhooks.afterUpdate', this.removeWebhooksCache.bind(this));
|
||||||
|
this.db.on('webhooks.afterDestroy', this.removeWebhooksCache.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeWebhooksCache() {
|
||||||
|
this.app.cache.del('webhooks');
|
||||||
}
|
}
|
||||||
|
|
||||||
toggle(workflow: WorkflowModel, enable?: boolean) {
|
toggle(workflow: WorkflowModel, enable?: boolean) {
|
||||||
|
@ -1,5 +1,8 @@
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { Plugin } from '@tachybase/server';
|
import { Model } from '@tachybase/database';
|
||||||
|
import Application, { Plugin } from '@tachybase/server';
|
||||||
|
|
||||||
|
import lodash from 'lodash';
|
||||||
|
|
||||||
import { WebhookController } from './webhooks';
|
import { WebhookController } from './webhooks';
|
||||||
|
|
||||||
@ -16,5 +19,41 @@ export class PluginWebhook extends Plugin {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.app.acl.allow('webhooks', ['trigger', 'test'], 'loggedIn');
|
this.app.acl.allow('webhooks', ['trigger', 'test'], 'loggedIn');
|
||||||
|
|
||||||
|
const app = this.app as Application;
|
||||||
|
this.app.acl.use(
|
||||||
|
async (ctx, next) => {
|
||||||
|
if (lodash.isEmpty(ctx.action.params)) {
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { resourceName, actionName } = ctx.action;
|
||||||
|
const webhooksRepo = ctx.db.getRepository('webhooks');
|
||||||
|
const actions = await app.cache.wrap(
|
||||||
|
'webhooks',
|
||||||
|
async () => {
|
||||||
|
return await webhooksRepo.find({
|
||||||
|
filter: {
|
||||||
|
enabled: true,
|
||||||
|
type: 'action',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
30 * 60 * 1000,
|
||||||
|
);
|
||||||
|
const actionObj = {};
|
||||||
|
const actionList = actions.filter((action) => {
|
||||||
|
return resourceName === action['resourceName'] && actionName === action['actionName'];
|
||||||
|
});
|
||||||
|
for (const action of actionList) {
|
||||||
|
actionObj[action.id] = await new WebhookController().action(ctx, action);
|
||||||
|
}
|
||||||
|
await next();
|
||||||
|
for (const action of actionList) {
|
||||||
|
await new WebhookController().triggerWorkflow(ctx, action, actionObj[action.id]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ tag: 'api-webhook', after: 'core' },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,15 @@
|
|||||||
|
import { extendCollection } from '@tachybase/database';
|
||||||
|
|
||||||
|
export default extendCollection({
|
||||||
|
name: 'webhooks',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'actionName',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'resourceName',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
@ -27,5 +27,9 @@ export default defineCollection({
|
|||||||
name: 'code',
|
name: 'code',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'settings',
|
||||||
|
type: 'json',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
@ -1,7 +1,160 @@
|
|||||||
import { Context } from '@tachybase/actions';
|
import { Context } from '@tachybase/actions';
|
||||||
|
import { IField } from '@tachybase/data-source-manager';
|
||||||
|
import Database, { Model } from '@tachybase/database';
|
||||||
import { PluginWorkflow } from '@tachybase/plugin-workflow';
|
import { PluginWorkflow } from '@tachybase/plugin-workflow';
|
||||||
|
import { ActionParams } from '@tachybase/resourcer';
|
||||||
|
import Application from '@tachybase/server';
|
||||||
import { dayjs } from '@tachybase/utils';
|
import { dayjs } from '@tachybase/utils';
|
||||||
|
|
||||||
|
import lodash from 'lodash';
|
||||||
|
|
||||||
|
function isSameBasic(val1: any, val2: any): boolean {
|
||||||
|
if (val1 instanceof Date || val2 instanceof Date) {
|
||||||
|
return new Date(val1).getTime() === new Date(val2).getTime();
|
||||||
|
}
|
||||||
|
return val1 === val2;
|
||||||
|
}
|
||||||
|
function getLostKey(smallOne: any, bigOne: any, path = ''): string[] {
|
||||||
|
const lostKeys: Set<string> = new Set();
|
||||||
|
if (typeof bigOne !== 'object' || bigOne === null) {
|
||||||
|
if (smallOne === undefined) {
|
||||||
|
return [path];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const bigKeys = Object.keys(bigOne);
|
||||||
|
for (const key of bigKeys) {
|
||||||
|
let keyLabel = path ? `${path}.${key}` : key;
|
||||||
|
if (Array.isArray(bigOne)) {
|
||||||
|
keyLabel = path; // 去掉数组中的点 (e.g., 'items.0' becomes 'items')
|
||||||
|
}
|
||||||
|
if (smallOne?.[key] === undefined) {
|
||||||
|
lostKeys.add(keyLabel);
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
const result = getLostKey(smallOne?.[key], bigOne?.[key], keyLabel);
|
||||||
|
for (const item of result) {
|
||||||
|
lostKeys.add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...lostKeys];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只用得出before(数据库值)针对after(前端参数)中不同的key
|
||||||
|
// after(前端参数)多余的部分不算在内
|
||||||
|
function getDiffKeyExceptAfter(before: any, after: any, path = ''): string[] {
|
||||||
|
if (after === null || after === undefined) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (typeof before !== 'object' || typeof after !== 'object' || before === null || after === null) {
|
||||||
|
// 一旦其中一个有值
|
||||||
|
if (!isSameBasic(before, after)) {
|
||||||
|
return [path];
|
||||||
|
} else {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(before) && Array.isArray(after) && before.length != after.length) {
|
||||||
|
return [path];
|
||||||
|
}
|
||||||
|
|
||||||
|
const beforeDiffKeys: Set<string> = new Set();
|
||||||
|
const beforeKeys = Object.keys(before);
|
||||||
|
for (const key of beforeKeys) {
|
||||||
|
if (after?.[key] === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let keyLabel = path ? `${path}.${key}` : key;
|
||||||
|
if (Array.isArray(after)) {
|
||||||
|
keyLabel = path; // 去掉数组中的点 (e.g., 'items.0' becomes 'items')
|
||||||
|
}
|
||||||
|
if (before?.[key] === undefined) {
|
||||||
|
beforeDiffKeys.add(keyLabel);
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
const result = getDiffKeyExceptAfter(before?.[key], after?.[key], keyLabel);
|
||||||
|
for (const item of result) {
|
||||||
|
beforeDiffKeys.add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...beforeDiffKeys];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获得真正变动的数据库字段 */
|
||||||
|
function getChanged(ctx: Context): () => Promise<{ changed?: string[]; data?: any; error: Error }> {
|
||||||
|
return async function () {
|
||||||
|
try {
|
||||||
|
const params = lodash.cloneDeep(ctx.action.params) as ActionParams;
|
||||||
|
// const changedKeys = new Set(Object.keys(params.values));
|
||||||
|
const repo = ctx.db.getRepository(ctx.action.resourceName);
|
||||||
|
const fieldsObj: Record<string, IField> = {};
|
||||||
|
const app = ctx.app as Application;
|
||||||
|
const c = app.mainDataSource.collectionManager.getCollection(ctx.action.resourceName);
|
||||||
|
const fields = c.getFields();
|
||||||
|
for (const field of fields) {
|
||||||
|
fieldsObj[field.options.name] = field;
|
||||||
|
}
|
||||||
|
|
||||||
|
let appendSet: Set<string> = new Set();
|
||||||
|
if (params.updateAssociationValues) {
|
||||||
|
appendSet = new Set(params.updateAssociationValues);
|
||||||
|
}
|
||||||
|
for (const key of appendSet) {
|
||||||
|
if (!fieldsObj[key]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const type = fieldsObj[key].options.type;
|
||||||
|
if (type === 'virtual') {
|
||||||
|
appendSet.delete(key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let dataBefore = (
|
||||||
|
await repo.findOne({
|
||||||
|
filter: {
|
||||||
|
id: params.filterByTk,
|
||||||
|
},
|
||||||
|
appends: [...appendSet],
|
||||||
|
})
|
||||||
|
).toJSON();
|
||||||
|
|
||||||
|
const lostKeys = getLostKey(dataBefore, params.values);
|
||||||
|
for (const lostKey of lostKeys) {
|
||||||
|
if (
|
||||||
|
lostKey.includes('.') ||
|
||||||
|
(fieldsObj[lostKey] !== undefined &&
|
||||||
|
['belongsTo', 'belongsToMany', 'hasOne', 'hasMany'].includes(fieldsObj[lostKey].options.type))
|
||||||
|
) {
|
||||||
|
appendSet.add(lostKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dataBefore = (
|
||||||
|
await repo.findOne({
|
||||||
|
filter: {
|
||||||
|
id: params.filterByTk,
|
||||||
|
},
|
||||||
|
appends: [...appendSet],
|
||||||
|
})
|
||||||
|
).toJSON();
|
||||||
|
|
||||||
|
const changed = getDiffKeyExceptAfter(dataBefore, params.values);
|
||||||
|
return {
|
||||||
|
error: null,
|
||||||
|
data: dataBefore,
|
||||||
|
changed,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
ctx.log.error(err);
|
||||||
|
return {
|
||||||
|
error: err.stack,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export class WebhookController {
|
export class WebhookController {
|
||||||
async getLink(ctx: Context) {
|
async getLink(ctx: Context) {
|
||||||
const {
|
const {
|
||||||
@ -69,6 +222,46 @@ export class WebhookController {
|
|||||||
ctx.action.params.values = body;
|
ctx.action.params.values = body;
|
||||||
await new WebhookController().getLink(ctx);
|
await new WebhookController().getLink(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async action(ctx: Context, action: any) {
|
||||||
|
const webhookCtx = {
|
||||||
|
request: ctx.request,
|
||||||
|
action: ctx.action,
|
||||||
|
body: '',
|
||||||
|
getChanged: getChanged(ctx),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await evalSimulate(action.code, {
|
||||||
|
ctx: webhookCtx,
|
||||||
|
lib: {
|
||||||
|
JSON,
|
||||||
|
Math,
|
||||||
|
dayjs,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return webhookCtx.body;
|
||||||
|
} catch (err) {
|
||||||
|
ctx.app.log.error(err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async triggerWorkflow(ctx, action, body) {
|
||||||
|
const { currentUser, currentRole } = ctx.state;
|
||||||
|
const { model: UserModel } = ctx.db.getCollection('users');
|
||||||
|
// 只有绑定工作流才执行
|
||||||
|
if (!action.workflowKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userInfo = {
|
||||||
|
user: UserModel.build(currentUser).desensitize(),
|
||||||
|
roleName: currentRole,
|
||||||
|
};
|
||||||
|
const pluginWorkflow = ctx.app.getPlugin(PluginWorkflow) as PluginWorkflow;
|
||||||
|
const wfRepo = ctx.db.getRepository('workflows');
|
||||||
|
const wf = await wfRepo.findOne({ filter: { key: action.workflowKey, enabled: true } });
|
||||||
|
await pluginWorkflow.trigger(wf, { data: body, ...userInfo }, { httpContext: ctx });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function run(jsCode: string, { ctx, lib }) {
|
function run(jsCode: string, { ctx, lib }) {
|
||||||
@ -78,3 +271,13 @@ function run(jsCode: string, { ctx, lib }) {
|
|||||||
console.log('err', err);
|
console.log('err', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function evalSimulate(jsCode, { ctx, lib }) {
|
||||||
|
const AsyncFunction: any = async function () {}.constructor;
|
||||||
|
return await new AsyncFunction('$root', `with($root) { ${jsCode}; }`)({
|
||||||
|
ctx,
|
||||||
|
// 允许用户覆盖,这个时候可以使用 _ctx
|
||||||
|
__ctx: ctx,
|
||||||
|
lib,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user