From 893f27fead571c052953417e5acb99608ee6f6da Mon Sep 17 00:00:00 2001 From: Junyi Date: Thu, 9 May 2024 16:11:58 +0800 Subject: [PATCH] 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 --- .../antd/variable/TextArea.tsx | 20 ++- .../src/client/RequestInstruction.tsx | 137 ++++++++++++++++-- .../src/locale/zh-CN.json | 3 +- .../src/server/RequestInstruction.ts | 25 +++- .../src/server/__tests__/instruction.test.ts | 50 ++++++- 5 files changed, 203 insertions(+), 32 deletions(-) diff --git a/packages/core/client/src/schema-component/antd/variable/TextArea.tsx b/packages/core/client/src/schema-component/antd/variable/TextArea.tsx index d5a663de16..92d95c0633 100644 --- a/packages/core/client/src/schema-component/antd/variable/TextArea.tsx +++ b/packages/core/client/src/schema-component/antd/variable/TextArea.tsx @@ -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; } } diff --git a/packages/plugins/@nocobase/plugin-workflow-request/src/client/RequestInstruction.tsx b/packages/plugins/@nocobase/plugin-workflow-request/src/client/RequestInstruction.tsx index 784da4a239..b6d28a49d6 100644 --- a/packages/plugins/@nocobase/plugin-workflow-request/src/client/RequestInstruction.tsx +++ b/packages/plugins/@nocobase/plugin-workflow-request/src/client/RequestInstruction.tsx @@ -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 ; +} 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, }; diff --git a/packages/plugins/@nocobase/plugin-workflow-request/src/locale/zh-CN.json b/packages/plugins/@nocobase/plugin-workflow-request/src/locale/zh-CN.json index 480125f88b..3568dae965 100644 --- a/packages/plugins/@nocobase/plugin-workflow-request/src/locale/zh-CN.json +++ b/packages/plugins/@nocobase/plugin-workflow-request/src/locale/zh-CN.json @@ -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": "忽略失败的请求并继续工作流" } diff --git a/packages/plugins/@nocobase/plugin-workflow-request/src/server/RequestInstruction.ts b/packages/plugins/@nocobase/plugin-workflow-request/src/server/RequestInstruction.ts index 07a627da32..64d1af3639 100644 --- a/packages/plugins/@nocobase/plugin-workflow-request/src/server/RequestInstruction.ts +++ b/packages/plugins/@nocobase/plugin-workflow-request/src/server/RequestInstruction.ts @@ -18,14 +18,29 @@ export interface Header { export type RequestConfig = Pick & { headers: Array
; + 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), + } + : {}), }); } diff --git a/packages/plugins/@nocobase/plugin-workflow-request/src/server/__tests__/instruction.test.ts b/packages/plugins/@nocobase/plugin-workflow-request/src/server/__tests__/instruction.test.ts index 52afd5f2f8..239c2cbd11 100644 --- a/packages/plugins/@nocobase/plugin-workflow-request/src/server/__tests__/instruction.test.ts +++ b/packages/plugins/@nocobase/plugin-workflow-request/src/server/__tests__/instruction.test.ts @@ -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({});