Skip to content

Commit b7d110e

Browse files
authored
feat(next): DEVXP-2540: validate arrays (#165)
* feat(next): DEVXP-2540: validate arrays * remove unncessary isArrayValue * simplify contains validation * optimize uniqueness validation * add missing uniqueItems case to getErrorMessage
1 parent 5592a20 commit b7d110e

File tree

7 files changed

+487
-148
lines changed

7 files changed

+487
-148
lines changed

next/src/errors/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ export type SchemaValidationErrorType =
2525
* Date validation keywords
2626
*/
2727
| 'minDate' | 'maxDate'
28+
/**
29+
* Array validation keywords
30+
*/
31+
| 'minItems' | 'maxItems' | 'uniqueItems' | 'contains' | 'maxContains' | 'minContains'
2832

2933
export type ValidationErrorPath = Array<string | number>
3034

next/src/errors/messages.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,18 @@ export function getErrorMessage(
6464
return `The date must be ${schema['x-jsf-presentation']?.minDate} or after.`
6565
case 'maxDate':
6666
return `The date must be ${schema['x-jsf-presentation']?.maxDate} or before.`
67+
case 'minItems':
68+
throw new Error('Array support is not implemented yet')
69+
case 'maxItems':
70+
throw new Error('Array support is not implemented yet')
71+
case 'uniqueItems':
72+
throw new Error('Array support is not implemented yet')
73+
case 'contains':
74+
throw new Error('Array support is not implemented yet')
75+
case 'minContains':
76+
throw new Error('Array support is not implemented yet')
77+
case 'maxContains':
78+
throw new Error('Array support is not implemented yet')
6779
}
6880
}
6981

next/src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export type JsfSchemaType = Exclude<JSONSchema, boolean>['type']
88
/**
99
* Defines the type of a value in the form that will be validated against the schema.
1010
*/
11-
export type SchemaValue = string | number | ObjectValue | null | undefined | Array<SchemaValue>
11+
export type SchemaValue = string | number | ObjectValue | null | undefined | Array<SchemaValue> | boolean
1212

1313
/**
1414
* A nested object value.

next/src/validation/array.ts

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import type { ValidationError, ValidationErrorPath } from '../errors'
2+
import type { JsfSchema, NonBooleanJsfSchema, SchemaValue } from '../types'
3+
import { validateSchema, type ValidationOptions } from './schema'
4+
import { deepEqual } from './util'
5+
6+
/**
7+
* Validate an array against a schema
8+
* @param value - The value to validate
9+
* @param schema - The schema to validate against
10+
* @param options - The validation options
11+
* @param path - The path to the current field being validated
12+
* @returns An array of validation errors
13+
* @description
14+
* Validates the array against the schema while keeping track of the path to the array.
15+
* Each item in the array is validated with `validateSchema`.
16+
*/
17+
export function validateArray(
18+
value: SchemaValue,
19+
schema: JsfSchema,
20+
options: ValidationOptions,
21+
path: ValidationErrorPath,
22+
): ValidationError[] {
23+
if (!Array.isArray(value)) {
24+
return []
25+
}
26+
27+
return [
28+
...validateLength(schema, value, path),
29+
...validateUniqueItems(schema, value, path),
30+
...validateContains(value, schema, options, path),
31+
...validatePrefixItems(schema, value, options, path),
32+
...validateItems(schema, value, options, path),
33+
]
34+
}
35+
36+
/**
37+
* Validate the length constraint of an array
38+
* @param schema - The schema to validate against
39+
* @param value - The array value to validate
40+
* @param path - The path to the current field being validated
41+
* @returns An array of validation errors
42+
* @description
43+
* Validates the length constraint of an array.
44+
* If the `maxItems` keyword is defined, the array must contain at most `maxItems` items.
45+
* If the `minItems` keyword is defined, the array must contain at least `minItems` items.
46+
*/
47+
function validateLength(schema: NonBooleanJsfSchema, value: SchemaValue[], path: ValidationErrorPath): ValidationError[] {
48+
const errors: ValidationError[] = []
49+
50+
const itemsLength = value.length
51+
52+
if (schema.maxItems !== undefined && itemsLength > schema.maxItems) {
53+
errors.push({ path, validation: 'maxItems' })
54+
}
55+
56+
if (schema.minItems !== undefined && itemsLength < schema.minItems) {
57+
errors.push({ path, validation: 'minItems' })
58+
}
59+
60+
return errors
61+
}
62+
63+
/**
64+
* Validate the items constraint of an array
65+
* @param schema - The schema to validate against
66+
* @param values - The array value to validate
67+
* @param options - The validation options
68+
* @param path - The path to the current field being validated
69+
* @returns An array of validation errors
70+
* @description
71+
* Validates the items constraint of an array.
72+
* If the `items` keyword is defined, each item in the array must match the schema of the `items` keyword.
73+
* When the `prefixItems` keyword is defined, the items constraint is validated only for the items after the prefix items.
74+
*/
75+
function validateItems(schema: NonBooleanJsfSchema, values: SchemaValue[], options: ValidationOptions, path: ValidationErrorPath): ValidationError[] {
76+
if (schema.items === undefined) {
77+
return []
78+
}
79+
80+
const errors: ValidationError[] = []
81+
const startIndex = Array.isArray(schema.prefixItems) ? schema.prefixItems.length : 0
82+
83+
for (const [i, item] of values.slice(startIndex).entries()) {
84+
errors.push(...validateSchema(item, schema.items, options, [...path, 'items', i + startIndex]))
85+
}
86+
87+
return errors
88+
}
89+
90+
/**
91+
* Validate the prefixItems constraint of an array
92+
* @param schema - The schema to validate against
93+
* @param values - The array value to validate
94+
* @param options - The validation options
95+
* @param path - The path to the current field being validated
96+
* @returns An array of validation errors
97+
* @description
98+
* Validates the prefixItems constraint of an array.
99+
* If the `prefixItems` keyword is defined, each item in the array must match the schema of the corresponding prefix item.
100+
*/
101+
function validatePrefixItems(schema: NonBooleanJsfSchema, values: SchemaValue[], options: ValidationOptions, path: ValidationErrorPath): ValidationError[] {
102+
if (!Array.isArray(schema.prefixItems)) {
103+
return []
104+
}
105+
106+
const errors: ValidationError[] = []
107+
for (const [i, item] of values.entries()) {
108+
if (i < schema.prefixItems.length) {
109+
errors.push(...validateSchema(item, schema.prefixItems[i] as JsfSchema, options, [...path, 'prefixItems', i]))
110+
}
111+
}
112+
113+
return errors
114+
}
115+
116+
/**
117+
* Validate the contains, minContains, and maxContains constraints of an array
118+
* @param value - The array value to validate
119+
* @param schema - The schema to validate against
120+
* @param options - The validation options
121+
* @param path - The path to the current field being validated
122+
* @returns An array of validation errors
123+
* @description
124+
* - When the `contains` keyword is defined without `minContains` and `maxContains`, the array must contain at least one item that is valid against the `contains` schema.
125+
* - When the `contains` keyword is defined with `minContains` and `maxContains`, the array must contain a number of items that is between `minContains` and `maxContains` that are valid against the `contains` schema.
126+
*/
127+
function validateContains(
128+
value: SchemaValue[],
129+
schema: NonBooleanJsfSchema,
130+
options: ValidationOptions,
131+
path: ValidationErrorPath,
132+
): ValidationError[] {
133+
if (!('contains' in schema)) {
134+
return []
135+
}
136+
137+
const errors: ValidationError[] = []
138+
139+
// How many items in the array are valid against the contains schema?
140+
const contains = value.filter(item =>
141+
validateSchema(item, schema.contains as JsfSchema, options, [...path, 'contains']).length === 0,
142+
).length
143+
144+
if (schema.minContains === undefined && schema.maxContains === undefined) {
145+
if (contains < 1) {
146+
errors.push({ path, validation: 'contains' })
147+
}
148+
}
149+
else {
150+
if (schema.minContains !== undefined && contains < schema.minContains) {
151+
errors.push({ path, validation: 'minContains' })
152+
}
153+
154+
if (schema.maxContains !== undefined && contains > schema.maxContains) {
155+
errors.push({ path, validation: 'maxContains' })
156+
}
157+
}
158+
159+
return errors
160+
}
161+
162+
/**
163+
* Validate the uniqueItems constraint of an array
164+
* @param schema - The schema to validate against
165+
* @param values - The array value to validate
166+
* @param path - The path to the current field being validated
167+
* @returns An array of validation errors
168+
* @description
169+
* Validates the uniqueItems constraint of an array when the `uniqueItems` keyword is defined as `true`.
170+
*/
171+
function validateUniqueItems(schema: NonBooleanJsfSchema, values: SchemaValue[], path: ValidationErrorPath): ValidationError[] {
172+
if (schema.uniqueItems !== true) {
173+
return []
174+
}
175+
176+
const seen = new Map()
177+
178+
for (let i = 0; i < values.length; i++) {
179+
for (const prevItem of seen.values()) {
180+
if (deepEqual(values[i], prevItem)) {
181+
return [{ path, validation: 'uniqueItems' }]
182+
}
183+
}
184+
seen.set(i, values[i])
185+
}
186+
187+
return []
188+
}

next/src/validation/schema.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ValidationError, ValidationErrorPath } from '../errors'
22
import type { JsfSchema, JsfSchemaType, SchemaValue } from '../types'
3+
import { validateArray } from './array'
34
import { validateAllOf, validateAnyOf, validateNot, validateOneOf } from './composition'
45
import { validateCondition } from './conditions'
56
import { validateConst } from './const'
@@ -70,7 +71,11 @@ function validateType(
7071
return []
7172
}
7273

73-
const valueType = value === null ? 'null' : typeof value
74+
const valueType = value === null
75+
? 'null'
76+
: Array.isArray(value)
77+
? 'array'
78+
: typeof value
7479

7580
if (Array.isArray(schemaType)) {
7681
if (value === null && schemaType.includes('null')) {
@@ -96,8 +101,7 @@ function validateType(
96101
return []
97102
}
98103

99-
return [{ path, validation: 'type' },
100-
]
104+
return [{ path, validation: 'type' }]
101105
}
102106

103107
/**
@@ -181,6 +185,7 @@ export function validateSchema(
181185
...validateConst(value, schema, path),
182186
...validateEnum(value, schema, path),
183187
...validateObject(value, schema, options, path),
188+
...validateArray(value, schema, options, path),
184189
...validateString(value, schema, path),
185190
...validateNumber(value, schema, path),
186191
...validateNot(value, schema, options, path),

0 commit comments

Comments
 (0)