Store HTTP request json body as a string (#12931)

Variables can be used without surrounding quotes.

The formatting is also preserved for raw json.

We would have to do additional work if we want to add other types of
bodies, like form data.

## Demo


https://github.com/user-attachments/assets/498dd9c8-6654-4440-9ab0-35bad5e34ca8

Closes https://github.com/twentyhq/core-team-issues/issues/1129

Related to https://github.com/twentyhq/core-team-issues/issues/1117.
Doesn't solve the issue for webhooks but does for http body input.
This commit is contained in:
Baptiste Devessier
2025-06-30 15:34:21 +02:00
committed by GitHub
parent 8272e5dfd0
commit d4fe8efd7f
12 changed files with 304 additions and 41 deletions

View File

@ -126,6 +126,7 @@ export const workflowHttpRequestActionSettingsSchema =
z.array(z.union([z.string(), z.number(), z.boolean(), z.null()])), z.array(z.union([z.string(), z.number(), z.boolean(), z.null()])),
]), ]),
) )
.or(z.string())
.optional(), .optional(),
}), }),
}); });

View File

@ -7,11 +7,15 @@ import {
DEFAULT_JSON_BODY_PLACEHOLDER, DEFAULT_JSON_BODY_PLACEHOLDER,
HttpRequestBody, HttpRequestBody,
} from '@/workflow/workflow-steps/workflow-actions/http-request-action/constants/HttpRequest'; } from '@/workflow/workflow-steps/workflow-actions/http-request-action/constants/HttpRequest';
import { hasNonStringValues } from '@/workflow/workflow-steps/workflow-actions/http-request-action/utils/hasNonStringValues'; 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 { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { isString } from '@sniptt/guards';
import { useState } from 'react'; import { useState } from 'react';
import { parseJson } from 'twenty-shared/utils';
import { IconFileText, IconKey } from 'twenty-ui/display'; import { IconFileText, IconKey } from 'twenty-ui/display';
import { JsonValue } from 'type-fest';
import { KeyValuePairInput } from './KeyValuePairInput'; import { KeyValuePairInput } from './KeyValuePairInput';
const StyledContainer = styled.div` const StyledContainer = styled.div`
@ -26,8 +30,8 @@ const StyledSelectDropdown = styled(Select)`
type BodyInputProps = { type BodyInputProps = {
label?: string; label?: string;
defaultValue?: HttpRequestBody; defaultValue?: HttpRequestBody | string;
onChange: (value?: HttpRequestBody) => void; onChange: (value?: string) => void;
readonly?: boolean; readonly?: boolean;
}; };
@ -36,11 +40,17 @@ export const BodyInput = ({
onChange, onChange,
readonly, readonly,
}: BodyInputProps) => { }: BodyInputProps) => {
const [isRawJson, setIsRawJson] = useState<boolean>(() => const defaultValueParsed = isString(defaultValue)
hasNonStringValues(defaultValue), ? (parseJson<JsonValue>(defaultValue) ?? {})
: defaultValue;
const [isRawJson, setIsRawJson] = useState(
shouldDisplayRawJsonByDefault(defaultValue),
); );
const [jsonString, setJsonString] = useState<string | null>( const [jsonString, setJsonString] = useState<string | null>(
JSON.stringify(defaultValue, null, 2), isString(defaultValue)
? defaultValue
: JSON.stringify(defaultValue, null, 2),
); );
const [errorMessage, setErrorMessage] = useState<string | undefined>(); const [errorMessage, setErrorMessage] = useState<string | undefined>();
@ -51,7 +61,8 @@ export const BodyInput = ({
} }
try { try {
JSON.parse(value); parseHttpJsonBodyWithoutVariablesOrThrow(value);
setErrorMessage(undefined); setErrorMessage(undefined);
return true; return true;
} catch (e) { } catch (e) {
@ -61,7 +72,7 @@ export const BodyInput = ({
}; };
const handleKeyValueChange = (value: Record<string, string>) => { const handleKeyValueChange = (value: Record<string, string>) => {
onChange(value); onChange(JSON.stringify(value, null, 2));
setErrorMessage(undefined); setErrorMessage(undefined);
}; };
@ -75,8 +86,9 @@ export const BodyInput = ({
} }
try { try {
const parsed = JSON.parse(value); parseHttpJsonBodyWithoutVariablesOrThrow(value);
onChange(parsed);
onChange(value);
} catch { } catch {
// Do nothing, validation will happen on blur // Do nothing, validation will happen on blur
} }
@ -121,7 +133,7 @@ export const BodyInput = ({
/> />
) : ( ) : (
<KeyValuePairInput <KeyValuePairInput
defaultValue={defaultValue as Record<string, string>} defaultValue={defaultValueParsed as Record<string, string>}
onChange={handleKeyValueChange} onChange={handleKeyValueChange}
readonly={readonly} readonly={readonly}
keyPlaceholder="Property name" keyPlaceholder="Property name"

View File

@ -39,7 +39,7 @@ export type KeyValuePair = {
export type KeyValuePairInputProps = { export type KeyValuePairInputProps = {
label?: string; label?: string;
defaultValue?: Record<string, string>; defaultValue?: Record<string, string> | Array<string>;
onChange: (value: Record<string, string>) => void; onChange: (value: Record<string, string>) => void;
readonly?: boolean; readonly?: boolean;
keyPlaceholder?: string; keyPlaceholder?: string;

View File

@ -1,6 +1,6 @@
import { WorkflowHttpRequestAction } from '@/workflow/types/Workflow'; import { WorkflowHttpRequestAction } from '@/workflow/types/Workflow';
import { Meta, StoryObj } from '@storybook/react'; import { Meta, StoryObj } from '@storybook/react';
import { expect, fn, within } from '@storybook/test'; import { expect, fn, waitFor, within } from '@storybook/test';
import { ComponentDecorator } from 'twenty-ui/testing'; import { ComponentDecorator } from 'twenty-ui/testing';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator'; import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
@ -8,7 +8,10 @@ import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/Workflow
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator'; import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator'; import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks'; import { graphqlMocks } from '~/testing/graphqlMocks';
import { getWorkflowNodeIdMock } from '~/testing/mock-data/workflow'; import {
getWorkflowNodeIdMock,
MOCKED_STEP_ID,
} from '~/testing/mock-data/workflow';
import { WorkflowEditActionHttpRequest } from '../WorkflowEditActionHttpRequest'; import { WorkflowEditActionHttpRequest } from '../WorkflowEditActionHttpRequest';
const DEFAULT_ACTION: WorkflowHttpRequestAction = { const DEFAULT_ACTION: WorkflowHttpRequestAction = {
@ -149,3 +152,208 @@ export const ReadOnly: Story = {
expect(methodSelect).toBeVisible(); expect(methodSelect).toBeVisible();
}, },
}; };
export const WithArrayStringBody: Story = {
args: {
action: {
id: getWorkflowNodeIdMock(),
name: 'API Call with Array',
type: 'HTTP_REQUEST',
valid: true,
settings: {
input: {
url: 'https://api.example.com/tags',
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: `[
"frontend",
"backend",
"database"
]`,
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: {
value: false,
},
continueOnFailure: {
value: false,
},
},
},
} satisfies WorkflowHttpRequestAction,
actionOptions: {
onActionUpdate: fn(),
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText('Key/Value')).toBeVisible();
expect(await canvas.findByText('0')).toBeVisible();
expect(await canvas.findByText('1')).toBeVisible();
expect(await canvas.findByText('2')).toBeVisible();
expect(await canvas.findByText('frontend')).toBeVisible();
expect(await canvas.findByText('backend')).toBeVisible();
expect(await canvas.findByText('database')).toBeVisible();
},
};
export const WithObjectStringBody: Story = {
args: {
action: {
id: getWorkflowNodeIdMock(),
name: 'API Call with Array',
type: 'HTTP_REQUEST',
valid: true,
settings: {
input: {
url: 'https://api.example.com/tags',
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: `{
"hey": "frontend",
"oh": "backend",
"amazing": "database {{${MOCKED_STEP_ID}.salary}}"
}`,
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: {
value: false,
},
continueOnFailure: {
value: false,
},
},
},
} satisfies WorkflowHttpRequestAction,
actionOptions: {
onActionUpdate: fn(),
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText('Key/Value')).toBeVisible();
const textboxes = await waitFor(() => {
const elements = canvas.getAllByRole('textbox');
expect(elements.length).toBe(14);
return elements;
});
expect(textboxes[5]).toHaveTextContent('hey');
expect(textboxes[7]).toHaveTextContent('oh');
expect(textboxes[9]).toHaveTextContent('amazing');
expect(textboxes[6]).toHaveTextContent('frontend');
expect(textboxes[8]).toHaveTextContent('backend');
expect(textboxes[10]).toHaveTextContent('database Salary');
},
};
export const WithArrayContainingNonStringVariablesBody: Story = {
args: {
action: {
id: getWorkflowNodeIdMock(),
name: 'API Call with Array',
type: 'HTTP_REQUEST',
valid: true,
settings: {
input: {
url: 'https://api.example.com/tags',
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: `[
"frontend",
{{${MOCKED_STEP_ID}.salary}},
"database"
]`,
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: {
value: false,
},
continueOnFailure: {
value: false,
},
},
},
} satisfies WorkflowHttpRequestAction,
actionOptions: {
onActionUpdate: fn(),
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText('Raw JSON')).toBeVisible();
await waitFor(() => {
const textboxes = canvas.getAllByRole('textbox');
expect(textboxes[5]).toHaveTextContent(
'[ "frontend", Salary, "database"]',
);
});
},
};
export const WithObjectContainingNonStringVariablesBody: Story = {
args: {
action: {
id: getWorkflowNodeIdMock(),
name: 'API Call with Array',
type: 'HTTP_REQUEST',
valid: true,
settings: {
input: {
url: 'https://api.example.com/tags',
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: `{
"speciality": "frontend",
"salary": {{${MOCKED_STEP_ID}.salary}}
}`,
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: {
value: false,
},
continueOnFailure: {
value: false,
},
},
},
} satisfies WorkflowHttpRequestAction,
actionOptions: {
onActionUpdate: fn(),
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText('Raw JSON')).toBeVisible();
await waitFor(() => {
const textboxes = canvas.getAllByRole('textbox');
expect(textboxes[5]).toHaveTextContent(
'{ "speciality": "frontend", "salary": Salary}',
);
});
},
};

View File

@ -21,7 +21,7 @@ export type HttpRequestFormData = {
url: string; url: string;
method: HttpMethod; method: HttpMethod;
headers: Record<string, string>; headers: Record<string, string>;
body?: HttpRequestBody; body?: HttpRequestBody | string;
}; };
export const DEFAULT_JSON_BODY_PLACEHOLDER = export const DEFAULT_JSON_BODY_PLACEHOLDER =

View File

@ -2,21 +2,17 @@ import { HttpRequestBody } from '../../constants/HttpRequest';
import { hasNonStringValues } from '../hasNonStringValues'; import { hasNonStringValues } from '../hasNonStringValues';
describe('hasNonStringValues', () => { describe('hasNonStringValues', () => {
it('should return false for undefined input', () => { it('should return true for empty object', () => {
expect(hasNonStringValues(undefined)).toBe(false); expect(hasNonStringValues({})).toBe(true);
});
it('should return false for empty object', () => {
expect(hasNonStringValues({})).toBe(false);
}); });
it('should return false for object with only string values', () => { it('should return false for object with only string values', () => {
expect(hasNonStringValues({ key1: 'value1', key2: 'value2' })).toBe(false); expect(hasNonStringValues({ key1: 'value1', key2: 'value2' })).toBe(false);
}); });
it('should return false for object with null values', () => { it('should return true for object with null values', () => {
const body: HttpRequestBody = { key1: null, key2: 'value' }; const body: HttpRequestBody = { key1: null, key2: 'value' };
expect(hasNonStringValues(body)).toBe(false); expect(hasNonStringValues(body)).toBe(true);
}); });
it('should return true for object with number values', () => { it('should return true for object with number values', () => {

View File

@ -1,11 +1,12 @@
import { HttpRequestBody } from '../constants/HttpRequest'; import { isString } from '@sniptt/guards';
import { JsonArray, JsonObject } from 'type-fest';
export const hasNonStringValues = (obj?: HttpRequestBody): boolean => { export const hasNonStringValues = (obj: JsonObject | JsonArray): boolean => {
if (!obj) { const values = Object.values(obj);
return false;
if (values.length === 0) {
return true;
} }
return Object.values(obj).some(
(value) => return !values.every((value) => isString(value));
value !== null && value !== undefined && typeof value !== 'string',
);
}; };

View File

@ -0,0 +1,5 @@
import { removeVariablesFromJson } from '@/workflow/workflow-variables/utils/removeVariablesFromJson';
export const parseHttpJsonBodyWithoutVariablesOrThrow = (value: string) => {
return JSON.parse(removeVariablesFromJson(value));
};

View File

@ -0,0 +1,30 @@
import { HttpRequestBody } from '@/workflow/workflow-steps/workflow-actions/http-request-action/constants/HttpRequest';
import { hasNonStringValues } from '@/workflow/workflow-steps/workflow-actions/http-request-action/utils/hasNonStringValues';
import { removeVariablesFromJson } from '@/workflow/workflow-variables/utils/removeVariablesFromJson';
import { isObject, isString, isUndefined } from '@sniptt/guards';
import { parseJson } from 'twenty-shared/utils';
import { JsonValue } from 'type-fest';
export const shouldDisplayRawJsonByDefault = (
defaultValue: string | HttpRequestBody | undefined,
) => {
const defaultValueParsedWithoutVariables: JsonValue | undefined = isString(
defaultValue,
)
? (parseJson<JsonValue>(removeVariablesFromJson(defaultValue)) ?? {})
: defaultValue;
if (isUndefined(defaultValueParsedWithoutVariables)) {
return false;
}
return (
((isObject(defaultValueParsedWithoutVariables) ||
Array.isArray(defaultValueParsedWithoutVariables)) &&
hasNonStringValues(defaultValueParsedWithoutVariables)) ||
!(
isObject(defaultValueParsedWithoutVariables) ||
Array.isArray(defaultValueParsedWithoutVariables)
)
);
};

View File

@ -0,0 +1,5 @@
import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from '@/workflow/workflow-variables/constants/CaptureAllVariableTagInnerRegex';
export const removeVariablesFromJson = (json: string): string => {
return json.replaceAll(CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX, 'null');
};

View File

@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { isString } from '@sniptt/guards';
import axios, { AxiosRequestConfig } from 'axios'; import axios, { AxiosRequestConfig } from 'axios';
import { WorkflowExecutor } from 'src/modules/workflow/workflow-executor/interfaces/workflow-executor.interface'; import { WorkflowExecutor } from 'src/modules/workflow/workflow-executor/interfaces/workflow-executor.interface';
@ -52,7 +53,9 @@ export class HttpRequestWorkflowAction implements WorkflowExecutor {
}; };
if (['POST', 'PUT', 'PATCH'].includes(method) && body) { if (['POST', 'PUT', 'PATCH'].includes(method) && body) {
axiosConfig.data = body; const parsedBody = isString(body) ? JSON.parse(body) : body;
axiosConfig.data = parsedBody;
} }
const response = await axios(axiosConfig); const response = await axios(axiosConfig);

View File

@ -2,13 +2,15 @@ export type WorkflowHttpRequestActionInput = {
url: string; url: string;
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
headers?: Record<string, string>; headers?: Record<string, string>;
body?: Record< body?:
string, | Record<
| string string,
| number | string
| boolean | number
| null | boolean
| undefined | null
| Array<string | number | boolean | null> | undefined
>; | Array<string | number | boolean | null>
>
| string;
}; };