feat(workflow): support Http Request Node (#1102)
* feat(workflow): support Http Request Node * style(workflow): hTTP Request ui title * style(workflow): request node ui title fix * feat(workflow): support timeout config,ignoreFail etc * refactor(workflow): request node Instruction remove unused input.result from templateVars * fix(workflow): fix locale * fix(workflow): perfect request implementation
This commit is contained in:
parent
6f4f601ba8
commit
9b4139e28a
@ -18,9 +18,12 @@
|
|||||||
"classnames": "^2.3.1",
|
"classnames": "^2.3.1",
|
||||||
"cron-parser": "4.4.0",
|
"cron-parser": "4.4.0",
|
||||||
"json-templates": "^4.2.0",
|
"json-templates": "^4.2.0",
|
||||||
"react-js-cron": "^1.4.0"
|
"react-js-cron": "^1.4.0",
|
||||||
|
"ejs": "^3.1.8",
|
||||||
|
"react-copy-to-clipboard": "^5.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/ejs": "^3.1.1",
|
||||||
"@nocobase/test": "0.8.0-alpha.13"
|
"@nocobase/test": "0.8.0-alpha.13"
|
||||||
},
|
},
|
||||||
"gitHead": "ce588eefb0bfc50f7d5bbee575e0b5e843bf6644"
|
"gitHead": "ce588eefb0bfc50f7d5bbee575e0b5e843bf6644"
|
||||||
|
121
packages/plugins/workflow/src/client/components/EjsTextArea.tsx
Normal file
121
packages/plugins/workflow/src/client/components/EjsTextArea.tsx
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Row, Col } from 'antd';
|
||||||
|
import { CopyOutlined, ToolOutlined } from '@ant-design/icons';
|
||||||
|
import { CopyToClipboard } from 'react-copy-to-clipboard';
|
||||||
|
|
||||||
|
import { Operand, VariableTypes, VariableTypesContext } from '../calculators';
|
||||||
|
import { Button, Input, message, Tooltip } from 'antd';
|
||||||
|
import { useFlowContext } from '../FlowContext';
|
||||||
|
import { useCollectionManager, useCompile } from '@nocobase/client';
|
||||||
|
import { useWorkflowTranslation } from '../locale';
|
||||||
|
|
||||||
|
function getVal(operand) {
|
||||||
|
const { options } = operand;
|
||||||
|
const { t } = useWorkflowTranslation();
|
||||||
|
const compile = useCompile();
|
||||||
|
const { getCollectionFields } = useCollectionManager();
|
||||||
|
const { nodes } = useFlowContext();
|
||||||
|
|
||||||
|
switch (operand.type) {
|
||||||
|
case '$context':
|
||||||
|
if (options?.path) {
|
||||||
|
return `<%=ctx.${options.path}%>`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case '$jobsMapByNodeId':
|
||||||
|
if (options?.nodeId) {
|
||||||
|
const node = nodes.find((n) => n.id == options.nodeId);
|
||||||
|
if (node) {
|
||||||
|
if (node.type === 'calculation') {
|
||||||
|
return `<%= node.${options.nodeId} // ${t('Calculation result')} %>`;
|
||||||
|
}
|
||||||
|
if (options.path && node?.config?.collection) {
|
||||||
|
const fields = getCollectionFields(node?.config?.collection);
|
||||||
|
const field = fields.find((f) => f.name == options.path);
|
||||||
|
if (field) {
|
||||||
|
return `<%= node[${options.nodeId}].${options.path} // ${compile(
|
||||||
|
field.uiSchema?.title || field.name,
|
||||||
|
)} %>`;
|
||||||
|
}
|
||||||
|
return `<%= node[${options.nodeId}].${options.path}%>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const AvailableVariableTool = (props) => {
|
||||||
|
const { t } = useWorkflowTranslation();
|
||||||
|
const [operand, setOperand] = useState({ type: '$context', value: '' });
|
||||||
|
return (
|
||||||
|
<Row style={{ width: '100%' }}>
|
||||||
|
<Col span={10}>
|
||||||
|
<Operand value={operand} onChange={(v) => setOperand(v)} />
|
||||||
|
</Col>
|
||||||
|
<Col span={10}>
|
||||||
|
<Input value={getVal(operand)} disabled />
|
||||||
|
</Col>
|
||||||
|
<Col span={4}>
|
||||||
|
<CopyToClipboard
|
||||||
|
text={getVal(operand)}
|
||||||
|
onCopy={() => {
|
||||||
|
message.success({
|
||||||
|
content: t('Copy success!'),
|
||||||
|
duration: 1,
|
||||||
|
style: {
|
||||||
|
marginTop: '20vh',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip title={t('Copy variable output template statement')}>
|
||||||
|
<Button icon={<CopyOutlined />} />
|
||||||
|
</Tooltip>
|
||||||
|
</CopyToClipboard>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function (props) {
|
||||||
|
const [showTool, setShowTool] = useState(false);
|
||||||
|
const { t } = useWorkflowTranslation();
|
||||||
|
const requestVariableTypes = {
|
||||||
|
$context: VariableTypes.$context,
|
||||||
|
$jobsMapByNodeId: VariableTypes.$jobsMapByNodeId,
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<VariableTypesContext.Provider value={requestVariableTypes}>
|
||||||
|
<Row style={{ width: '100%' }}>
|
||||||
|
<Col span={24}>
|
||||||
|
<Tooltip title={t('Show available variable tool')}>
|
||||||
|
<Button type={'text'} icon={<ToolOutlined />} onClick={() => setShowTool(!showTool)} />
|
||||||
|
</Tooltip>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
{showTool && <AvailableVariableTool></AvailableVariableTool>}
|
||||||
|
|
||||||
|
<Row style={{ width: '100%' }}>
|
||||||
|
<Col span={24}>
|
||||||
|
<Input.TextArea {...props} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
{showTool && (
|
||||||
|
<Row>
|
||||||
|
<Col span={24}>
|
||||||
|
<div className="ant-formily-item-extra">
|
||||||
|
{props.description}
|
||||||
|
{' '}
|
||||||
|
{t('Syntax see')}{' '}
|
||||||
|
<a target={'_blank'} href={'https://ejs.co'}>
|
||||||
|
ejs
|
||||||
|
</a>
|
||||||
|
{' '}
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
</VariableTypesContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
@ -85,4 +85,22 @@ export default {
|
|||||||
"Fields that are not assigned a value will be set to the default value, and those that do not have a default value are set to null.": "Fields that are not assigned a value will be set to the default value, and those that do not have a default value are set to null.",
|
"Fields that are not assigned a value will be set to the default value, and those that do not have a default value are set to null.": "Fields that are not assigned a value will be set to the default value, and those that do not have a default value are set to null.",
|
||||||
"Trigger in executed workflow cannot be modified": "Trigger in executed workflow cannot be modified",
|
"Trigger in executed workflow cannot be modified": "Trigger in executed workflow cannot be modified",
|
||||||
"Node in executed workflow cannot be modified": "Node in executed workflow cannot be modified",
|
"Node in executed workflow cannot be modified": "Node in executed workflow cannot be modified",
|
||||||
|
|
||||||
|
'HTTP request': 'HTTP request',
|
||||||
|
'URL': 'URL',
|
||||||
|
'You can use the above available variables in URL.': 'You can use the above available variables in URL.',
|
||||||
|
'Request headers': 'Request headers',
|
||||||
|
'Name(e.g. Content-Type)': 'Name(e.g. Content-Type)',
|
||||||
|
'Value(e.g. Application/json)': 'Value(e.g. Application/json)',
|
||||||
|
'Add request header': 'Add request header',
|
||||||
|
'HTTP method': 'HTTP method',
|
||||||
|
'Request data': 'Request data',
|
||||||
|
'Input request data': 'Input request data',
|
||||||
|
'You can use the above available variables in request data.': 'You can use the above available variables in request data.',
|
||||||
|
'Copy success!': 'Copy success!',
|
||||||
|
'Copy variable output template statement': 'Copy variable output template statement',
|
||||||
|
'Default headers is Content-Type: application/json': 'Default headers is Content-Type: application/json',
|
||||||
|
'Ignore fail request and continue workflow': 'Ignore fail request and continue workflow',
|
||||||
|
'Syntax see': 'Syntax see',
|
||||||
|
'Show available variable tool': 'Show available variable tool'
|
||||||
};
|
};
|
||||||
|
@ -125,4 +125,22 @@ export default {
|
|||||||
'Fields that are not assigned a value will be set to the default value, and those that do not have a default value are set to null.': '未被赋值的字段将被设置为默认值,没有默认值的设置为空值。',
|
'Fields that are not assigned a value will be set to the default value, and those that do not have a default value are set to null.': '未被赋值的字段将被设置为默认值,没有默认值的设置为空值。',
|
||||||
'Trigger in executed workflow cannot be modified': '已经执行过工作流的触发器不能被修改',
|
'Trigger in executed workflow cannot be modified': '已经执行过工作流的触发器不能被修改',
|
||||||
'Node in executed workflow cannot be modified': '已经执行过工作流中的节点不能被修改',
|
'Node in executed workflow cannot be modified': '已经执行过工作流中的节点不能被修改',
|
||||||
|
|
||||||
|
'HTTP request': 'HTTP 请求',
|
||||||
|
'URL': '地址',
|
||||||
|
'You can use the above available variables in URL.': '您可以在地址中使用使用上面的可用变量。',
|
||||||
|
'Request headers': '请求头',
|
||||||
|
'Name(e.g. Content-Type)': '名称(例如 Content-Type)',
|
||||||
|
'Value(e.g. Application/json)': '值(例如 Application/json)',
|
||||||
|
'Add request header': '添加请求头',
|
||||||
|
'HTTP method': 'HTTP 方法',
|
||||||
|
'Request data': '请求数据',
|
||||||
|
'Input request data': '输入请求数据',
|
||||||
|
'You can use the above available variables in request data.': '您可以在请求数据中使用上面的可用变量。',
|
||||||
|
'Copy success!': '拷贝成功!',
|
||||||
|
'Copy variable output template statement': '拷贝变量输出模版语句',
|
||||||
|
'Default headers is Content-Type: application/json': '默认请求头是 Content-Type: application/json',
|
||||||
|
'Ignore fail request and continue workflow': '忽略失败请求并继续工作流',
|
||||||
|
'Syntax see': '语法参考',
|
||||||
|
'Show available variable tool':'显示可用变量工具'
|
||||||
};
|
};
|
||||||
|
@ -25,6 +25,7 @@ import update from './update';
|
|||||||
import destroy from './destroy';
|
import destroy from './destroy';
|
||||||
import { JobStatusOptions, JobStatusOptionsMap } from '../constants';
|
import { JobStatusOptions, JobStatusOptionsMap } from '../constants';
|
||||||
import { lang, NAMESPACE } from '../locale';
|
import { lang, NAMESPACE } from '../locale';
|
||||||
|
import request from "./request";
|
||||||
|
|
||||||
export interface Instruction {
|
export interface Instruction {
|
||||||
title: string;
|
title: string;
|
||||||
@ -51,6 +52,7 @@ instructions.register('query', query);
|
|||||||
instructions.register('create', create);
|
instructions.register('create', create);
|
||||||
instructions.register('update', update);
|
instructions.register('update', update);
|
||||||
instructions.register('destroy', destroy);
|
instructions.register('destroy', destroy);
|
||||||
|
instructions.register('request', request);
|
||||||
|
|
||||||
function useUpdateAction() {
|
function useUpdateAction() {
|
||||||
const form = useForm();
|
const form = useForm();
|
||||||
|
142
packages/plugins/workflow/src/client/nodes/request.tsx
Normal file
142
packages/plugins/workflow/src/client/nodes/request.tsx
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import { NAMESPACE } from '../locale';
|
||||||
|
import { ArrayItems } from '@formily/antd';
|
||||||
|
import React from 'react';
|
||||||
|
import EjsTextArea from '../components/EjsTextArea';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: `{{t("HTTP request", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
type: 'request',
|
||||||
|
group: 'extended',
|
||||||
|
fieldset: {
|
||||||
|
'config.url': {
|
||||||
|
type: 'string',
|
||||||
|
name: 'config.url',
|
||||||
|
required: true,
|
||||||
|
title: `{{t("URL", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-decorator-props': {},
|
||||||
|
'x-component': 'EjsTextArea',
|
||||||
|
'x-component-props': {
|
||||||
|
autoSize: {
|
||||||
|
minRows: 1,
|
||||||
|
},
|
||||||
|
placeholder: 'https://xxxxxx',
|
||||||
|
description: `{{t("You can use the above available variables in URL.", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'config.timeout': {
|
||||||
|
type: 'number',
|
||||||
|
name: 'config.timeout',
|
||||||
|
required: true,
|
||||||
|
title: `{{t("Timeout config", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-decorator-props': {},
|
||||||
|
'x-component': 'InputNumber',
|
||||||
|
'x-component-props': {
|
||||||
|
addonAfter: `{{t("ms", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
min: 1,
|
||||||
|
step: 1000,
|
||||||
|
defaultValue: 5000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'config.headers': {
|
||||||
|
type: 'array',
|
||||||
|
name: 'config.headers',
|
||||||
|
'x-component': 'ArrayItems',
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
title: `{{t("Request headers", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
description: `{{t("Default headers is Content-Type: application/json", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
space: {
|
||||||
|
type: 'void',
|
||||||
|
'x-component': 'Space',
|
||||||
|
properties: {
|
||||||
|
name: {
|
||||||
|
type: 'string',
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'Input',
|
||||||
|
'x-component-props': {
|
||||||
|
placeholder: `{{t("Name(e.g. Content-Type)", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
type: 'string',
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'Input',
|
||||||
|
'x-component-props': {
|
||||||
|
placeholder: `{{t("Value(e.g. Application/json)", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
remove: {
|
||||||
|
type: 'void',
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'ArrayItems.Remove',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
add: {
|
||||||
|
type: 'void',
|
||||||
|
title: `{{t("Add request header", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
'x-component': 'ArrayItems.Addition',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'config.method': {
|
||||||
|
type: 'string',
|
||||||
|
name: 'config.method',
|
||||||
|
required: true,
|
||||||
|
title: `{{t("HTTP method", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'Select',
|
||||||
|
'x-component-props': {
|
||||||
|
defaultValue: 'POST',
|
||||||
|
showSearch: false,
|
||||||
|
allowClear: false,
|
||||||
|
},
|
||||||
|
enum: [
|
||||||
|
{ label: 'GET', value: 'GET' },
|
||||||
|
{ label: 'POST', value: 'POST' },
|
||||||
|
{ label: 'PUT', value: 'PUT' },
|
||||||
|
{ label: 'PATCH', value: 'PATCH' },
|
||||||
|
{ label: 'DELETE', value: 'DELETE' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'config.data': {
|
||||||
|
type: 'string',
|
||||||
|
name: 'config.data',
|
||||||
|
'x-hidden': false,
|
||||||
|
title: `{{t("Request data", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-decorator-props': {},
|
||||||
|
'x-component': 'EjsTextArea',
|
||||||
|
'x-component-props': {
|
||||||
|
autoSize: {
|
||||||
|
minRows: 5,
|
||||||
|
},
|
||||||
|
placeholder: `{{t("Input request data", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
description: `{{t("You can use the above available variables in request data.", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
},
|
||||||
|
|
||||||
|
},
|
||||||
|
'config.ignoreFail': {
|
||||||
|
type: 'boolean',
|
||||||
|
name: 'config.ignoreFail',
|
||||||
|
title: `{{t("Ignore fail request and continue workflow", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'Checkbox',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
view: {},
|
||||||
|
scope: {},
|
||||||
|
components: {
|
||||||
|
ArrayItems,
|
||||||
|
EjsTextArea,
|
||||||
|
},
|
||||||
|
};
|
@ -0,0 +1,247 @@
|
|||||||
|
/**
|
||||||
|
* @jest-environment node
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Application } from '@nocobase/server';
|
||||||
|
import Database from '@nocobase/database';
|
||||||
|
import { getApp, sleep } from '..';
|
||||||
|
import { RequestConfig } from '../../instructions/request';
|
||||||
|
import axios, { AxiosRequestConfig } from 'axios';
|
||||||
|
import { JOB_STATUS } from '../../constants';
|
||||||
|
|
||||||
|
const testUrl = 'https://nocobase.com/test';
|
||||||
|
const url_400 = 'https://nocobase.com/400';
|
||||||
|
const timeoutUrl = 'https://nocobase.com/timeout';
|
||||||
|
jest.mock('axios', () => {
|
||||||
|
return {
|
||||||
|
request: async (config: AxiosRequestConfig) => {
|
||||||
|
await sleep(1000);
|
||||||
|
if (config.url === url_400) {
|
||||||
|
return {
|
||||||
|
data: config,
|
||||||
|
status: 400,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (config.url === timeoutUrl) {
|
||||||
|
throw new Error('timeout');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
data: config,
|
||||||
|
status: 200,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('workflow > instructions > request', () => {
|
||||||
|
let app: Application;
|
||||||
|
let db: Database;
|
||||||
|
let PostRepo;
|
||||||
|
let WorkflowModel;
|
||||||
|
let workflow;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
app = await getApp();
|
||||||
|
|
||||||
|
db = app.db;
|
||||||
|
WorkflowModel = db.getCollection('workflows').model;
|
||||||
|
PostRepo = db.getCollection('posts').repository;
|
||||||
|
|
||||||
|
workflow = await WorkflowModel.create({
|
||||||
|
title: 'test workflow',
|
||||||
|
enabled: true,
|
||||||
|
type: 'collection',
|
||||||
|
config: {
|
||||||
|
mode: 1,
|
||||||
|
collection: 'posts',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => app.stop());
|
||||||
|
|
||||||
|
describe('request', () => {
|
||||||
|
it('request', async () => {
|
||||||
|
await workflow.createNode({
|
||||||
|
type: 'request',
|
||||||
|
config: {
|
||||||
|
url: 'https://www.baidu.com?name=lily&age=20',
|
||||||
|
method: 'GET',
|
||||||
|
} as RequestConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
await PostRepo.create({ values: { title: 't1' } });
|
||||||
|
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
let [execution] = await workflow.getExecutions();
|
||||||
|
let [job] = await execution.getJobs();
|
||||||
|
expect(job.status).toEqual(JOB_STATUS.PENDING);
|
||||||
|
|
||||||
|
await sleep(2000);
|
||||||
|
|
||||||
|
[execution] = await workflow.getExecutions();
|
||||||
|
[job] = await execution.getJobs();
|
||||||
|
expect(job.status).toEqual(JOB_STATUS.RESOLVED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('request - url with variable template', async () => {
|
||||||
|
await workflow.createNode({
|
||||||
|
type: 'request',
|
||||||
|
config: {
|
||||||
|
url: 'https://www.xxx.com?title=<%= ctx.data.title // 测试 %>',
|
||||||
|
method: 'GET',
|
||||||
|
} as RequestConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
await PostRepo.create({ values: { title: 't1' } });
|
||||||
|
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
let [execution] = await workflow.getExecutions();
|
||||||
|
let [job] = await execution.getJobs();
|
||||||
|
expect(job.status).toEqual(JOB_STATUS.PENDING);
|
||||||
|
|
||||||
|
await sleep(1000);
|
||||||
|
|
||||||
|
[execution] = await workflow.getExecutions();
|
||||||
|
[job] = await execution.getJobs();
|
||||||
|
expect(job.status).toEqual(JOB_STATUS.RESOLVED);
|
||||||
|
expect(job.result.url).toEqual('https://www.xxx.com?title=t1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('request - timeout', async () => {
|
||||||
|
await workflow.createNode({
|
||||||
|
type: 'request',
|
||||||
|
config: {
|
||||||
|
url: timeoutUrl,
|
||||||
|
method: 'GET',
|
||||||
|
timeout: 1000,
|
||||||
|
} as RequestConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
await PostRepo.create({ values: { title: 't1' } });
|
||||||
|
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
let [execution] = await workflow.getExecutions();
|
||||||
|
let [job] = await execution.getJobs();
|
||||||
|
expect(job.status).toEqual(JOB_STATUS.PENDING);
|
||||||
|
|
||||||
|
await sleep(1000);
|
||||||
|
|
||||||
|
[execution] = await workflow.getExecutions();
|
||||||
|
[job] = await execution.getJobs();
|
||||||
|
expect(job.status).toEqual(JOB_STATUS.REJECTED);
|
||||||
|
expect(job.result).toMatch('timeout');
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
it('request - ignoreFail', async () => {
|
||||||
|
await workflow.createNode({
|
||||||
|
type: 'request',
|
||||||
|
config: {
|
||||||
|
url: timeoutUrl,
|
||||||
|
method: 'GET',
|
||||||
|
timeout: 1000,
|
||||||
|
ignoreFail: true,
|
||||||
|
} as RequestConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
await PostRepo.create({ values: { title: 't1' } });
|
||||||
|
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
let [execution] = await workflow.getExecutions();
|
||||||
|
let [job] = await execution.getJobs();
|
||||||
|
expect(job.status).toEqual(JOB_STATUS.PENDING);
|
||||||
|
|
||||||
|
await sleep(1000);
|
||||||
|
|
||||||
|
[execution] = await workflow.getExecutions();
|
||||||
|
[job] = await execution.getJobs();
|
||||||
|
expect(job.status).toEqual(JOB_STATUS.RESOLVED);
|
||||||
|
expect(job.result).toMatch('timeout');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('response 400', async () => {
|
||||||
|
await workflow.createNode({
|
||||||
|
type: 'request',
|
||||||
|
config: {
|
||||||
|
url: url_400,
|
||||||
|
method: 'GET',
|
||||||
|
timeout: 1000,
|
||||||
|
ignoreFail: false,
|
||||||
|
} as RequestConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
await PostRepo.create({ values: { title: 't1' } });
|
||||||
|
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
let [execution] = await workflow.getExecutions();
|
||||||
|
let [job] = await execution.getJobs();
|
||||||
|
expect(job.status).toEqual(JOB_STATUS.PENDING);
|
||||||
|
|
||||||
|
await sleep(1000);
|
||||||
|
|
||||||
|
[execution] = await workflow.getExecutions();
|
||||||
|
[job] = await execution.getJobs();
|
||||||
|
expect(job.status).toEqual(JOB_STATUS.REJECTED);
|
||||||
|
expect(job.result.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('response 400 ignoreFail', async () => {
|
||||||
|
await workflow.createNode({
|
||||||
|
type: 'request',
|
||||||
|
config: {
|
||||||
|
url: url_400,
|
||||||
|
method: 'GET',
|
||||||
|
timeout: 1000,
|
||||||
|
ignoreFail: true,
|
||||||
|
} as RequestConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
await PostRepo.create({ values: { title: 't1' } });
|
||||||
|
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
let [execution] = await workflow.getExecutions();
|
||||||
|
let [job] = await execution.getJobs();
|
||||||
|
expect(job.status).toEqual(JOB_STATUS.PENDING);
|
||||||
|
|
||||||
|
await sleep(1000);
|
||||||
|
|
||||||
|
[execution] = await workflow.getExecutions();
|
||||||
|
[job] = await execution.getJobs();
|
||||||
|
expect(job.status).toEqual(JOB_STATUS.RESOLVED);
|
||||||
|
expect(job.result.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('request with data', async () => {
|
||||||
|
const n1 = await workflow.createNode({
|
||||||
|
type: 'request',
|
||||||
|
config: {
|
||||||
|
url: testUrl,
|
||||||
|
method: 'POST',
|
||||||
|
data: '{"title": "<%=ctx.data.title%>"}',
|
||||||
|
} as RequestConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
await PostRepo.create({ values: { title: 't1' } });
|
||||||
|
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
let [execution] = await workflow.getExecutions();
|
||||||
|
let [job] = await execution.getJobs();
|
||||||
|
expect(job.status).toEqual(JOB_STATUS.PENDING);
|
||||||
|
|
||||||
|
await sleep(1000);
|
||||||
|
|
||||||
|
[execution] = await workflow.getExecutions();
|
||||||
|
[job] = await execution.getJobs();
|
||||||
|
expect(job.status).toEqual(JOB_STATUS.RESOLVED);
|
||||||
|
expect(JSON.parse(job.result.data)).toEqual({ title: 't1' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -43,7 +43,8 @@ export default function<T extends Instruction>(
|
|||||||
'query',
|
'query',
|
||||||
'create',
|
'create',
|
||||||
'update',
|
'update',
|
||||||
'destroy'
|
'destroy',
|
||||||
|
'request'
|
||||||
].reduce((result, key) => Object.assign(result, {
|
].reduce((result, key) => Object.assign(result, {
|
||||||
[key]: requireModule(path.isAbsolute(key) ? key : path.join(__dirname, key))
|
[key]: requireModule(path.isAbsolute(key) ? key : path.join(__dirname, key))
|
||||||
}), {});
|
}), {});
|
||||||
|
95
packages/plugins/workflow/src/server/instructions/request.ts
Normal file
95
packages/plugins/workflow/src/server/instructions/request.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import { JOB_STATUS } from '../constants';
|
||||||
|
import { render } from 'ejs';
|
||||||
|
import axios, { AxiosRequestConfig } from 'axios';
|
||||||
|
import _ from 'lodash';
|
||||||
|
import { Instruction } from './index';
|
||||||
|
|
||||||
|
export interface Header {
|
||||||
|
name: string;
|
||||||
|
value: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RequestConfig = Pick<AxiosRequestConfig, 'url' | 'method' | 'data' | 'timeout'> & {
|
||||||
|
headers: Array<Header>;
|
||||||
|
ignoreFail: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function renderData(templateStr: string, vars: any): Promise<string> {
|
||||||
|
if (_.isEmpty(templateStr)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return render(templateStr, vars, { async: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerResume(plugin, job, status, result) {
|
||||||
|
job.set('status', status);
|
||||||
|
job.set('result', result);
|
||||||
|
return plugin.resume(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class implements Instruction {
|
||||||
|
async run(node, input, processor) {
|
||||||
|
const templateVars = {
|
||||||
|
node: processor.jobsMapByNodeId,
|
||||||
|
ctx: processor.execution.context,
|
||||||
|
};
|
||||||
|
const requestConfig = node.config as RequestConfig;
|
||||||
|
|
||||||
|
// default headers
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
const { headers: headerArr = [], ignoreFail = false, method = 'POST', timeout = 5000 } = requestConfig;
|
||||||
|
headerArr.forEach((header) => (headers[header.name] = header.value));
|
||||||
|
|
||||||
|
let url, data;
|
||||||
|
try {
|
||||||
|
url = await renderData(requestConfig.url, templateVars);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(e);
|
||||||
|
throw new Error(`ejs can't render url, please check url format!${(e as Error)?.message}`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
data = await renderData(requestConfig.data, templateVars);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(e);
|
||||||
|
throw new Error(`ejs can't render request data, please check request data format!${(e as Error)?.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const job = await processor.saveJob({
|
||||||
|
status: JOB_STATUS.PENDING,
|
||||||
|
nodeId: node.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const plugin = processor.options.plugin;
|
||||||
|
axios
|
||||||
|
.request({
|
||||||
|
method,
|
||||||
|
timeout,
|
||||||
|
headers,
|
||||||
|
url,
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
.then((resp) => {
|
||||||
|
if (resp.status >= 200 && resp.status < 300) {
|
||||||
|
triggerResume(plugin, job, JOB_STATUS.RESOLVED, resp.data);
|
||||||
|
} else {
|
||||||
|
triggerResume(plugin, job, JOB_STATUS.REJECTED, resp);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.warn(e);
|
||||||
|
triggerResume(plugin, job, JOB_STATUS.REJECTED, (e as Error)?.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
async resume(node, job, processor) {
|
||||||
|
const { ignoreFail } = node.config as RequestConfig;
|
||||||
|
if (ignoreFail) {
|
||||||
|
job.set('status', JOB_STATUS.RESOLVED);
|
||||||
|
}
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
}
|
60
yarn.lock
60
yarn.lock
@ -5212,6 +5212,11 @@
|
|||||||
resolved "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-2.0.1.tgz#ffb6620d290624f3726aa362c0c8a4b44c8d7200"
|
resolved "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-2.0.1.tgz#ffb6620d290624f3726aa362c0c8a4b44c8d7200"
|
||||||
integrity sha512-TF8aoF5cHcLO7W7403blM7L1T+6NF3XMyN3fxyUolq2uOcFeicG/khQg/dGxiCJWoAcmYulYN7LYSRKO54IXaA==
|
integrity sha512-TF8aoF5cHcLO7W7403blM7L1T+6NF3XMyN3fxyUolq2uOcFeicG/khQg/dGxiCJWoAcmYulYN7LYSRKO54IXaA==
|
||||||
|
|
||||||
|
"@types/ejs@^3.1.1":
|
||||||
|
version "3.1.1"
|
||||||
|
resolved "https://registry.npmmirror.com/@types/ejs/-/ejs-3.1.1.tgz#29c539826376a65e7f7d672d51301f37ed718f6d"
|
||||||
|
integrity sha512-RQul5wEfY7BjWm0sYY86cmUN/pcXWGyVxWX93DFFJvcrxax5zKlieLwA3T77xJGwNcZW0YW6CYG70p1m8xPFmA==
|
||||||
|
|
||||||
"@types/estree@*":
|
"@types/estree@*":
|
||||||
version "0.0.51"
|
version "0.0.51"
|
||||||
resolved "https://registry.npmjs.org/@types/estree/-/estree-0.0.51.tgz#cfd70924a25a3fd32b218e5e420e6897e1ac4f40"
|
resolved "https://registry.npmjs.org/@types/estree/-/estree-0.0.51.tgz#cfd70924a25a3fd32b218e5e420e6897e1ac4f40"
|
||||||
@ -7404,6 +7409,13 @@ brace-expansion@^1.1.7:
|
|||||||
balanced-match "^1.0.0"
|
balanced-match "^1.0.0"
|
||||||
concat-map "0.0.1"
|
concat-map "0.0.1"
|
||||||
|
|
||||||
|
brace-expansion@^2.0.1:
|
||||||
|
version "2.0.1"
|
||||||
|
resolved "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae"
|
||||||
|
integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==
|
||||||
|
dependencies:
|
||||||
|
balanced-match "^1.0.0"
|
||||||
|
|
||||||
braces@^2.3.1, braces@^2.3.2:
|
braces@^2.3.1, braces@^2.3.2:
|
||||||
version "2.3.2"
|
version "2.3.2"
|
||||||
resolved "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz#5979fd3f14cd531565e5fa2df1abfff1dfaee729"
|
resolved "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz#5979fd3f14cd531565e5fa2df1abfff1dfaee729"
|
||||||
@ -7902,7 +7914,7 @@ chalk@^1.1.1, chalk@^1.1.3, chalk@~1.1.3:
|
|||||||
strip-ansi "^3.0.0"
|
strip-ansi "^3.0.0"
|
||||||
supports-color "^2.0.0"
|
supports-color "^2.0.0"
|
||||||
|
|
||||||
chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.1:
|
chalk@^4.0.0, chalk@^4.0.2, chalk@^4.1.0, chalk@^4.1.1:
|
||||||
version "4.1.2"
|
version "4.1.2"
|
||||||
resolved "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
|
resolved "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
|
||||||
integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
|
integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
|
||||||
@ -8713,6 +8725,13 @@ copy-to-clipboard@^3.2.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
toggle-selection "^1.0.6"
|
toggle-selection "^1.0.6"
|
||||||
|
|
||||||
|
copy-to-clipboard@^3.3.1:
|
||||||
|
version "3.3.3"
|
||||||
|
resolved "https://registry.npmmirror.com/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz#55ac43a1db8ae639a4bd99511c148cdd1b83a1b0"
|
||||||
|
integrity sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==
|
||||||
|
dependencies:
|
||||||
|
toggle-selection "^1.0.6"
|
||||||
|
|
||||||
copy-to@^2.0.1:
|
copy-to@^2.0.1:
|
||||||
version "2.0.1"
|
version "2.0.1"
|
||||||
resolved "https://registry.npmjs.org/copy-to/-/copy-to-2.0.1.tgz#2680fbb8068a48d08656b6098092bdafc906f4a5"
|
resolved "https://registry.npmjs.org/copy-to/-/copy-to-2.0.1.tgz#2680fbb8068a48d08656b6098092bdafc906f4a5"
|
||||||
@ -9936,6 +9955,13 @@ ee-first@1.1.1, ee-first@~1.1.1:
|
|||||||
resolved "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
|
resolved "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
|
||||||
integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=
|
integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=
|
||||||
|
|
||||||
|
ejs@^3.1.8:
|
||||||
|
version "3.1.8"
|
||||||
|
resolved "https://registry.npmmirror.com/ejs/-/ejs-3.1.8.tgz#758d32910c78047585c7ef1f92f9ee041c1c190b"
|
||||||
|
integrity sha512-/sXZeMlhS0ArkfX2Aw780gJzXSMPnKjtspYZv+f3NiKLlubezAHDU5+9xz6gd3/NhG3txQCo6xlglmTS+oTGEQ==
|
||||||
|
dependencies:
|
||||||
|
jake "^10.8.5"
|
||||||
|
|
||||||
electron-to-chromium@^1.3.886:
|
electron-to-chromium@^1.3.886:
|
||||||
version "1.3.896"
|
version "1.3.896"
|
||||||
resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.896.tgz#4a94efe4870b1687eafd5c378198a49da06e8a1b"
|
resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.896.tgz#4a94efe4870b1687eafd5c378198a49da06e8a1b"
|
||||||
@ -10930,6 +10956,13 @@ file-uri-to-path@2:
|
|||||||
resolved "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-2.0.0.tgz#7b415aeba227d575851e0a5b0c640d7656403fba"
|
resolved "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-2.0.0.tgz#7b415aeba227d575851e0a5b0c640d7656403fba"
|
||||||
integrity sha512-hjPFI8oE/2iQPVe4gbrJ73Pp+Xfub2+WI2LlXDbsaJBwT5wuMh35WNWVYYTpnz895shtwfyutMFLFywpQAFdLg==
|
integrity sha512-hjPFI8oE/2iQPVe4gbrJ73Pp+Xfub2+WI2LlXDbsaJBwT5wuMh35WNWVYYTpnz895shtwfyutMFLFywpQAFdLg==
|
||||||
|
|
||||||
|
filelist@^1.0.1:
|
||||||
|
version "1.0.4"
|
||||||
|
resolved "https://registry.npmmirror.com/filelist/-/filelist-1.0.4.tgz#f78978a1e944775ff9e62e744424f215e58352b5"
|
||||||
|
integrity sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==
|
||||||
|
dependencies:
|
||||||
|
minimatch "^5.0.1"
|
||||||
|
|
||||||
fill-range@^4.0.0:
|
fill-range@^4.0.0:
|
||||||
version "4.0.0"
|
version "4.0.0"
|
||||||
resolved "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7"
|
resolved "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7"
|
||||||
@ -13290,6 +13323,16 @@ istanbul-reports@^3.0.2:
|
|||||||
html-escaper "^2.0.0"
|
html-escaper "^2.0.0"
|
||||||
istanbul-lib-report "^3.0.0"
|
istanbul-lib-report "^3.0.0"
|
||||||
|
|
||||||
|
jake@^10.8.5:
|
||||||
|
version "10.8.5"
|
||||||
|
resolved "https://registry.npmmirror.com/jake/-/jake-10.8.5.tgz#f2183d2c59382cb274226034543b9c03b8164c46"
|
||||||
|
integrity sha512-sVpxYeuAhWt0OTWITwT98oyV0GsXyMlXCF+3L1SuafBVUIr/uILGRB+NqwkzhgXKvoJpDIpQvqkUALgdmQsQxw==
|
||||||
|
dependencies:
|
||||||
|
async "^3.2.3"
|
||||||
|
chalk "^4.0.2"
|
||||||
|
filelist "^1.0.1"
|
||||||
|
minimatch "^3.0.4"
|
||||||
|
|
||||||
javascript-natural-sort@^0.7.1:
|
javascript-natural-sort@^0.7.1:
|
||||||
version "0.7.1"
|
version "0.7.1"
|
||||||
resolved "https://registry.npmmirror.com/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz#f9e2303d4507f6d74355a73664d1440fb5a0ef59"
|
resolved "https://registry.npmmirror.com/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz#f9e2303d4507f6d74355a73664d1440fb5a0ef59"
|
||||||
@ -15718,6 +15761,13 @@ minimatch@^3.0.3, minimatch@^3.1.1, minimatch@^3.1.2:
|
|||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion "^1.1.7"
|
brace-expansion "^1.1.7"
|
||||||
|
|
||||||
|
minimatch@^5.0.1:
|
||||||
|
version "5.1.0"
|
||||||
|
resolved "https://registry.npmmirror.com/minimatch/-/minimatch-5.1.0.tgz#1717b464f4971b144f6aabe8f2d0b8e4511e09c7"
|
||||||
|
integrity sha512-9TPBGGak4nHfGZsPBohm9AWg6NoT7QTCehS3BIJABslyZbzxfV78QM2Y6+i741OPZIafFAaiiEMh5OyIrJPgtg==
|
||||||
|
dependencies:
|
||||||
|
brace-expansion "^2.0.1"
|
||||||
|
|
||||||
minimist-options@4.1.0:
|
minimist-options@4.1.0:
|
||||||
version "4.1.0"
|
version "4.1.0"
|
||||||
resolved "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz#c0655713c53a8a2ebd77ffa247d342c40f010619"
|
resolved "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz#c0655713c53a8a2ebd77ffa247d342c40f010619"
|
||||||
@ -19233,6 +19283,14 @@ react-contenteditable@^3.3.6:
|
|||||||
fast-deep-equal "^3.1.3"
|
fast-deep-equal "^3.1.3"
|
||||||
prop-types "^15.7.1"
|
prop-types "^15.7.1"
|
||||||
|
|
||||||
|
react-copy-to-clipboard@^5.1.0:
|
||||||
|
version "5.1.0"
|
||||||
|
resolved "https://registry.npmmirror.com/react-copy-to-clipboard/-/react-copy-to-clipboard-5.1.0.tgz#09aae5ec4c62750ccb2e6421a58725eabc41255c"
|
||||||
|
integrity sha512-k61RsNgAayIJNoy9yDsYzDe/yAZAzEbEgcz3DZMhF686LEyukcE1hzurxe85JandPUG+yTfGVFzuEw3xt8WP/A==
|
||||||
|
dependencies:
|
||||||
|
copy-to-clipboard "^3.3.1"
|
||||||
|
prop-types "^15.8.1"
|
||||||
|
|
||||||
react-docgen-typescript-dumi-tmp@^1.22.1-0:
|
react-docgen-typescript-dumi-tmp@^1.22.1-0:
|
||||||
version "1.22.1-0"
|
version "1.22.1-0"
|
||||||
resolved "https://registry.npmjs.org/react-docgen-typescript-dumi-tmp/-/react-docgen-typescript-dumi-tmp-1.22.1-0.tgz#6f452de05c5c114a6e1dd60b34930afaa7ae39a0"
|
resolved "https://registry.npmjs.org/react-docgen-typescript-dumi-tmp/-/react-docgen-typescript-dumi-tmp-1.22.1-0.tgz#6f452de05c5c114a6e1dd60b34930afaa7ae39a0"
|
||||||
|
Loading…
Reference in New Issue
Block a user