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 { 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}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 },
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user