mirror of
https://github.com/nocobase/nocobase
synced 2024-11-15 03:36:05 +00:00
feat(plugin-workflow-request): support "application/x-www-form-urlencoded" type (#4296)
* feat(plugin-workflow-request): support application/x-www-form-urlencoded type * fix(plugin-workflow-request): avoid nullable body
This commit is contained in:
parent
556dfcd0e3
commit
893f27fead
@ -385,17 +385,15 @@ export function TextArea(props) {
|
||||
componentCls,
|
||||
hashId,
|
||||
css`
|
||||
&.ant-input-group.ant-input-group-compact {
|
||||
display: flex;
|
||||
.ant-input {
|
||||
flex-grow: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
.ant-input-disabled {
|
||||
.ant-tag {
|
||||
color: #bfbfbf;
|
||||
border-color: #d9d9d9;
|
||||
}
|
||||
display: flex;
|
||||
.ant-input {
|
||||
flex-grow: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
.ant-input-disabled {
|
||||
.ant-tag {
|
||||
color: #bfbfbf;
|
||||
border-color: #d9d9d9;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -6,18 +6,109 @@
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { onFieldValueChange } from '@formily/core';
|
||||
import { uid } from '@formily/shared';
|
||||
import { useForm, useField, useFormEffects } from '@formily/react';
|
||||
import { ArrayItems } from '@formily/antd-v5';
|
||||
|
||||
import {
|
||||
Instruction,
|
||||
WorkflowVariableInput,
|
||||
WorkflowVariableJSON,
|
||||
WorkflowVariableTextArea,
|
||||
defaultFieldNames,
|
||||
} from '@nocobase/plugin-workflow/client';
|
||||
|
||||
import { NAMESPACE } from '../locale';
|
||||
import { SchemaComponent } from '@nocobase/client';
|
||||
|
||||
const BodySchema = {
|
||||
'application/json': {
|
||||
type: 'void',
|
||||
properties: {
|
||||
data: {
|
||||
type: 'object',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-decorator-props': {},
|
||||
'x-component': 'WorkflowVariableJSON',
|
||||
'x-component-props': {
|
||||
changeOnSelect: true,
|
||||
autoSize: {
|
||||
minRows: 10,
|
||||
},
|
||||
placeholder: `{{t("Input request data", { ns: "${NAMESPACE}" })}}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'application/x-www-form-urlencoded': {
|
||||
type: 'void',
|
||||
properties: {
|
||||
data: {
|
||||
type: 'array',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-decorator-props': {},
|
||||
'x-component': 'ArrayItems',
|
||||
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")}}`,
|
||||
},
|
||||
},
|
||||
value: {
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'WorkflowVariableTextArea',
|
||||
'x-component-props': {
|
||||
useTypedConstant: true,
|
||||
},
|
||||
},
|
||||
remove: {
|
||||
type: 'void',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'ArrayItems.Remove',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
properties: {
|
||||
add: {
|
||||
type: 'void',
|
||||
title: `{{t("Add key-value pairs", { ns: "${NAMESPACE}" })}}`,
|
||||
'x-component': 'ArrayItems.Addition',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function BodyComponent(props) {
|
||||
const f = useField();
|
||||
const { values, setValuesIn, clearFormGraph } = useForm();
|
||||
const { contentType } = values;
|
||||
const [schema, setSchema] = useState(BodySchema[contentType]);
|
||||
|
||||
useFormEffects(() => {
|
||||
onFieldValueChange('contentType', (field) => {
|
||||
clearFormGraph(`${f.address}.*`);
|
||||
setSchema({ ...BodySchema[field.value], name: uid() });
|
||||
setValuesIn('data', null);
|
||||
});
|
||||
});
|
||||
|
||||
return <SchemaComponent basePath={f.address} schema={schema} onlyRenderProperties />;
|
||||
}
|
||||
|
||||
export default class extends Instruction {
|
||||
title = `{{t("HTTP request", { ns: "${NAMESPACE}" })}}`;
|
||||
@ -56,12 +147,26 @@ export default class extends Instruction {
|
||||
placeholder: 'https://www.nocobase.com',
|
||||
},
|
||||
},
|
||||
contentType: {
|
||||
type: 'string',
|
||||
title: `{{t("Content-Type", { ns: "${NAMESPACE}" })}}`,
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Select',
|
||||
'x-component-props': {
|
||||
allowClear: false,
|
||||
},
|
||||
enum: [
|
||||
{ label: 'application/json', value: 'application/json' },
|
||||
{ label: 'application/x-www-form-urlencoded', value: 'application/x-www-form-urlencoded' },
|
||||
],
|
||||
default: 'application/json',
|
||||
},
|
||||
headers: {
|
||||
type: 'array',
|
||||
'x-component': 'ArrayItems',
|
||||
'x-decorator': 'FormItem',
|
||||
title: `{{t("Headers", { ns: "${NAMESPACE}" })}}`,
|
||||
description: `{{t('"Content-Type" only support "application/json", and no need to specify', { ns: "${NAMESPACE}" })}}`,
|
||||
description: `{{t('"Content-Type" will be ignored from headers.', { ns: "${NAMESPACE}" })}}`,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@ -80,7 +185,7 @@ export default class extends Instruction {
|
||||
value: {
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'WorkflowVariableInput',
|
||||
'x-component': 'WorkflowVariableTextArea',
|
||||
'x-component-props': {
|
||||
useTypedConstant: true,
|
||||
},
|
||||
@ -125,7 +230,7 @@ export default class extends Instruction {
|
||||
value: {
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'WorkflowVariableInput',
|
||||
'x-component': 'WorkflowVariableTextArea',
|
||||
'x-component-props': {
|
||||
useTypedConstant: true,
|
||||
},
|
||||
@ -148,19 +253,19 @@ export default class extends Instruction {
|
||||
},
|
||||
},
|
||||
data: {
|
||||
type: 'string',
|
||||
type: 'void',
|
||||
title: `{{t("Body", { ns: "${NAMESPACE}" })}}`,
|
||||
'x-decorator': 'FormItem',
|
||||
'x-decorator-props': {},
|
||||
'x-component': 'WorkflowVariableJSON',
|
||||
'x-component-props': {
|
||||
changeOnSelect: true,
|
||||
autoSize: {
|
||||
minRows: 10,
|
||||
},
|
||||
placeholder: `{{t("Input request data", { ns: "${NAMESPACE}" })}}`,
|
||||
},
|
||||
description: `{{t("Only support standard JSON data", { ns: "${NAMESPACE}" })}}`,
|
||||
'x-component': 'BodyComponent',
|
||||
// 'x-component-props': {
|
||||
// changeOnSelect: true,
|
||||
// autoSize: {
|
||||
// minRows: 10,
|
||||
// },
|
||||
// placeholder: `{{t("Input request data", { ns: "${NAMESPACE}" })}}`,
|
||||
// },
|
||||
// description: `{{t("Only support standard JSON data", { ns: "${NAMESPACE}" })}}`,
|
||||
},
|
||||
timeout: {
|
||||
type: 'number',
|
||||
@ -184,7 +289,7 @@ export default class extends Instruction {
|
||||
};
|
||||
components = {
|
||||
ArrayItems,
|
||||
WorkflowVariableInput,
|
||||
BodyComponent,
|
||||
WorkflowVariableTextArea,
|
||||
WorkflowVariableJSON,
|
||||
};
|
||||
|
@ -9,12 +9,13 @@
|
||||
"Add parameter": "添加参数",
|
||||
"Body": "请求体",
|
||||
"Use variable": "使用变量",
|
||||
"Add key-value pairs": "添加键值对",
|
||||
"Format": "格式化",
|
||||
"Insert": "插入",
|
||||
"Timeout config": "超时设置",
|
||||
"ms": "毫秒",
|
||||
"Input request data": "输入请求数据",
|
||||
"Only support standard JSON data": "仅支持标准 JSON 数据",
|
||||
"\"Content-Type\" only support \"application/json\", and no need to specify": "\"Content-Type\" 请求头仅支持 \"application/json\",无需填写",
|
||||
"\"Content-Type\" will be ignored from headers.": "请求头中配置的 \"Content-Type\" 将被忽略。",
|
||||
"Ignore failed request and continue workflow": "忽略失败的请求并继续工作流"
|
||||
}
|
||||
|
@ -18,14 +18,29 @@ export interface Header {
|
||||
|
||||
export type RequestConfig = Pick<AxiosRequestConfig, 'url' | 'method' | 'params' | 'data' | 'timeout'> & {
|
||||
headers: Array<Header>;
|
||||
contentType: string;
|
||||
ignoreFail: boolean;
|
||||
};
|
||||
|
||||
const ContentTypeTransformers = {
|
||||
'application/json'(data) {
|
||||
return data;
|
||||
},
|
||||
'application/x-www-form-urlencoded'(data: { name: string; value: string }[]) {
|
||||
return new URLSearchParams(
|
||||
data.filter(({ name, value }) => name && typeof value !== 'undefined').map(({ name, value }) => [name, value]),
|
||||
).toString();
|
||||
},
|
||||
};
|
||||
|
||||
async function request(config) {
|
||||
// default headers
|
||||
const { url, method = 'POST', data, timeout = 5000 } = config;
|
||||
const { url, method = 'POST', contentType = 'application/json', data, timeout = 5000 } = config;
|
||||
const headers = (config.headers ?? []).reduce((result, header) => {
|
||||
if (header.name.toLowerCase() === 'content-type') {
|
||||
// header.value = ['application/json', 'application/x-www-form-urlencoded'].includes(header.value)
|
||||
// ? header.value
|
||||
// : 'application/json';
|
||||
return result;
|
||||
}
|
||||
return Object.assign(result, { [header.name]: header.value });
|
||||
@ -36,15 +51,19 @@ async function request(config) {
|
||||
);
|
||||
|
||||
// TODO(feat): only support JSON type for now, should support others in future
|
||||
headers['Content-Type'] = 'application/json';
|
||||
headers['Content-Type'] = contentType;
|
||||
|
||||
return axios.request({
|
||||
url,
|
||||
method,
|
||||
headers,
|
||||
params,
|
||||
data,
|
||||
timeout,
|
||||
...(method.toLowerCase() !== 'get' && data != null
|
||||
? {
|
||||
data: ContentTypeTransformers[contentType](data),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -58,7 +58,7 @@ class MockAPI {
|
||||
await sleep(100);
|
||||
ctx.body = {
|
||||
meta: { title: ctx.query.title },
|
||||
data: { title: ctx.request.body['title'] },
|
||||
data: ctx.request.body,
|
||||
};
|
||||
}
|
||||
await next();
|
||||
@ -316,6 +316,54 @@ describe('workflow > instructions > request', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('contentType', () => {
|
||||
it('no contentType as "application/json"', async () => {
|
||||
const n1 = await workflow.createNode({
|
||||
type: 'request',
|
||||
config: {
|
||||
url: api.URL_DATA,
|
||||
method: 'POST',
|
||||
data: { a: '{{$context.data.title}}' },
|
||||
},
|
||||
});
|
||||
|
||||
await PostRepo.create({ values: { title: 't1' } });
|
||||
|
||||
await sleep(500);
|
||||
|
||||
const [execution] = await workflow.getExecutions();
|
||||
expect(execution.status).toEqual(EXECUTION_STATUS.RESOLVED);
|
||||
const [job] = await execution.getJobs();
|
||||
expect(job.status).toEqual(JOB_STATUS.RESOLVED);
|
||||
expect(job.result.data).toEqual({ a: 't1' });
|
||||
});
|
||||
|
||||
it('contentType as "application/x-www-form-urlencoded"', async () => {
|
||||
const n1 = await workflow.createNode({
|
||||
type: 'request',
|
||||
config: {
|
||||
url: api.URL_DATA,
|
||||
method: 'POST',
|
||||
data: [
|
||||
{ name: 'a', value: '{{$context.data.title}}' },
|
||||
{ name: 'a', value: '&=1' },
|
||||
],
|
||||
contentType: 'application/x-www-form-urlencoded',
|
||||
},
|
||||
});
|
||||
|
||||
await PostRepo.create({ values: { title: 't1' } });
|
||||
|
||||
await sleep(500);
|
||||
|
||||
const [execution] = await workflow.getExecutions();
|
||||
expect(execution.status).toEqual(EXECUTION_STATUS.RESOLVED);
|
||||
const [job] = await execution.getJobs();
|
||||
expect(job.status).toEqual(JOB_STATUS.RESOLVED);
|
||||
expect(job.result.data).toEqual({ a: ['t1', '&=1'] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('request db resource', () => {
|
||||
it('request db resource', async () => {
|
||||
const user = await db.getRepository('users').create({});
|
||||
|
Loading…
Reference in New Issue
Block a user