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:
Junyi 2024-05-09 16:11:58 +08:00 committed by GitHub
parent 556dfcd0e3
commit 893f27fead
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 203 additions and 32 deletions

View File

@ -385,7 +385,6 @@ export function TextArea(props) {
componentCls,
hashId,
css`
&.ant-input-group.ant-input-group-compact {
display: flex;
.ant-input {
flex-grow: 1;
@ -397,7 +396,6 @@ export function TextArea(props) {
border-color: #d9d9d9;
}
}
}
> .x-button {
height: min-content;

View File

@ -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,
};

View File

@ -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": "忽略失败的请求并继续工作流"
}

View File

@ -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),
}
: {}),
});
}

View File

@ -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({});