feat: add FormData, text, and none body types to HTTP request workflow (#14055)

This PR is a cleaned-up and improved version of #13982, excluding file
uploads for FormData.


https://github.com/user-attachments/assets/a85546f6-4e3b-4654-a4ea-e76a11016b96

---------

Co-authored-by: Thomas Trompette <thomas.trompette@sfr.fr>
This commit is contained in:
Naifer
2025-09-11 13:32:19 +01:00
committed by GitHub
parent d2ebd870df
commit 39326e6144
15 changed files with 371 additions and 148 deletions
@@ -1,19 +1,25 @@
import { FormFieldInputContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputContainer';
import { FormRawJsonFieldInput } from '@/object-record/record-field/ui/form-types/components/FormRawJsonFieldInput';
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { Select } from '@/ui/input/components/Select';
import {
BODY_TYPES,
DEFAULT_JSON_BODY_PLACEHOLDER,
type HttpRequestBody,
type HttpRequestFormData,
} from '@/workflow/workflow-steps/workflow-actions/http-request-action/constants/HttpRequest';
import { getBodyTypeFromHeaders } from '@/workflow/workflow-steps/workflow-actions/http-request-action/utils/getBodyTypeFromHeaders';
import { parseHttpJsonBodyWithoutVariablesOrThrow } from '@/workflow/workflow-steps/workflow-actions/http-request-action/utils/parseHttpJsonBodyWithoutVariablesOrThrow';
import { shouldDisplayRawJsonByDefault } from '@/workflow/workflow-steps/workflow-actions/http-request-action/utils/shouldDisplayRawJsonByDefault';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import styled from '@emotion/styled';
import { isString } from '@sniptt/guards';
import { useState } from 'react';
import { parseJson } from 'twenty-shared/utils';
import { isDefined, parseJson } from 'twenty-shared/utils';
import {
CONTENT_TYPE_VALUES_HTTP_REQUEST,
type BodyType,
} from 'twenty-shared/workflow';
import { IconFileText, IconKey } from 'twenty-ui/display';
import { type JsonValue } from 'type-fest';
import { KeyValuePairInput } from './KeyValuePairInput';
@@ -27,32 +33,55 @@ const StyledContainer = styled.div`
const StyledSelectDropdown = styled(Select)`
margin-bottom: ${({ theme }) => theme.spacing(2)};
`;
const StyledNoBodyMessage = styled.div`
font-size: ${({ theme }) => theme.font.size.md};
padding: ${({ theme }) => theme.spacing(2)};
text-align: left;
`;
type BodyInputProps = {
label?: string;
defaultValue?: HttpRequestBody | string;
onChange: (value?: string) => void;
onChange: (
value?: string | Record<string, string>,
type?: keyof HttpRequestFormData,
) => void;
readonly?: boolean;
headers?: Record<string, string>;
};
export const BodyInput = ({
defaultValue,
onChange,
readonly,
headers,
}: BodyInputProps) => {
const defaultValueParsed = isString(defaultValue)
? (parseJson<JsonValue>(defaultValue) ?? {})
: defaultValue;
const [isRawJson, setIsRawJson] = useState(
shouldDisplayRawJsonByDefault(defaultValue),
);
const isBodyTypeRawJson =
getBodyTypeFromHeaders(headers) === BODY_TYPES.RAW_JSON;
const [jsonString, setJsonString] = useState<string | null>(
isString(defaultValue)
? defaultValue
: JSON.stringify(defaultValue, null, 2),
isBodyTypeRawJson
? isString(defaultValue)
? defaultValue
: JSON.stringify(defaultValue, null, 2)
: null,
);
const [errorMessage, setErrorMessage] = useState<string | undefined>();
const [textValue, setTextValue] = useState<string | undefined>(
getBodyTypeFromHeaders(headers) === BODY_TYPES.TEXT &&
isString(defaultValue)
? defaultValue
: undefined,
);
const handleChangeTextValue = (text: string) => {
setTextValue(text);
onChange(text);
};
const validateJson = (value: string | null): boolean => {
if (!value?.trim()) {
@@ -93,15 +122,37 @@ export const BodyInput = ({
// Do nothing, validation will happen on blur
}
};
const handleModeChange = (isRawJson: boolean) => {
setIsRawJson(isRawJson);
onChange();
setJsonString(null);
const resetBodyStateValue = (bodyTypeValue: BodyType) => {
if (bodyTypeValue === BODY_TYPES.RAW_JSON && isDefined(jsonString)) {
setJsonString(null);
setErrorMessage(undefined);
} else if (bodyTypeValue === BODY_TYPES.TEXT && isDefined(textValue)) {
setTextValue(undefined);
}
};
const handleModeChange = (bodyTypeValue: BodyType) => {
if (
bodyTypeValue === BODY_TYPES.NONE &&
isDefined(headers?.['content-type'])
) {
const headersCopy = { ...headers };
delete headersCopy['content-type'];
onChange(headersCopy, 'headers');
} else if (
bodyTypeValue !== BODY_TYPES.NONE &&
getBodyTypeFromHeaders(headers) !== bodyTypeValue
) {
const newHeaders = {
...headers,
'content-type': CONTENT_TYPE_VALUES_HTTP_REQUEST[bodyTypeValue],
};
resetBodyStateValue(bodyTypeValue);
onChange(newHeaders, 'headers');
}
};
const handleBlur = () => {
if (isRawJson && Boolean(jsonString)) {
if (isBodyTypeRawJson) {
validateJson(jsonString);
}
};
@@ -111,17 +162,20 @@ export const BodyInput = ({
<InputLabel>Body Input</InputLabel>
<StyledSelectDropdown
options={[
{ label: 'Key/Value', value: 'keyValue', Icon: IconKey },
{ label: 'Raw JSON', value: 'rawJson', Icon: IconFileText },
{ label: 'Key/Value', value: BODY_TYPES.KEY_VALUE, Icon: IconKey },
{ label: 'Raw JSON', value: BODY_TYPES.RAW_JSON, Icon: IconFileText },
{ label: 'Form Data', value: BODY_TYPES.FORM_DATA, Icon: IconKey },
{ label: 'Text', value: BODY_TYPES.TEXT, Icon: IconFileText },
{ label: 'None', value: BODY_TYPES.NONE, Icon: IconFileText },
]}
dropdownId="body-input-mode"
value={isRawJson ? 'rawJson' : 'keyValue'}
onChange={(value) => handleModeChange(value === 'rawJson')}
value={getBodyTypeFromHeaders(headers) || BODY_TYPES.NONE}
onChange={(value) => handleModeChange(value as BodyType)}
disabled={readonly}
/>
<StyledContainer>
{isRawJson ? (
{isBodyTypeRawJson ? (
<FormRawJsonFieldInput
placeholder={DEFAULT_JSON_BODY_PLACEHOLDER}
readonly={readonly}
@@ -131,14 +185,34 @@ export const BodyInput = ({
onChange={handleJsonChange}
VariablePicker={WorkflowVariablePicker}
/>
) : (
) : getBodyTypeFromHeaders(headers) === BODY_TYPES.KEY_VALUE ? (
<KeyValuePairInput
key={'keyValuePair'}
defaultValue={defaultValueParsed as Record<string, string>}
onChange={handleKeyValueChange}
readonly={readonly}
keyPlaceholder="Property name"
valuePlaceholder="Property value"
/>
) : getBodyTypeFromHeaders(headers) === BODY_TYPES.FORM_DATA ? (
<KeyValuePairInput
key={'FormDataPair'}
defaultValue={defaultValueParsed as Record<string, string>}
onChange={handleKeyValueChange}
readonly={readonly}
keyPlaceholder="Property name"
valuePlaceholder="Property value"
/>
) : getBodyTypeFromHeaders(headers) === BODY_TYPES.TEXT ? (
<FormTextFieldInput
placeholder={'Enter text'}
readonly={readonly}
defaultValue={textValue}
onChange={(value: string) => handleChangeTextValue(value)}
VariablePicker={WorkflowVariablePicker}
/>
) : (
<StyledNoBodyMessage>No body</StyledNoBodyMessage>
)}
</StyledContainer>
</FormFieldInputContainer>
@@ -1,6 +1,8 @@
import { FormFieldInputContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputContainer';
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { AUTO_SET_HEADER_KEYS } from '@/workflow/workflow-steps/workflow-actions/http-request-action/constants/AutoSetHeaderKeys';
import { isAutoSetHeaderKey } from '@/workflow/workflow-steps/workflow-actions/http-request-action/utils/isReadOnlyHeaderKey';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
@@ -28,11 +30,11 @@ const StyledKeyValueContainer = styled.div<{ readonly: boolean | undefined }>`
grid-template-columns: repeat(2, minmax(0, 1fr)) ${theme.spacing(8)};
`};
`;
export type KeyValuePair = {
id: string;
key: string;
value: string;
isAutoSet: boolean;
};
export type KeyValuePairInputProps = {
@@ -46,23 +48,36 @@ export type KeyValuePairInputProps = {
export const KeyValuePairInput = ({
label,
defaultValue = {},
onChange,
readonly,
keyPlaceholder = 'Key',
valuePlaceholder = 'Value',
defaultValue,
}: KeyValuePairInputProps) => {
const [pairs, setPairs] = useState<KeyValuePair[]>(() => {
const initialPairs = Object.entries(defaultValue).map(([key, value]) => ({
id: v4(),
key,
value,
}));
const initialPairs = defaultValue
? Object.entries(defaultValue).map(([key, value]) => ({
id: v4(),
key,
value,
isAutoSet: isAutoSetHeaderKey(key),
}))
: [];
return initialPairs.length > 0
? [...initialPairs, { id: v4(), key: '', value: '' }]
: [{ id: v4(), key: '', value: '' }];
? [...initialPairs, { id: v4(), key: '', value: '', isAutoSet: false }]
: [{ id: v4(), key: '', value: '', isAutoSet: false }];
});
const getKeyValidationError = (pair: KeyValuePair): string | undefined => {
const trimmedKey = pair.key.trim();
if (!pair.isAutoSet && isAutoSetHeaderKey(trimmedKey)) {
return 'This key should be set through body input';
}
return undefined;
};
const handlePairChange = (
pairId: string,
field: 'key' | 'value',
@@ -70,6 +85,7 @@ export const KeyValuePairInput = ({
) => {
const index = pairs.findIndex((p) => p.id === pairId);
const newPairs = [...pairs];
newPairs[index] = { ...newPairs[index], [field]: newValue };
if (
@@ -77,15 +93,18 @@ export const KeyValuePairInput = ({
(field === 'key' || field === 'value') &&
Boolean(newValue.trim())
) {
newPairs.push({ id: v4(), key: '', value: '' });
newPairs.push({ id: v4(), key: '', value: '', isAutoSet: false });
}
setPairs(newPairs);
const record = newPairs.reduce(
(acc, { key, value }) => {
if (key.trim().length > 0) {
acc[key] = value;
(acc, pair) => {
if (!pair.isAutoSet && isAutoSetHeaderKey(pair.key)) {
return acc;
}
if (pair.key.trim().length > 0) {
acc[pair.key] = pair.value;
}
return acc;
},
@@ -97,8 +116,14 @@ export const KeyValuePairInput = ({
const handleRemovePair = (pairId: string) => {
const newPairs = pairs.filter((pair) => pair.id !== pairId);
if (newPairs.length === 0) {
newPairs.push({ id: v4(), key: '', value: '' });
if (
newPairs.length === 0 ||
(newPairs.length === AUTO_SET_HEADER_KEYS?.length &&
newPairs.every((pair) =>
AUTO_SET_HEADER_KEYS.includes(pair.key.trim()),
))
) {
newPairs.push({ id: v4(), key: '', value: '', isAutoSet: false });
}
setPairs(newPairs);
@@ -123,17 +148,18 @@ export const KeyValuePairInput = ({
<StyledKeyValueContainer key={pair.id} readonly={readonly}>
<FormTextFieldInput
placeholder={keyPlaceholder}
readonly={readonly}
readonly={readonly || pair.isAutoSet}
defaultValue={pair.key}
onChange={(value) =>
handlePairChange(pair.id, 'key', value ?? '')
}
VariablePicker={WorkflowVariablePicker}
error={getKeyValidationError(pair)}
/>
<FormTextFieldInput
placeholder={valuePlaceholder}
readonly={readonly}
readonly={readonly || pair.isAutoSet}
defaultValue={pair.value}
onChange={(value) =>
handlePairChange(pair.id, 'value', value ?? '')
@@ -141,7 +167,9 @@ export const KeyValuePairInput = ({
VariablePicker={WorkflowVariablePicker}
/>
{!readonly && pair.id !== pairs[pairs.length - 1].id ? (
{!readonly &&
pair.id !== pairs[pairs.length - 1].id &&
!pair.isAutoSet ? (
<Button
onClick={() => handleRemovePair(pair.id)}
Icon={IconTrash}
@@ -12,6 +12,7 @@ import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { WorkflowActionFooter } from '@/workflow/workflow-steps/components/WorkflowActionFooter';
import { getBodyTypeFromHeaders } from '@/workflow/workflow-steps/workflow-actions/http-request-action/utils/getBodyTypeFromHeaders';
import { isMethodWithBody } from '@/workflow/workflow-steps/workflow-actions/http-request-action/utils/isMethodWithBody';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { useTheme } from '@emotion/react';
@@ -105,14 +106,12 @@ export const WorkflowEditActionHttpRequest = ({
onActionUpdate: actionOptions.onActionUpdate,
readonly: actionOptions.readonly === true,
});
const { outputSchema, handleOutputSchemaChange, error } =
useHttpRequestOutputSchema({
action,
onActionUpdate: actionOptions.onActionUpdate,
readonly: actionOptions.readonly === true,
});
const { testHttpRequest, isTesting, httpRequestTestData } =
useTestHttpRequest(action.id);
@@ -177,6 +176,7 @@ export const WorkflowEditActionHttpRequest = ({
/>
<KeyValuePairInput
key={getBodyTypeFromHeaders(formData.headers) || 'none'}
label="Headers Input"
defaultValue={formData.headers}
onChange={(value) => handleFieldChange('headers', value)}
@@ -188,8 +188,11 @@ export const WorkflowEditActionHttpRequest = ({
{isMethodWithBody(formData.method) && (
<BodyInput
defaultValue={formData.body}
onChange={(value) => handleFieldChange('body', value)}
onChange={(value, type = 'body') =>
handleFieldChange(type, value)
}
readonly={actionOptions.readonly}
headers={formData.headers}
/>
)}
@@ -49,7 +49,7 @@ const CONFIGURED_ACTION: WorkflowHttpRequestAction = {
url: 'https://api.example.com/data',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'content-type': 'application/json',
Authorization: 'Bearer token123',
},
body: {
@@ -167,7 +167,7 @@ export const WithArrayStringBody: Story = {
url: 'https://api.example.com/tags',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'content-type': 'application/x-www-form-urlencoded',
},
body: `[
"frontend",
@@ -217,7 +217,7 @@ export const WithObjectStringBody: Story = {
url: 'https://api.example.com/tags',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'content-type': 'application/x-www-form-urlencoded',
},
body: `{
"hey": "frontend",
@@ -273,7 +273,7 @@ export const WithArrayContainingNonStringVariablesBody: Story = {
url: 'https://api.example.com/tags',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'content-type': 'application/json',
},
body: `[
"frontend",
@@ -323,7 +323,7 @@ export const WithObjectContainingNonStringVariablesBody: Story = {
url: 'https://api.example.com/tags',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'content-type': 'application/json',
},
body: `{
"speciality": "frontend",
@@ -0,0 +1 @@
export const AUTO_SET_HEADER_KEYS = ['content-type'];
@@ -4,6 +4,7 @@ const {
DEFAULT_JSON_BODY_PLACEHOLDER,
JSON_RESPONSE_PLACEHOLDER,
DEFAULT_HTTP_REQUEST_OUTPUT_VALUE,
BODY_TYPES,
} = {
HTTP_METHODS: [
{ label: 'GET', value: 'GET' },
@@ -25,6 +26,13 @@ const {
duration: undefined,
error: undefined,
},
BODY_TYPES: {
KEY_VALUE: 'keyValue',
RAW_JSON: 'rawJson',
FORM_DATA: 'formData',
TEXT: 'text',
NONE: 'none',
} as const,
};
export type HttpMethodWithBody = (typeof METHODS_WITH_BODY)[number];
@@ -41,6 +49,7 @@ export type HttpRequestFormData = {
};
export {
BODY_TYPES,
DEFAULT_HTTP_REQUEST_OUTPUT_VALUE,
DEFAULT_JSON_BODY_PLACEHOLDER,
HTTP_METHODS,
@@ -51,23 +51,19 @@ export const useHttpRequestForm = ({
let newFormData = { ...formData, [field]: value };
if (field === 'method' && !isMethodWithBody(value as string)) {
const headersCopy = { ...formData.headers };
delete headersCopy?.['content-type'];
newFormData = { ...newFormData, body: undefined, headers: headersCopy };
} else if (
field === 'headers' &&
typeof value === 'object' &&
formData.headers?.['content-type'] !== value?.['content-type']
) {
newFormData = { ...newFormData, body: undefined };
}
if (field === 'method' && isMethodWithBody(value as string)) {
newFormData = {
...newFormData,
headers: {
...newFormData.headers,
'content-type': 'application/json',
},
};
}
setFormData(newFormData);
saveAction(newFormData);
};
return {
formData,
handleFieldChange,
@@ -1,8 +1,12 @@
import { type HttpRequestFormData } from '@/workflow/workflow-steps/workflow-actions/http-request-action/constants/HttpRequest';
import { httpRequestTestDataFamilyState } from '@/workflow/workflow-steps/workflow-actions/http-request-action/states/httpRequestTestDataFamilyState';
import { type HttpRequestTestData } from '@/workflow/workflow-steps/workflow-actions/http-request-action/types/HttpRequestTestData';
import { isMethodWithBody } from '@/workflow/workflow-steps/workflow-actions/http-request-action/utils/isMethodWithBody';
import { useState } from 'react';
import { useRecoilState } from 'recoil';
import { resolveInput } from 'twenty-shared/utils';
import { parseDataFromContentType } from 'twenty-shared/workflow';
import { type HttpRequestBody } from '../constants/HttpRequest';
const convertFlatVariablesToNestedContext = (flatVariables: {
[variablePath: string]: any;
@@ -33,13 +37,60 @@ export const useTestHttpRequest = (actionId: string) => {
httpRequestTestDataFamilyState(actionId),
);
const callFetchRequest = async (
url: string,
headers: Record<string, string>,
method: string,
body?: HttpRequestBody | string,
): Promise<HttpRequestTestData['output']> => {
const requestOptions: RequestInit = {
method,
headers: headers,
};
if (isMethodWithBody(method) && body !== undefined) {
const contentType = headers['content-type'];
requestOptions.body = parseDataFromContentType(body, contentType);
if (contentType === 'multipart/form-data') {
const headersCopy = { ...headers };
delete headersCopy['content-type'];
requestOptions.headers = { ...headersCopy };
}
}
const response = await fetch(url as string, requestOptions);
let responseData: string;
const contentType = response.headers.get('content-type');
if (contentType !== null && contentType.includes('application/json')) {
const jsonData = await response.json();
responseData = JSON.stringify(jsonData, null, 2);
} else {
responseData = await response.text();
}
const responseHeaders: Record<string, string> = {};
response.headers.forEach((value, key) => {
responseHeaders[key] = value;
});
return {
data: responseData,
status: response.status,
statusText: response.statusText,
headers: responseHeaders,
error: undefined,
};
};
const testHttpRequest = async (
httpRequestFormData: HttpRequestFormData,
variableValues: { [variablePath: string]: any },
) => {
setIsTesting(true);
const startTime = Date.now();
try {
const nestedVariableContext =
convertFlatVariablesToNestedContext(variableValues);
@@ -51,58 +102,37 @@ export const useTestHttpRequest = (actionId: string) => {
httpRequestFormData.headers,
nestedVariableContext,
);
const substitutedBody = resolveInput(
const substitutedBodyRaw = resolveInput(
httpRequestFormData.body,
nestedVariableContext,
);
const requestOptions: RequestInit = {
method: httpRequestFormData.method,
headers: substitutedHeaders as Record<string, string>,
};
const substitutedBody: HttpRequestBody | string | undefined =
typeof substitutedBodyRaw === 'string' ||
(typeof substitutedBodyRaw === 'object' && substitutedBodyRaw !== null)
? substitutedBodyRaw
: undefined;
if (['POST', 'PUT', 'PATCH'].includes(httpRequestFormData.method)) {
if (substitutedBody !== undefined) {
if (typeof substitutedBody === 'string') {
requestOptions.body = substitutedBody;
} else {
requestOptions.body = JSON.stringify(substitutedBody);
}
}
}
const output = await callFetchRequest(
substitutedUrl as string,
substitutedHeaders as Record<string, string>,
httpRequestFormData.method,
substitutedBody,
);
const response = await fetch(substitutedUrl as string, requestOptions);
const contentType = output?.headers?.['content-type'];
const language = contentType?.includes('application/json')
? 'json'
: 'plaintext';
const duration = Date.now() - startTime;
let responseData: string;
const contentType = response.headers.get('content-type');
if (contentType !== null && contentType.includes('application/json')) {
const jsonData = await response.json();
responseData = JSON.stringify(jsonData, null, 2);
} else {
responseData = await response.text();
}
const responseHeaders: Record<string, string> = {};
response.headers.forEach((value, key) => {
responseHeaders[key] = value;
});
const outputWithDuration = {
...output,
duration,
};
setHttpRequestTestData((prev) => ({
...prev,
output: {
data: responseData,
status: response.status,
statusText: response.statusText,
headers: responseHeaders,
duration,
error: undefined,
},
language:
contentType !== null && contentType.includes('application/json')
? 'json'
: 'plaintext',
output: outputWithDuration,
language,
}));
} catch (error) {
const duration = Date.now() - startTime;
@@ -0,0 +1,23 @@
import { BODY_TYPES } from '@/workflow/workflow-steps/workflow-actions/http-request-action/constants/HttpRequest';
import { isDefined } from 'twenty-shared/utils';
import {
type BodyType,
CONTENT_TYPE_VALUES_HTTP_REQUEST,
} from 'twenty-shared/workflow';
export const getBodyTypeFromHeaders = (
headers?: Record<string, string>,
): BodyType | null => {
if (!isDefined(headers) || !isDefined(headers['content-type'])) {
return null;
}
const contentType = headers['content-type'];
const match = Object.entries(CONTENT_TYPE_VALUES_HTTP_REQUEST).find(
([bodyTypeKey, contentTypeVal]) =>
bodyTypeKey !== BODY_TYPES.NONE && contentType === contentTypeVal,
);
return isDefined(match) ? (match[0] as BodyType) : null;
};
@@ -0,0 +1,5 @@
import { AUTO_SET_HEADER_KEYS } from '../constants/AutoSetHeaderKeys';
export const isAutoSetHeaderKey = (key: string): boolean => {
return AUTO_SET_HEADER_KEYS.includes(key.trim().toLowerCase());
};
@@ -1,13 +1,14 @@
import { Injectable } from '@nestjs/common';
import axios, { type AxiosRequestConfig } from 'axios';
import { isDefined } from 'twenty-shared/utils';
import { parseDataFromContentType } from 'twenty-shared/workflow';
import { HttpToolParametersZodSchema } from 'src/engine/core-modules/tool/tools/http-tool/http-tool.schema';
import { type HttpRequestInput } from 'src/engine/core-modules/tool/tools/http-tool/types/http-request-input.type';
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
@Injectable()
export class HttpTool implements Tool {
description =
@@ -16,16 +17,23 @@ export class HttpTool implements Tool {
async execute(parameters: ToolInput): Promise<ToolOutput> {
const { url, method, headers, body } = parameters as HttpRequestInput;
const headersCopy = { ...headers };
const isMethodForBody = ['POST', 'PUT', 'PATCH'].includes(method);
try {
const axiosConfig: AxiosRequestConfig = {
url,
method: method,
headers,
headers: headersCopy,
};
if (['POST', 'PUT', 'PATCH'].includes(method) && body) {
axiosConfig.data = body;
if (isMethodForBody && body) {
const contentType = headers?.['content-type'];
axiosConfig.data = parseDataFromContentType(body, contentType);
if (isDefined(headersCopy) && contentType === 'multipart/form-data') {
delete headersCopy['content-type'];
}
}
const response = await axios(axiosConfig);
@@ -0,0 +1,9 @@
import type { BodyType } from '../types/workflowHttpRequestStep';
export const CONTENT_TYPE_VALUES_HTTP_REQUEST: Record<BodyType, string> = {
rawJson: 'application/json',
formData: 'multipart/form-data',
keyValue: 'application/x-www-form-urlencoded',
text: 'text/plain',
none: '',
};
+7 -40
View File
@@ -7,51 +7,18 @@
* |___/
*/
export { CONTENT_TYPE_VALUES_HTTP_REQUEST } from './constants/ContentTypeValuesHttpRequest';
export { TRIGGER_STEP_ID } from './constants/TriggerStepId';
export {
objectRecordSchema,
baseWorkflowActionSettingsSchema,
baseWorkflowActionSchema,
baseTriggerSchema,
workflowCodeActionSettingsSchema,
workflowSendEmailActionSettingsSchema,
workflowCreateRecordActionSettingsSchema,
workflowUpdateRecordActionSettingsSchema,
workflowDeleteRecordActionSettingsSchema,
workflowFindRecordsActionSettingsSchema,
workflowFormActionSettingsSchema,
workflowHttpRequestActionSettingsSchema,
workflowAiAgentActionSettingsSchema,
workflowFilterActionSettingsSchema,
workflowIteratorActionSettingsSchema,
workflowCodeActionSchema,
workflowSendEmailActionSchema,
workflowCreateRecordActionSchema,
workflowUpdateRecordActionSchema,
workflowDeleteRecordActionSchema,
workflowFindRecordsActionSchema,
workflowFormActionSchema,
workflowHttpRequestActionSchema,
workflowAiAgentActionSchema,
workflowFilterActionSchema,
workflowIteratorActionSchema,
workflowActionSchema,
workflowDatabaseEventTriggerSchema,
workflowManualTriggerSchema,
workflowCronTriggerSchema,
workflowWebhookTriggerSchema,
workflowTriggerSchema,
workflowRunStepStatusSchema,
workflowRunStateStepInfoSchema,
workflowRunStateStepInfosSchema,
workflowRunStateSchema,
workflowRunStatusSchema,
workflowRunSchema,
baseTriggerSchema, baseWorkflowActionSchema, baseWorkflowActionSettingsSchema, objectRecordSchema, workflowActionSchema, workflowAiAgentActionSchema, workflowAiAgentActionSettingsSchema, workflowCodeActionSchema, workflowCodeActionSettingsSchema, workflowCreateRecordActionSchema, workflowCreateRecordActionSettingsSchema, workflowCronTriggerSchema, workflowDatabaseEventTriggerSchema, workflowDeleteRecordActionSchema, workflowDeleteRecordActionSettingsSchema, workflowFilterActionSchema, workflowFilterActionSettingsSchema, workflowFindRecordsActionSchema, workflowFindRecordsActionSettingsSchema, workflowFormActionSchema, workflowFormActionSettingsSchema, workflowHttpRequestActionSchema, workflowHttpRequestActionSettingsSchema, workflowIteratorActionSchema, workflowIteratorActionSettingsSchema, workflowManualTriggerSchema, workflowRunSchema, workflowRunStateSchema, workflowRunStateStepInfoSchema,
workflowRunStateStepInfosSchema, workflowRunStatusSchema, workflowRunStepStatusSchema, workflowSendEmailActionSchema, workflowSendEmailActionSettingsSchema, workflowTriggerSchema, workflowUpdateRecordActionSchema, workflowUpdateRecordActionSettingsSchema, workflowWebhookTriggerSchema
} from './schemas/workflow.schema';
export type { BodyType } from './types/workflowHttpRequestStep';
export { StepStatus } from './types/WorkflowRunStateStepInfos';
export type {
WorkflowRunStepInfo,
WorkflowRunStepInfos,
WorkflowRunStepInfos
} from './types/WorkflowRunStateStepInfos';
export { StepStatus } from './types/WorkflowRunStateStepInfos';
export { canObjectBeManagedByWorkflow } from './utils/canObjectBeManagedByWorkflow';
export { getWorkflowRunContext } from './utils/getWorkflowRunContext';
export { parseDataFromContentType } from './utils/parseDataFromContentType';
@@ -0,0 +1 @@
export type BodyType = 'keyValue' | 'rawJson' | 'formData' | 'text' | 'none';
@@ -0,0 +1,69 @@
type InputData = Record<string, any> | string;
const parseUrlEncoded = (data: InputData): string => {
let parsedData = data;
if (typeof data === 'string') {
try {
parsedData = JSON.parse(data);
} catch {
parsedData = data;
}
}
return new URLSearchParams(parsedData).toString();
};
const parseFormData = (data: InputData): FormData => {
const form = new FormData();
if (typeof data === 'string') {
try {
const obj = JSON.parse(data);
Object.entries(obj).forEach(([key, val]) =>
form.append(key, String(val)),
);
} catch {
throw new Error('String data for FormData must be valid JSON');
}
} else {
Object.entries(data).forEach(([key, val]) => form.append(key, val));
}
return form;
};
const parseJson = (data: InputData): string => {
if (typeof data === 'string') {
return data;
}
return JSON.stringify(data);
};
const parseText = (data: InputData): string => {
if (typeof data === 'string') {
return data;
}
return Object.entries(data)
.map(([key, val]) => `${key}=${val}`)
.join('\n');
};
export const parseDataFromContentType = (
data: InputData,
contentType?: string,
) => {
if (contentType === undefined) {
return parseJson(data);
}
switch (contentType) {
case 'application/x-www-form-urlencoded':
return parseUrlEncoded(data);
case 'multipart/form-data':
return parseFormData(data);
case 'application/json':
return parseJson(data);
case 'text/plain':
return parseText(data);
default:
return parseJson(data);
}
};