diff --git a/packages/twenty-front/src/modules/workflow/utils/__tests__/parseAndValidateVariableFriendlyStringifiedJson.test.ts b/packages/twenty-front/src/modules/workflow/utils/__tests__/parseAndValidateVariableFriendlyStringifiedJson.test.ts new file mode 100644 index 000000000..c67d85827 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/utils/__tests__/parseAndValidateVariableFriendlyStringifiedJson.test.ts @@ -0,0 +1,214 @@ +import { parseAndValidateVariableFriendlyStringifiedJson } from '../parseAndValidateVariableFriendlyStringifiedJson'; + +describe('parseAndValidateVariableFriendlyStringifiedJson', () => { + describe('Valid JSON with variable-friendly keys', () => { + it('should accept empty object', () => { + const result = parseAndValidateVariableFriendlyStringifiedJson('{}'); + + expect(result.isValid).toBe(true); + expect(result.data).toEqual({}); + }); + + it('should accept object with camelCase keys', () => { + const result = parseAndValidateVariableFriendlyStringifiedJson( + '{"camelCaseKey": "value", "anotherKey": 123}', + ); + + expect(result.isValid).toBe(true); + expect(result.data).toEqual({ + camelCaseKey: 'value', + anotherKey: 123, + }); + }); + + it('should accept object with snake_case keys', () => { + const result = parseAndValidateVariableFriendlyStringifiedJson( + '{"snake_case_key": "value", "another_key": true}', + ); + + expect(result.isValid).toBe(true); + expect(result.data).toEqual({ + snake_case_key: 'value', + another_key: true, + }); + }); + + it('should accept keys with dashes and special characters', () => { + const result = parseAndValidateVariableFriendlyStringifiedJson( + '{"key-with-dashes": "value", "key.with.dots": "another", "key$symbol": 42}', + ); + + expect(result.isValid).toBe(true); + expect(result.data).toEqual({ + 'key-with-dashes': 'value', + 'key.with.dots': 'another', + key$symbol: 42, + }); + }); + + it('should accept numeric keys', () => { + const result = parseAndValidateVariableFriendlyStringifiedJson( + '{"123": "numeric", "456key": "mixed"}', + ); + + expect(result.isValid).toBe(true); + expect(result.data).toEqual({ + '123': 'numeric', + '456key': 'mixed', + }); + }); + + it('should accept complex nested values', () => { + const result = parseAndValidateVariableFriendlyStringifiedJson( + '{"user": {"name": "John", "age": 30}, "items": [1, 2, 3]}', + ); + + expect(result.isValid).toBe(true); + expect(result.data).toEqual({ + user: { name: 'John', age: 30 }, + items: [1, 2, 3], + }); + }); + }); + + describe('Invalid keys with whitespace', () => { + it('should reject key with space', () => { + const result = parseAndValidateVariableFriendlyStringifiedJson( + '{"key with space": "value"}', + ); + + expect(result.isValid).toBe(false); + expect(result.error).toBe('JSON keys cannot contain spaces'); + }); + + it('should reject key with leading space', () => { + const result = parseAndValidateVariableFriendlyStringifiedJson( + '{" leadingSpace": "value"}', + ); + + expect(result.isValid).toBe(false); + expect(result.error).toBe('JSON keys cannot contain spaces'); + }); + + it('should reject key with trailing space', () => { + const result = parseAndValidateVariableFriendlyStringifiedJson( + '{"trailingSpace ": "value"}', + ); + + expect(result.isValid).toBe(false); + expect(result.error).toBe('JSON keys cannot contain spaces'); + }); + + it('should reject key with tab character', () => { + const result = parseAndValidateVariableFriendlyStringifiedJson( + '{"key\\twith\\ttab": "value"}', + ); + + expect(result.isValid).toBe(false); + expect(result.error).toBe('JSON keys cannot contain spaces'); + }); + + it('should reject when one of multiple keys has space', () => { + const result = parseAndValidateVariableFriendlyStringifiedJson( + '{"validKey": "value", "invalid key": "another"}', + ); + + expect(result.isValid).toBe(false); + expect(result.error).toBe('JSON keys cannot contain spaces'); + }); + }); + + describe('Malformed JSON', () => { + it('should reject invalid JSON syntax', () => { + const result = + parseAndValidateVariableFriendlyStringifiedJson('{"key": value}'); + + expect(result.isValid).toBe(false); + expect(result.error).toContain('Unexpected token'); + }); + + it('should reject unclosed object', () => { + const result = + parseAndValidateVariableFriendlyStringifiedJson('{"key": "value"'); + + expect(result.isValid).toBe(false); + expect(result.error).toContain( + "SyntaxError: Expected ',' or '}' after property value in JSON at position 15 (line 1 column 16)", + ); + }); + + it('should reject empty string', () => { + const result = parseAndValidateVariableFriendlyStringifiedJson(''); + + expect(result.isValid).toBe(false); + expect(result.error).toContain('Unexpected end of JSON input'); + }); + + it('should reject non-JSON string', () => { + const result = + parseAndValidateVariableFriendlyStringifiedJson('not json at all'); + + expect(result.isValid).toBe(false); + expect(result.error).toContain('Unexpected token'); + }); + }); + + describe('Non-object JSON values', () => { + it('should reject JSON array', () => { + const result = + parseAndValidateVariableFriendlyStringifiedJson('[1, 2, 3]'); + + expect(result.isValid).toBe(false); + }); + + it('should reject JSON string', () => { + const result = + parseAndValidateVariableFriendlyStringifiedJson('"just a string"'); + + expect(result.isValid).toBe(false); + }); + + it('should reject JSON number', () => { + const result = parseAndValidateVariableFriendlyStringifiedJson('42'); + + expect(result.isValid).toBe(false); + }); + + it('should reject JSON boolean', () => { + const result = parseAndValidateVariableFriendlyStringifiedJson('true'); + + expect(result.isValid).toBe(false); + }); + + it('should reject JSON null', () => { + const result = parseAndValidateVariableFriendlyStringifiedJson('null'); + + expect(result.isValid).toBe(false); + }); + }); + + describe('Edge cases', () => { + it('should accept empty string key', () => { + const result = + parseAndValidateVariableFriendlyStringifiedJson('{"": "value"}'); + + expect(result.isValid).toBe(true); + expect(result.data).toEqual({ '': 'value' }); + }); + + it('should handle deeply nested objects', () => { + const result = parseAndValidateVariableFriendlyStringifiedJson( + '{"level1": {"level2": {"level3": "deep"}}}', + ); + + expect(result.isValid).toBe(true); + expect(result.data).toEqual({ + level1: { + level2: { + level3: 'deep', + }, + }, + }); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/utils/parseAndValidateVariableFriendlyStringifiedJson.ts b/packages/twenty-front/src/modules/workflow/utils/parseAndValidateVariableFriendlyStringifiedJson.ts new file mode 100644 index 000000000..ae516ab1a --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/utils/parseAndValidateVariableFriendlyStringifiedJson.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; + +const schema = z + .record(z.any()) + .refine((data) => Object.keys(data).every((key) => !key.match(/\s/)), { + message: 'JSON keys cannot contain spaces', + }); + +export const parseAndValidateVariableFriendlyStringifiedJson = ( + expectedJson: string, +) => { + let value: unknown; + + try { + value = JSON.parse(expectedJson); + } catch (error) { + return { + isValid: false, + error: String(error), + } as const; + } + + const parsingResult = schema.safeParse(value); + + if (parsingResult.success) { + return { + isValid: true, + data: parsingResult.data, + } as const; + } + + return { + isValid: false, + error: parsingResult.error.issues[0].message, + } as const; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/WorkflowEditActionHttpRequest.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/WorkflowEditActionHttpRequest.tsx index 4af4dcbb6..b0bca41ac 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/WorkflowEditActionHttpRequest.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/components/WorkflowEditActionHttpRequest.tsx @@ -6,8 +6,10 @@ import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowS import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader'; import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader'; +import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth'; 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'; import { useEffect } from 'react'; import { useIcons } from 'twenty-ui/display'; import { @@ -18,8 +20,6 @@ import { useHttpRequestForm } from '../hooks/useHttpRequestForm'; import { useHttpRequestOutputSchema } from '../hooks/useHttpRequestOutputSchema'; import { BodyInput } from './BodyInput'; import { KeyValuePairInput } from './KeyValuePairInput'; -import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth'; -import { useTheme } from '@emotion/react'; type WorkflowEditActionHttpRequestProps = { action: WorkflowHttpRequestAction; @@ -109,7 +109,7 @@ export const WorkflowEditActionHttpRequest = ({ )} (); const handleOutputSchemaChange = (value: string | null) => { - if (value === null || value === '' || readonly === true) { - setError(undefined); + if (readonly === true) { return; } setOutputSchema(value); - try { - const parsedJson = JSON.parse(value); - const outputSchema = getHttpRequestOutputSchema(parsedJson); - onActionUpdate?.({ - ...action, - settings: { - ...action.settings, - outputSchema, - }, - }); - setError(undefined); - } catch (error) { - setError(String(error)); + const parsingResult = parseAndValidateVariableFriendlyStringifiedJson( + isNonEmptyString(value) ? value : '{}', + ); + + if (!parsingResult.isValid) { + setError(parsingResult.error); + return; } + + setError(undefined); + onActionUpdate?.({ + ...action, + settings: { + ...action.settings, + outputSchema: getHttpRequestOutputSchema(parsingResult.data), + }, + }); }; return { diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx b/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx index d058fa466..577d3ffc2 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx @@ -8,6 +8,7 @@ import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/Gene import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState'; import { WorkflowWebhookTrigger } from '@/workflow/types/Workflow'; +import { parseAndValidateVariableFriendlyStringifiedJson } from '@/workflow/utils/parseAndValidateVariableFriendlyStringifiedJson'; import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody'; import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader'; import { WEBHOOK_TRIGGER_AUTHENTICATION_OPTIONS } from '@/workflow/workflow-trigger/constants/WebhookTriggerAuthenticationOptions'; @@ -18,6 +19,7 @@ import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTri import { getWebhookTriggerDefaultSettings } from '@/workflow/workflow-trigger/utils/getWebhookTriggerDefaultSettings'; import { useTheme } from '@emotion/react'; import { useLingui } from '@lingui/react/macro'; +import { isNonEmptyString } from '@sniptt/guards'; import { useState } from 'react'; import { useRecoilValue } from 'recoil'; import { isDefined } from 'twenty-shared/utils'; @@ -152,28 +154,27 @@ export const WorkflowEditTriggerWebhookForm = ({ } onBlur={onBlur} readonly={triggerOptions.readonly} - defaultValue={JSON.stringify(trigger.settings.expectedBody)} + defaultValue={JSON.stringify( + trigger.settings.expectedBody, + null, + 2, + )} onChange={(newExpectedBody) => { if (triggerOptions.readonly === true) { return; } - let formattedExpectedBody = {}; - try { - formattedExpectedBody = JSON.parse( - newExpectedBody || '{}', - (key, value) => { - if (isDefined(key) && key.includes(' ')) { - throw new Error(t`JSON keys cannot contain spaces`); - } - return value; - }, + const parsingResult = + parseAndValidateVariableFriendlyStringifiedJson( + isNonEmptyString(newExpectedBody) ? newExpectedBody : '{}', ); - } catch (e) { + + if (!parsingResult.isValid) { setErrorMessages((prev) => ({ ...prev, - expectedBody: String(e), + expectedBody: parsingResult.error, })); + return; } @@ -182,18 +183,17 @@ export const WorkflowEditTriggerWebhookForm = ({ expectedBody: undefined, })); - const outputSchema = getFunctionOutputSchema( - formattedExpectedBody, - ); + const outputSchema = getFunctionOutputSchema(parsingResult.data); triggerOptions.onTriggerUpdate( { ...trigger, settings: { ...trigger.settings, - expectedBody: formattedExpectedBody, + httpMethod: 'POST', + expectedBody: parsingResult.data, outputSchema, - } as WorkflowWebhookTrigger['settings'], + } satisfies WorkflowWebhookTrigger['settings'], }, { computeOutputSchema: false }, );