Skip to content

Commit d6d8b47

Browse files
fix(validator): primitive iml expression bypass (#18)
In case a field contains a primitive IML Expression, the primitive type validators for number, boolean and so on are currently mistakenly returning validation error, even when a valid-typed primitive value could be mapped into them. This is patch for such behavior. _There's a catch when someone would do:_ ``` const context = { first: 'fal', second: 'se' } ``` and then `{{context.first}}{{context.second}}` to get `false` as string which would be casted later, similarly with numbers, but that sounds too obscure to be significant at the moment. If we ever encounter a problem with the `isPrimitiveIMLExpression`, we can always make it more relaxed to the `containsIMLExpression` instead, but we want to be stricter when we can now. --------- Co-authored-by: Patrik Simek <[email protected]>
1 parent c75fddb commit d6d8b47

File tree

6 files changed

+99
-5
lines changed

6 files changed

+99
-5
lines changed

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@makehq/forman-schema",
3-
"version": "1.4.0",
3+
"version": "1.4.1",
44
"description": "Forman Schema Tools",
55
"license": "MIT",
66
"author": "Make",

src/utils.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,16 @@ export function containsIMLExpression(value: unknown): boolean {
4646
return value.indexOf('{{') > -1 && value.indexOf('}}') > -1;
4747
}
4848

49+
/**
50+
* Utility function to check if a value is a primitive IML expression.
51+
* @param value
52+
*/
53+
export function isPrimitiveIMLExpression(value: unknown): boolean {
54+
if (typeof value !== 'string') return false;
55+
// The last index of '{{' has to be at the start, meaning there's no following '{{' anywhere further, and the first closing '}}' has to be at the end
56+
return value.lastIndexOf('{{') === 0 && value.indexOf('}}') === value.length - 2;
57+
}
58+
4959
/**
5060
* Constants for API endpoints
5161
*/

src/validator.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,14 @@ import {
88
FormanValidationOptions,
99
FormanSchemaNested,
1010
} from './types';
11-
import { containsIMLExpression, FORMAN_VISUAL_TYPES, isObject, isOptionGroup, normalizeFormanFieldType } from './utils';
11+
import {
12+
containsIMLExpression,
13+
FORMAN_VISUAL_TYPES,
14+
isObject,
15+
isOptionGroup,
16+
isPrimitiveIMLExpression,
17+
normalizeFormanFieldType,
18+
} from './utils';
1219

1320
/**
1421
* Context for schema validation operations
@@ -223,7 +230,7 @@ async function validateFormanValue(
223230
let actualType: string = typeof value;
224231
if (actualType === 'object' && Array.isArray(value)) actualType = 'array';
225232

226-
if (expectedType && expectedType !== actualType) {
233+
if (expectedType && expectedType !== actualType && !isPrimitiveIMLExpression(value)) {
227234
return {
228235
valid: false,
229236
errors: [

test/utils.spec.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
isObject,
55
isOptionGroup,
66
containsIMLExpression,
7+
isPrimitiveIMLExpression,
78
normalizeFormanFieldType,
89
API_ENDPOINTS,
910
FORMAN_VISUAL_TYPES,
@@ -97,6 +98,53 @@ describe('Utils Functions', () => {
9798
});
9899
});
99100

101+
describe('isPrimitiveIMLExpression', () => {
102+
it('should return true for primitive IML expressions', () => {
103+
expect(isPrimitiveIMLExpression('{{variable}}')).toBe(true);
104+
expect(isPrimitiveIMLExpression('{{user.email}}')).toBe(true);
105+
expect(isPrimitiveIMLExpression('{{data.items[0].name}}')).toBe(true);
106+
});
107+
108+
it('should return false for strings with IML expressions but not primitive', () => {
109+
expect(isPrimitiveIMLExpression('Hello {{name}}')).toBe(false);
110+
expect(isPrimitiveIMLExpression('{{name}} welcome')).toBe(false);
111+
expect(isPrimitiveIMLExpression('Start {{middle}} end')).toBe(false);
112+
expect(isPrimitiveIMLExpression('prefix{{variable}}')).toBe(false);
113+
expect(isPrimitiveIMLExpression('{{variable}}suffix')).toBe(false);
114+
});
115+
116+
it('should return false for strings with multiple IML expressions', () => {
117+
expect(isPrimitiveIMLExpression('{{first}}{{second}}')).toBe(false);
118+
expect(isPrimitiveIMLExpression('{{first}} {{second}}')).toBe(false);
119+
expect(isPrimitiveIMLExpression('{{a}}{{b}}{{c}}')).toBe(false);
120+
});
121+
122+
it('should return false for strings without IML expressions', () => {
123+
expect(isPrimitiveIMLExpression('regular string')).toBe(false);
124+
expect(isPrimitiveIMLExpression('{ single brace }')).toBe(false);
125+
expect(isPrimitiveIMLExpression('{{ incomplete')).toBe(false);
126+
expect(isPrimitiveIMLExpression('incomplete }}')).toBe(false);
127+
expect(isPrimitiveIMLExpression('')).toBe(false);
128+
});
129+
130+
it('should return false for non-string values', () => {
131+
expect(isPrimitiveIMLExpression(123)).toBe(false);
132+
expect(isPrimitiveIMLExpression(null)).toBe(false);
133+
expect(isPrimitiveIMLExpression(undefined)).toBe(false);
134+
expect(isPrimitiveIMLExpression(true)).toBe(false);
135+
expect(isPrimitiveIMLExpression({})).toBe(false);
136+
expect(isPrimitiveIMLExpression([])).toBe(false);
137+
});
138+
139+
it('should return false for malformed IML expressions', () => {
140+
expect(isPrimitiveIMLExpression('{variable}')).toBe(false);
141+
expect(isPrimitiveIMLExpression('{{variable}')).toBe(false);
142+
expect(isPrimitiveIMLExpression('{{{variable}}}')).toBe(false);
143+
expect(isPrimitiveIMLExpression('{{varia{{ble}}')).toBe(false);
144+
expect(isPrimitiveIMLExpression('{{varia}}ble}}')).toBe(false);
145+
});
146+
});
147+
100148
describe('normalizeFormanFieldType', () => {
101149
it('should normalize account: prefixed types', () => {
102150
const field = {

test/validator-extended.spec.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,10 @@ describe('Forman Schema Extended Validation', () => {
314314
normalField: 'invalid-email',
315315
imlField: '{{user.email}}',
316316
mixedField: 'Hello {{user.name}}, welcome!',
317+
numericField: '{{1.id}}',
318+
booleanField: '{{2.isEmpty}}',
319+
conflatedNumericField: '{{object.firstHalf}}{{object.secondHalf}}',
320+
complexField: '{{user}}',
317321
};
318322

319323
const formanSchema = [
@@ -338,6 +342,26 @@ describe('Forman Schema Extended Validation', () => {
338342
min: 50,
339343
},
340344
},
345+
{
346+
name: 'numericField',
347+
type: 'number',
348+
},
349+
{
350+
name: 'booleanField',
351+
type: 'boolean',
352+
},
353+
{
354+
name: 'conflatedNumericField',
355+
type: 'number',
356+
},
357+
{
358+
name: 'complexField',
359+
type: 'collection',
360+
spec: [
361+
{ name: 'name', type: 'text' },
362+
{ name: 'email', type: 'email' },
363+
],
364+
},
341365
];
342366

343367
expect(await validateForman(formanValue, formanSchema)).toEqual({
@@ -348,6 +372,11 @@ describe('Forman Schema Extended Validation', () => {
348372
path: 'normalField',
349373
message: "Value doesn't match the pattern: ^[^@]+@[^@]+\\.[^@]+$",
350374
},
375+
{
376+
domain: 'default',
377+
message: "Expected type 'number', got type 'string'.",
378+
path: 'conflatedNumericField',
379+
},
351380
],
352381
});
353382
});

0 commit comments

Comments
 (0)