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 15460be41..5ba7dc7e2 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 35255eebb..d574d3506 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 537ab3b40..3d668ac21 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 017dda41b..20f61e5d4 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 e591a6554..ef3d85e91 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 0265d8bb8..bc0a127ae 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 4c0af52b4..a34893ee5 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 000000000..39ba2a822 --- /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 000000000..95bd45af1 --- /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 000000000..3cd60ec75 --- /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 cf3aeb9da..181d03468 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 179443abd..078fb81a3 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; };