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:
sealday 2024-10-15 22:16:11 +08:00
parent f458d590c3
commit 970264f86e
9 changed files with 416 additions and 4 deletions

View File

@ -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': {
type: 'void',
'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': {
type: 'void',
'x-component': 'Grid.Row',

View File

@ -62,12 +62,35 @@ export const dispatchers: CollectionOptions = {
enum: [
{ label: tval('HTTP endpoint'), value: 'code' },
{ label: tval('Scheduler'), value: 'cron' },
{ label: tval('API action'), value: 'action' },
],
'x-component': 'Radio.Group',
'x-decorator': 'FormItem',
default: 'code',
} 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',
name: 'code',
@ -78,7 +101,7 @@ export const dispatchers: CollectionOptions = {
'x-component': 'CodeMirror',
'x-component-props': {
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,
},

View File

@ -289,5 +289,7 @@
"Common": "Common",
"ApprovalCarbonCopy": "Approval:CarbonCopy",
"Approval Launch": "Approval Launch",
"needAuthorization": "needAuthorization"
"needAuthorization": "needAuthorization",
"API action": "API action",
"Resource": "Resource"
}

View File

@ -367,5 +367,7 @@
"CC'd to Me": "抄送我的",
"My Pending Tasks": "我的代办",
"Common": "通用",
"needAuthorization": "是否携带权限校验"
"needAuthorization": "是否携带权限校验",
"API action": "api操作",
"Resource": "数据表"
}

View File

@ -298,6 +298,14 @@ export default class PluginWorkflowServer extends Plugin {
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) {

View File

@ -1,5 +1,8 @@
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';
@ -16,5 +19,41 @@ export class PluginWebhook extends Plugin {
},
});
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' },
);
}
}

View File

@ -0,0 +1,15 @@
import { extendCollection } from '@tachybase/database';
export default extendCollection({
name: 'webhooks',
fields: [
{
name: 'actionName',
type: 'string',
},
{
name: 'resourceName',
type: 'text',
},
],
});

View File

@ -27,5 +27,9 @@ export default defineCollection({
name: 'code',
type: 'text',
},
{
name: 'settings',
type: 'json',
},
],
});

View File

@ -1,7 +1,160 @@
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 { ActionParams } from '@tachybase/resourcer';
import Application from '@tachybase/server';
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 {
async getLink(ctx: Context) {
const {
@ -69,6 +222,46 @@ export class WebhookController {
ctx.action.params.values = body;
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 }) {
@ -78,3 +271,13 @@ function run(jsCode: string, { ctx, lib }) {
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,
});
}