From d4fe8efd7fb1afb2f8f4c9b21aa8b167b5ad8998 Mon Sep 17 00:00:00 2001 From: Baptiste Devessier Date: Mon, 30 Jun 2025 15:34:21 +0200 Subject: [PATCH] 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. --- .../validation-schemas/workflowSchema.ts | 1 + .../components/BodyInput.tsx | 34 ++- .../components/KeyValuePairInput.tsx | 2 +- .../WorkflowEditActionHttpRequest.stories.tsx | 212 +++++++++++++++++- .../constants/HttpRequest.ts | 2 +- .../__tests__/hasNonStringValues.test.ts | 12 +- .../utils/hasNonStringValues.ts | 17 +- ...arseHttpJsonBodyWithoutVariablesOrThrow.ts | 5 + .../utils/shouldDisplayRawJsonByDefault.ts | 30 +++ .../utils/removeVariablesFromJson.ts | 5 + .../http-request.workflow-action.ts | 5 +- ...workflow-http-request-action-input.type.ts | 20 +- 12 files changed, 304 insertions(+), 41 deletions(-) create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/utils/parseHttpJsonBodyWithoutVariablesOrThrow.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/utils/shouldDisplayRawJsonByDefault.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-variables/utils/removeVariablesFromJson.ts diff --git a/packages/twenty-front/src/modules/workflow/validation-schemas/workflowSchema.ts b/packages/twenty-front/src/modules/workflow/validation-schemas/workflowSchema.ts index 15460be419..5ba7dc7e21 100644 --- a/packages/twenty-front/src/modules/workflow/validation-schemas/workflowSchema.ts +++ b/packages/twenty-front/src/modules/workflow/validation-schemas/workflowSchema.ts @@ -126,6 +126,7 @@ export const workflowHttpRequestActionSettingsSchema = z.array(z.union([z.string(), z.number(), z.boolean(), z.null()])), ]), ) + .or(z.string()) .optional(), }), }); diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/BodyInput.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/BodyInput.tsx index 35255eebb4..d574d35069 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/BodyInput.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/BodyInput.tsx @@ -7,11 +7,15 @@ import { DEFAULT_JSON_BODY_PLACEHOLDER, 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 { 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 { IconFileText, IconKey } from 'twenty-ui/display'; +import { JsonValue } from 'type-fest'; import { KeyValuePairInput } from './KeyValuePairInput'; const StyledContainer = styled.div` @@ -26,8 +30,8 @@ const StyledSelectDropdown = styled(Select)` type BodyInputProps = { label?: string; - defaultValue?: HttpRequestBody; - onChange: (value?: HttpRequestBody) => void; + defaultValue?: HttpRequestBody | string; + onChange: (value?: string) => void; readonly?: boolean; }; @@ -36,11 +40,17 @@ export const BodyInput = ({ onChange, readonly, }: BodyInputProps) => { - const [isRawJson, setIsRawJson] = useState(() => - hasNonStringValues(defaultValue), + const defaultValueParsed = isString(defaultValue) + ? (parseJson(defaultValue) ?? {}) + : defaultValue; + + const [isRawJson, setIsRawJson] = useState( + shouldDisplayRawJsonByDefault(defaultValue), ); const [jsonString, setJsonString] = useState( - JSON.stringify(defaultValue, null, 2), + isString(defaultValue) + ? defaultValue + : JSON.stringify(defaultValue, null, 2), ); const [errorMessage, setErrorMessage] = useState(); @@ -51,7 +61,8 @@ export const BodyInput = ({ } try { - JSON.parse(value); + parseHttpJsonBodyWithoutVariablesOrThrow(value); + setErrorMessage(undefined); return true; } catch (e) { @@ -61,7 +72,7 @@ export const BodyInput = ({ }; const handleKeyValueChange = (value: Record) => { - onChange(value); + onChange(JSON.stringify(value, null, 2)); setErrorMessage(undefined); }; @@ -75,8 +86,9 @@ export const BodyInput = ({ } try { - const parsed = JSON.parse(value); - onChange(parsed); + parseHttpJsonBodyWithoutVariablesOrThrow(value); + + onChange(value); } catch { // Do nothing, validation will happen on blur } @@ -121,7 +133,7 @@ export const BodyInput = ({ /> ) : ( } + defaultValue={defaultValueParsed as Record} onChange={handleKeyValueChange} readonly={readonly} keyPlaceholder="Property name" diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/KeyValuePairInput.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/KeyValuePairInput.tsx index 537ab3b408..3d668ac210 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/KeyValuePairInput.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/KeyValuePairInput.tsx @@ -39,7 +39,7 @@ export type KeyValuePair = { export type KeyValuePairInputProps = { label?: string; - defaultValue?: Record; + defaultValue?: Record | Array; onChange: (value: Record) => void; readonly?: boolean; keyPlaceholder?: string; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/__stories__/WorkflowEditActionHttpRequest.stories.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/__stories__/WorkflowEditActionHttpRequest.stories.tsx index 017dda41b4..20f61e5d48 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/__stories__/WorkflowEditActionHttpRequest.stories.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/__stories__/WorkflowEditActionHttpRequest.stories.tsx @@ -1,6 +1,6 @@ import { WorkflowHttpRequestAction } from '@/workflow/types/Workflow'; 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 { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator'; import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; @@ -8,7 +8,10 @@ import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/Workflow import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator'; import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator'; 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'; const DEFAULT_ACTION: WorkflowHttpRequestAction = { @@ -149,3 +152,208 @@ export const ReadOnly: Story = { 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}', + ); + }); + }, +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/constants/HttpRequest.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/constants/HttpRequest.ts index e591a65549..ef3d85e91a 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/constants/HttpRequest.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/constants/HttpRequest.ts @@ -21,7 +21,7 @@ export type HttpRequestFormData = { url: string; method: HttpMethod; headers: Record; - body?: HttpRequestBody; + body?: HttpRequestBody | string; }; export const DEFAULT_JSON_BODY_PLACEHOLDER = diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/utils/__tests__/hasNonStringValues.test.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/utils/__tests__/hasNonStringValues.test.ts index 0265d8bb81..bc0a127ae6 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/utils/__tests__/hasNonStringValues.test.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/utils/__tests__/hasNonStringValues.test.ts @@ -2,21 +2,17 @@ import { HttpRequestBody } from '../../constants/HttpRequest'; import { hasNonStringValues } from '../hasNonStringValues'; describe('hasNonStringValues', () => { - it('should return false for undefined input', () => { - expect(hasNonStringValues(undefined)).toBe(false); - }); - - it('should return false for empty object', () => { - expect(hasNonStringValues({})).toBe(false); + it('should return true for empty object', () => { + expect(hasNonStringValues({})).toBe(true); }); it('should return false for object with only string values', () => { 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' }; - expect(hasNonStringValues(body)).toBe(false); + expect(hasNonStringValues(body)).toBe(true); }); it('should return true for object with number values', () => { diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/utils/hasNonStringValues.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/utils/hasNonStringValues.ts index 4c0af52b44..a34893ee59 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/utils/hasNonStringValues.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/utils/hasNonStringValues.ts @@ -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 => { - if (!obj) { - return false; +export const hasNonStringValues = (obj: JsonObject | JsonArray): boolean => { + const values = Object.values(obj); + + if (values.length === 0) { + return true; } - return Object.values(obj).some( - (value) => - value !== null && value !== undefined && typeof value !== 'string', - ); + + return !values.every((value) => isString(value)); }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/utils/parseHttpJsonBodyWithoutVariablesOrThrow.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/utils/parseHttpJsonBodyWithoutVariablesOrThrow.ts new file mode 100644 index 0000000000..39ba2a8225 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/utils/parseHttpJsonBodyWithoutVariablesOrThrow.ts @@ -0,0 +1,5 @@ +import { removeVariablesFromJson } from '@/workflow/workflow-variables/utils/removeVariablesFromJson'; + +export const parseHttpJsonBodyWithoutVariablesOrThrow = (value: string) => { + return JSON.parse(removeVariablesFromJson(value)); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/utils/shouldDisplayRawJsonByDefault.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/utils/shouldDisplayRawJsonByDefault.ts new file mode 100644 index 0000000000..95bd45af14 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/utils/shouldDisplayRawJsonByDefault.ts @@ -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(removeVariablesFromJson(defaultValue)) ?? {}) + : defaultValue; + + if (isUndefined(defaultValueParsedWithoutVariables)) { + return false; + } + + return ( + ((isObject(defaultValueParsedWithoutVariables) || + Array.isArray(defaultValueParsedWithoutVariables)) && + hasNonStringValues(defaultValueParsedWithoutVariables)) || + !( + isObject(defaultValueParsedWithoutVariables) || + Array.isArray(defaultValueParsedWithoutVariables) + ) + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/removeVariablesFromJson.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/removeVariablesFromJson.ts new file mode 100644 index 0000000000..3cd60ec75e --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/removeVariablesFromJson.ts @@ -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'); +}; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/http-request.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/http-request.workflow-action.ts index cf3aeb9da7..181d03468a 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/http-request.workflow-action.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/http-request.workflow-action.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; +import { isString } from '@sniptt/guards'; import axios, { AxiosRequestConfig } from 'axios'; 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) { - axiosConfig.data = body; + const parsedBody = isString(body) ? JSON.parse(body) : body; + + axiosConfig.data = parsedBody; } const response = await axios(axiosConfig); diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/types/workflow-http-request-action-input.type.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/types/workflow-http-request-action-input.type.ts index 179443abd6..078fb81a3c 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/types/workflow-http-request-action-input.type.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/types/workflow-http-request-action-input.type.ts @@ -2,13 +2,15 @@ export type WorkflowHttpRequestActionInput = { url: string; method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; headers?: Record; - body?: Record< - string, - | string - | number - | boolean - | null - | undefined - | Array - >; + body?: + | Record< + string, + | string + | number + | boolean + | null + | undefined + | Array + > + | string; };