Allow variables with dots and keys (#17361)
Fixes https://github.com/twentyhq/private-issues/issues/410#issuecomment-3781085655 Currently, JSON keys with spaces like { "toto toto": 123 } are rejected with "JSON keys cannot contain spaces" error. This is problematic for HTTP requests and webhook triggers where users cannot control the response structure. We use Handlebars to eval variables, segment-literal bracket notation to escape keys with special characters: Normal: {{step.normalKey}} With spaces: `{{step.[key with space]}}` So we simply need to wrap segments with spaces with brackets. This PR: - Create shared path utilities to wrap the variable segments when needed - Use it in all variable generation places - Remove the restrictions This body is now supported: <img width="609" height="457" alt="Capture d’écran 2026-01-22 à 16 10 13" src="https://github.com/user-attachments/assets/e7653c0a-df1e-49af-9c9a-4b7d59a99726" />
This commit is contained in:
@@ -75,6 +75,12 @@ export { extractRawVariableNamePart } from './utils/extractRawVariableNameParts'
|
||||
export { getWorkflowRunContext } from './utils/getWorkflowRunContext';
|
||||
export { parseBooleanFromStringValue } from './utils/parseBooleanFromStringValue';
|
||||
export { parseDataFromContentType } from './utils/parseDataFromContentType';
|
||||
export {
|
||||
needsEscaping,
|
||||
escapePathSegment,
|
||||
joinVariablePath,
|
||||
parseVariablePath,
|
||||
} from './utils/variable-path.util';
|
||||
export type {
|
||||
LeafType,
|
||||
NodeType,
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import {
|
||||
escapePathSegment,
|
||||
joinVariablePath,
|
||||
needsEscaping,
|
||||
parseVariablePath,
|
||||
} from '../variable-path.util';
|
||||
|
||||
describe('variable path utility functions', () => {
|
||||
describe('needsEscaping', () => {
|
||||
it('should return true for keys with spaces', () => {
|
||||
expect(needsEscaping('key with space')).toBe(true);
|
||||
expect(needsEscaping('toto toto')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for keys with dots', () => {
|
||||
expect(needsEscaping('key.with.dots')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for keys with brackets', () => {
|
||||
expect(needsEscaping('key[0]')).toBe(true);
|
||||
expect(needsEscaping('[key]')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for simple keys', () => {
|
||||
expect(needsEscaping('simpleKey')).toBe(false);
|
||||
expect(needsEscaping('camelCase')).toBe(false);
|
||||
expect(needsEscaping('snake_case')).toBe(false);
|
||||
expect(needsEscaping('kebab-case')).toBe(false);
|
||||
});
|
||||
|
||||
describe('escapePathSegment', () => {
|
||||
it('should wrap keys with spaces in brackets', () => {
|
||||
expect(escapePathSegment('key with space')).toBe('[key with space]');
|
||||
});
|
||||
|
||||
it('should wrap keys with dots in brackets', () => {
|
||||
expect(escapePathSegment('key.with.dots')).toBe('[key.with.dots]');
|
||||
});
|
||||
|
||||
it('should not modify simple keys', () => {
|
||||
expect(escapePathSegment('simpleKey')).toBe('simpleKey');
|
||||
});
|
||||
});
|
||||
|
||||
describe('joinVariablePath', () => {
|
||||
it('should join simple segments with dots', () => {
|
||||
expect(joinVariablePath(['step', 'field', 'value'])).toBe(
|
||||
'step.field.value',
|
||||
);
|
||||
});
|
||||
|
||||
it('should escape segments with spaces', () => {
|
||||
expect(joinVariablePath(['step', 'key with space', 'value'])).toBe(
|
||||
'step.[key with space].value',
|
||||
);
|
||||
});
|
||||
|
||||
it('should escape segments with dots', () => {
|
||||
expect(joinVariablePath(['step', 'key.with.dots'])).toBe(
|
||||
'step.[key.with.dots]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle mixed simple and special segments', () => {
|
||||
expect(
|
||||
joinVariablePath(['step', 'normal', 'has space', 'another']),
|
||||
).toBe('step.normal.[has space].another');
|
||||
});
|
||||
|
||||
it('should handle empty array', () => {
|
||||
expect(joinVariablePath([])).toBe('');
|
||||
});
|
||||
|
||||
it('should handle single segment', () => {
|
||||
expect(joinVariablePath(['step'])).toBe('step');
|
||||
expect(joinVariablePath(['key with space'])).toBe('[key with space]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseVariablePath', () => {
|
||||
it('should parse simple dot-separated path', () => {
|
||||
expect(parseVariablePath('step.field.value')).toEqual([
|
||||
'step',
|
||||
'field',
|
||||
'value',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should parse path with bracketed segments containing spaces', () => {
|
||||
expect(parseVariablePath('step.[key with space].value')).toEqual([
|
||||
'step',
|
||||
'key with space',
|
||||
'value',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should parse path with bracketed segments containing dots', () => {
|
||||
expect(parseVariablePath('step.[key.with.dots]')).toEqual([
|
||||
'step',
|
||||
'key.with.dots',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle multiple bracketed segments', () => {
|
||||
expect(
|
||||
parseVariablePath('[first key].[second key].[third key]'),
|
||||
).toEqual(['first key', 'second key', 'third key']);
|
||||
});
|
||||
|
||||
it('should handle mixed simple and bracketed segments', () => {
|
||||
expect(parseVariablePath('step.normal.[has space].another')).toEqual([
|
||||
'step',
|
||||
'normal',
|
||||
'has space',
|
||||
'another',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
expect(parseVariablePath('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle single segment', () => {
|
||||
expect(parseVariablePath('step')).toEqual(['step']);
|
||||
expect(parseVariablePath('[key with space]')).toEqual([
|
||||
'key with space',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should be inverse of joinVariablePath', () => {
|
||||
const paths = [
|
||||
['step', 'field', 'value'],
|
||||
['step', 'key with space', 'value'],
|
||||
['step', 'key.with.dots'],
|
||||
['step', 'normal', 'has space', 'another'],
|
||||
];
|
||||
|
||||
for (const path of paths) {
|
||||
const joined = joinVariablePath(path);
|
||||
const parsed = parseVariablePath(joined);
|
||||
expect(parsed).toEqual(path);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
// Characters that require bracket escaping in variable paths
|
||||
// Spaces, dots, and brackets would break the dot-notation parsing
|
||||
const SPECIAL_CHARS_REGEX = /[\s.[]/;
|
||||
|
||||
export const needsEscaping = (key: string): boolean =>
|
||||
SPECIAL_CHARS_REGEX.test(key);
|
||||
|
||||
export const escapePathSegment = (segment: string): string =>
|
||||
needsEscaping(segment) ? `[${segment}]` : segment;
|
||||
|
||||
export const joinVariablePath = (segments: string[]): string =>
|
||||
segments.map(escapePathSegment).join('.');
|
||||
|
||||
/**
|
||||
* Parses a variable path string into segments, handling bracket notation.
|
||||
* Examples:
|
||||
* "step.normal.key" => ["step", "normal", "key"]
|
||||
* "step.[key with space].value" => ["step", "key with space", "value"]
|
||||
* "step.[key.with.dots]" => ["step", "key.with.dots"]
|
||||
*/
|
||||
export const parseVariablePath = (path: string): string[] => {
|
||||
const segments: string[] = [];
|
||||
let current = '';
|
||||
let inBracket = false;
|
||||
let segmentIndex = 0;
|
||||
|
||||
while (segmentIndex < path.length) {
|
||||
const char = path[segmentIndex];
|
||||
|
||||
if (char === '[' && !inBracket) {
|
||||
if (current.length > 0) {
|
||||
segments.push(current);
|
||||
current = '';
|
||||
}
|
||||
inBracket = true;
|
||||
segmentIndex++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === ']' && inBracket) {
|
||||
segments.push(current);
|
||||
current = '';
|
||||
inBracket = false;
|
||||
segmentIndex++;
|
||||
// Skip the following dot if present
|
||||
if (segmentIndex < path.length && path[segmentIndex] === '.') {
|
||||
segmentIndex++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '.' && !inBracket) {
|
||||
if (current.length > 0) {
|
||||
segments.push(current);
|
||||
current = '';
|
||||
}
|
||||
segmentIndex++;
|
||||
continue;
|
||||
}
|
||||
|
||||
current += char;
|
||||
segmentIndex++;
|
||||
}
|
||||
|
||||
if (current.length > 0) {
|
||||
segments.push(current);
|
||||
}
|
||||
|
||||
return segments;
|
||||
};
|
||||
Reference in New Issue
Block a user