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:
committed by
GitHub
parent
92576aec0f
commit
43c3d114bb
@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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;
|
||||||
|
};
|
||||||
@ -6,8 +6,10 @@ import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowS
|
|||||||
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
|
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
|
||||||
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
|
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 { isMethodWithBody } from '@/workflow/workflow-steps/workflow-actions/http-request-action/utils/isMethodWithBody';
|
||||||
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
|
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
|
||||||
|
import { useTheme } from '@emotion/react';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useIcons } from 'twenty-ui/display';
|
import { useIcons } from 'twenty-ui/display';
|
||||||
import {
|
import {
|
||||||
@ -18,8 +20,6 @@ import { useHttpRequestForm } from '../hooks/useHttpRequestForm';
|
|||||||
import { useHttpRequestOutputSchema } from '../hooks/useHttpRequestOutputSchema';
|
import { useHttpRequestOutputSchema } from '../hooks/useHttpRequestOutputSchema';
|
||||||
import { BodyInput } from './BodyInput';
|
import { BodyInput } from './BodyInput';
|
||||||
import { KeyValuePairInput } from './KeyValuePairInput';
|
import { KeyValuePairInput } from './KeyValuePairInput';
|
||||||
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
|
|
||||||
import { useTheme } from '@emotion/react';
|
|
||||||
|
|
||||||
type WorkflowEditActionHttpRequestProps = {
|
type WorkflowEditActionHttpRequestProps = {
|
||||||
action: WorkflowHttpRequestAction;
|
action: WorkflowHttpRequestAction;
|
||||||
@ -109,7 +109,7 @@ export const WorkflowEditActionHttpRequest = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<FormRawJsonFieldInput
|
<FormRawJsonFieldInput
|
||||||
label="Expected Body Output"
|
label="Expected Response Body"
|
||||||
placeholder={JSON_RESPONSE_PLACEHOLDER}
|
placeholder={JSON_RESPONSE_PLACEHOLDER}
|
||||||
defaultValue={outputSchema}
|
defaultValue={outputSchema}
|
||||||
onChange={handleOutputSchemaChange}
|
onChange={handleOutputSchemaChange}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { WorkflowHttpRequestAction } from '@/workflow/types/Workflow';
|
import { WorkflowHttpRequestAction } from '@/workflow/types/Workflow';
|
||||||
|
import { parseAndValidateVariableFriendlyStringifiedJson } from '@/workflow/utils/parseAndValidateVariableFriendlyStringifiedJson';
|
||||||
import { BaseOutputSchema } from '@/workflow/workflow-variables/types/StepOutputSchema';
|
import { BaseOutputSchema } from '@/workflow/workflow-variables/types/StepOutputSchema';
|
||||||
|
import { isNonEmptyString } from '@sniptt/guards';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { convertOutputSchemaToJson } from '../utils/convertOutputSchemaToJson';
|
import { convertOutputSchemaToJson } from '../utils/convertOutputSchemaToJson';
|
||||||
import { getHttpRequestOutputSchema } from '../utils/getHttpRequestOutputSchema';
|
import { getHttpRequestOutputSchema } from '../utils/getHttpRequestOutputSchema';
|
||||||
@ -30,27 +32,29 @@ export const useHttpRequestOutputSchema = ({
|
|||||||
const [error, setError] = useState<string | undefined>();
|
const [error, setError] = useState<string | undefined>();
|
||||||
|
|
||||||
const handleOutputSchemaChange = (value: string | null) => {
|
const handleOutputSchemaChange = (value: string | null) => {
|
||||||
if (value === null || value === '' || readonly === true) {
|
if (readonly === true) {
|
||||||
setError(undefined);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setOutputSchema(value);
|
setOutputSchema(value);
|
||||||
|
|
||||||
try {
|
const parsingResult = parseAndValidateVariableFriendlyStringifiedJson(
|
||||||
const parsedJson = JSON.parse(value);
|
isNonEmptyString(value) ? value : '{}',
|
||||||
const outputSchema = getHttpRequestOutputSchema(parsedJson);
|
);
|
||||||
onActionUpdate?.({
|
|
||||||
...action,
|
if (!parsingResult.isValid) {
|
||||||
settings: {
|
setError(parsingResult.error);
|
||||||
...action.settings,
|
return;
|
||||||
outputSchema,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
setError(undefined);
|
|
||||||
} catch (error) {
|
|
||||||
setError(String(error));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setError(undefined);
|
||||||
|
onActionUpdate?.({
|
||||||
|
...action,
|
||||||
|
settings: {
|
||||||
|
...action.settings,
|
||||||
|
outputSchema: getHttpRequestOutputSchema(parsingResult.data),
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/Gene
|
|||||||
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
||||||
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
|
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
|
||||||
import { WorkflowWebhookTrigger } from '@/workflow/types/Workflow';
|
import { WorkflowWebhookTrigger } from '@/workflow/types/Workflow';
|
||||||
|
import { parseAndValidateVariableFriendlyStringifiedJson } from '@/workflow/utils/parseAndValidateVariableFriendlyStringifiedJson';
|
||||||
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
|
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
|
||||||
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
|
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
|
||||||
import { WEBHOOK_TRIGGER_AUTHENTICATION_OPTIONS } from '@/workflow/workflow-trigger/constants/WebhookTriggerAuthenticationOptions';
|
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 { getWebhookTriggerDefaultSettings } from '@/workflow/workflow-trigger/utils/getWebhookTriggerDefaultSettings';
|
||||||
import { useTheme } from '@emotion/react';
|
import { useTheme } from '@emotion/react';
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
|
import { isNonEmptyString } from '@sniptt/guards';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRecoilValue } from 'recoil';
|
import { useRecoilValue } from 'recoil';
|
||||||
import { isDefined } from 'twenty-shared/utils';
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
@ -152,28 +154,27 @@ export const WorkflowEditTriggerWebhookForm = ({
|
|||||||
}
|
}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
readonly={triggerOptions.readonly}
|
readonly={triggerOptions.readonly}
|
||||||
defaultValue={JSON.stringify(trigger.settings.expectedBody)}
|
defaultValue={JSON.stringify(
|
||||||
|
trigger.settings.expectedBody,
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}
|
||||||
onChange={(newExpectedBody) => {
|
onChange={(newExpectedBody) => {
|
||||||
if (triggerOptions.readonly === true) {
|
if (triggerOptions.readonly === true) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let formattedExpectedBody = {};
|
const parsingResult =
|
||||||
try {
|
parseAndValidateVariableFriendlyStringifiedJson(
|
||||||
formattedExpectedBody = JSON.parse(
|
isNonEmptyString(newExpectedBody) ? newExpectedBody : '{}',
|
||||||
newExpectedBody || '{}',
|
|
||||||
(key, value) => {
|
|
||||||
if (isDefined(key) && key.includes(' ')) {
|
|
||||||
throw new Error(t`JSON keys cannot contain spaces`);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
} catch (e) {
|
|
||||||
|
if (!parsingResult.isValid) {
|
||||||
setErrorMessages((prev) => ({
|
setErrorMessages((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
expectedBody: String(e),
|
expectedBody: parsingResult.error,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -182,18 +183,17 @@ export const WorkflowEditTriggerWebhookForm = ({
|
|||||||
expectedBody: undefined,
|
expectedBody: undefined,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const outputSchema = getFunctionOutputSchema(
|
const outputSchema = getFunctionOutputSchema(parsingResult.data);
|
||||||
formattedExpectedBody,
|
|
||||||
);
|
|
||||||
|
|
||||||
triggerOptions.onTriggerUpdate(
|
triggerOptions.onTriggerUpdate(
|
||||||
{
|
{
|
||||||
...trigger,
|
...trigger,
|
||||||
settings: {
|
settings: {
|
||||||
...trigger.settings,
|
...trigger.settings,
|
||||||
expectedBody: formattedExpectedBody,
|
httpMethod: 'POST',
|
||||||
|
expectedBody: parsingResult.data,
|
||||||
outputSchema,
|
outputSchema,
|
||||||
} as WorkflowWebhookTrigger['settings'],
|
} satisfies WorkflowWebhookTrigger['settings'],
|
||||||
},
|
},
|
||||||
{ computeOutputSchema: false },
|
{ computeOutputSchema: false },
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user