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:
+96
-22
@@ -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>
|
||||
|
||||
+46
-18
@@ -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}
|
||||
|
||||
+6
-3
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
+5
-5
@@ -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",
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const AUTO_SET_HEADER_KEYS = ['content-type'];
|
||||
+9
@@ -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,
|
||||
|
||||
+8
-12
@@ -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,
|
||||
|
||||
+74
-44
@@ -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;
|
||||
|
||||
+23
@@ -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;
|
||||
};
|
||||
+5
@@ -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,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);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user