Skip to content

Commit

Permalink
add anyOf validation
Browse files Browse the repository at this point in the history
  • Loading branch information
dragidavid committed Feb 5, 2025
1 parent f19f2d4 commit ed27660
Show file tree
Hide file tree
Showing 4 changed files with 125 additions and 12 deletions.
1 change: 1 addition & 0 deletions next/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface ObjectValue {
*/
export type JsfSchema = JSONSchema & {
'properties'?: Record<string, JsfSchema>
'anyOf'?: JsfSchema[]
'x-jsf-logic'?: {
validations: Record<string, object>
computedValues: Record<string, object>
Expand Down
32 changes: 32 additions & 0 deletions next/src/validation/anyOf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { ValidationError } from '../form'
import type { JsfSchema, SchemaValue } from '../types'
import { validateSchema } from './schema'

/**
* Validate a value against the `anyOf` keyword in a schema
* @param value - The value to validate
* @param schema - The schema containing the `anyOf` keyword
* @returns An array of validation errors
* @description
* The function validates the value against each subschema in the `anyOf` array.
* It returns no errors as soon as one subschema validates successfully.
* If none of the subschemas validate, an error is returned.
*/
export function validateAnyOf(value: SchemaValue, schema: JsfSchema): ValidationError[] {
if (!schema.anyOf || !Array.isArray(schema.anyOf)) {
return []
}

for (const subSchema of schema.anyOf) {
const errors = validateSchema(value, subSchema)
if (errors.length === 0) {
return []
}
}

return [{
path: [],
validation: 'anyOf',
message: 'should match at least one schema',
}]
}
42 changes: 30 additions & 12 deletions next/src/validation/schema.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ValidationError } from '../form'
import type { JsfSchema, JsfSchemaType, SchemaValue } from '../types'
import type { ObjectValidationErrorType } from './object'
import { validateAnyOf } from './anyOf'
import { validateObject } from './object'
import { validateString } from './string'

Expand All @@ -21,28 +22,33 @@ export type SchemaValidationErrorType =
* The value fails validation due to object schema
*/
| ObjectValidationErrorType
/**
* The value fails validation against anyOf subschemas
*/
| 'anyOf'

/**
* Get the type of a schema
* @param schema - The schema to get the type of
* @returns The type of the schema, or an array of types if the schema is an array. Will fallback to 'object' if no type is defined.
* @example
* getType(false) // 'boolean'
* getType(true) // 'boolean'
* getType({ type: 'string' }) // 'string'
* getType({ type: ['string', 'number'] }) // ['string', 'number']
* getType({}) // 'object'
* @returns The type of the schema, or an array of types if the schema is an array.
* If no type is defined, returns undefined.
*
* IMPORTANT:
* We intentionally return 'undefined' (instead of defaulting to 'object') when no type is defined.
* In JSON Schema 2020-12, an absent "type" keyword means there is no type constraint.
* This change prevents erroneously enforcing a default type of 'object', which was causing false negatives
* (e.g. when validating an "anyOf" schema without a "type").
*/
export function getSchemaType(schema: JsfSchema): JsfSchemaType | JsfSchemaType[] {
export function getSchemaType(schema: JsfSchema): JsfSchemaType | JsfSchemaType[] | undefined {
if (typeof schema === 'boolean') {
return 'boolean'
}

if (schema.type) {
if (schema.type !== undefined) {
return schema.type
}

return 'object'
return undefined
}

/**
Expand All @@ -51,11 +57,16 @@ export function getSchemaType(schema: JsfSchema): JsfSchemaType | JsfSchemaType[
* @param schema - The schema to validate against
* @returns An array of validation errors
* @description
* - If the schema type is an array, the value must be an instance of one of the types in the array.
* - If the schema type is a string, the value must be of the same type.
* When getSchemaType returns undefined, this function skips type validation.
* This aligns with JSON Schema 2020-12 semantics: if no type is provided, no type check is enforced.
*/
function validateType(value: SchemaValue, schema: JsfSchema): ValidationError[] {
const schemaType = getSchemaType(schema)
// Skip type-checking if no type is specified.
if (schemaType === undefined) {
return []
}

const valueType = value === undefined ? 'undefined' : typeof value

const hasTypeMismatch = Array.isArray(schemaType)
Expand Down Expand Up @@ -112,5 +123,12 @@ export function validateSchema(value: SchemaValue, schema: JsfSchema, required:
...validateString(value, schema),
]

if (schema.anyOf && Array.isArray(schema.anyOf)) {
const anyOfErrors = validateAnyOf(value, schema)
if (anyOfErrors.length > 0) {
return anyOfErrors
}
}

return errors
}
62 changes: 62 additions & 0 deletions next/test/validation/anyOf.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, expect, it } from '@jest/globals'
import { createHeadlessForm } from '../../src'

describe('anyOf validation', () => {
it('returns no errors if the value matches at least one subschema in anyOf (top-level)', () => {
const schema = {
anyOf: [
{ type: 'string', minLength: 5 },
{ type: 'number' },
],
}
const form = createHeadlessForm(schema)

// Test with a string that meets the minLength requirement
expect(form.handleValidation('hello world')).not.toHaveProperty('formErrors')

// Test with a number
expect(form.handleValidation(42)).not.toHaveProperty('formErrors')
})

it('returns an error if the value does not match any subschema in anyOf (top-level)', () => {
const schema = {
anyOf: [
{ type: 'string', pattern: '^[a-z]+$' },
{ type: 'string', minLength: 5 },
],
}
const form = createHeadlessForm(schema)

// "123" does not match the pattern nor does it meet the minLength requirement.
expect(form.handleValidation('123')).toEqual({
formErrors: { '': 'should match at least one schema' },
})
})

it('validates nested anyOf in an object property', () => {
const schema = {
type: 'object',
properties: {
value: {
anyOf: [
{ type: 'string', pattern: '^[0-9]+$' },
{ type: 'number' },
],
},
},
}

const form = createHeadlessForm(schema)

// Test with a valid number value
expect(form.handleValidation({ value: 123 })).not.toHaveProperty('formErrors')

// Test with a valid string matching the pattern
expect(form.handleValidation({ value: '456' })).not.toHaveProperty('formErrors')

// Test with an invalid string value; the error path will be prefixed by the property key.
expect(form.handleValidation({ value: 'abc' })).toEqual({
formErrors: { '.value': 'should match at least one schema' },
})
})
})

0 comments on commit ed27660

Please sign in to comment.