Pretty format webhook payload example + unify expected body validation (#13034)

## Webhook Expected Body is automatically pretty formatted


https://github.com/user-attachments/assets/0ca7d621-0c6e-4bef-903f-859efd02f9cc

## Expected body fields can't contain spaces in keys' name


https://github.com/user-attachments/assets/b68d36a6-acd4-4ba2-a99a-5857e30c8582

Closes https://github.com/twentyhq/core-team-issues/issues/1117
This commit is contained in:
Baptiste Devessier
2025-07-04 11:45:14 +02:00
committed by GitHub
parent 92576aec0f
commit 43c3d114bb
5 changed files with 290 additions and 36 deletions

View File

@ -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',
},
},
});
});
});
});

View File

@ -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;
};

View File

@ -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 = ({
)}
<FormRawJsonFieldInput
label="Expected Body Output"
label="Expected Response Body"
placeholder={JSON_RESPONSE_PLACEHOLDER}
defaultValue={outputSchema}
onChange={handleOutputSchemaChange}

View File

@ -1,5 +1,7 @@
import { WorkflowHttpRequestAction } from '@/workflow/types/Workflow';
import { parseAndValidateVariableFriendlyStringifiedJson } from '@/workflow/utils/parseAndValidateVariableFriendlyStringifiedJson';
import { BaseOutputSchema } from '@/workflow/workflow-variables/types/StepOutputSchema';
import { isNonEmptyString } from '@sniptt/guards';
import { useState } from 'react';
import { convertOutputSchemaToJson } from '../utils/convertOutputSchemaToJson';
import { getHttpRequestOutputSchema } from '../utils/getHttpRequestOutputSchema';
@ -30,27 +32,29 @@ export const useHttpRequestOutputSchema = ({
const [error, setError] = useState<string | undefined>();
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 {

View File

@ -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 },
);