From bb5d28c015eededdaf99d6ffa815b679bbfa1251 Mon Sep 17 00:00:00 2001 From: Luigi Bifulco Date: Wed, 7 Oct 2026 19:10:18 +0200 Subject: [PATCH] feat(core): add a pluggable Form Validator next to Ajv Adds the validation seam from issue #1498 as a non-breaking addition: the `validator` init/updateCore option takes a FormValidatorFactory `(schema) => FormValidator` or a FormValidator already bound to the schema. The types mirror the FormValidator / ValidationIssue of the 4.x presentation-model branch, restricted to synchronous results. - The core reducer creates the Form Validator through the factory whenever the schema changes and caches it in `state.formValidator`; `state.validator` keeps holding the compiled Ajv function when Ajv validates, so existing consumers are unaffected. - Issues from custom validators are converted to the Ajv error shape the rest of core and the renderers read (`issuesToErrors`): missing `key` becomes `custom`, `required` issues addressed to the missing property are split into parent path plus `params.missingProperty`, non-error severities are dropped and a missing `parentSchema` is resolved from the form schema. - `createAjvValidator(ajv?)` is the built-in adapter; `compiledAjvValidator(validateFn)` wraps Ajv standalone code for CSP setups without unsafe-eval. - `getValidator(state)` exposes the bound Form Validator. - Switching to NoValidation via setValidationMode now also drops the cached validator, as init and updateCore already did. Precedence: `validator` wins, otherwise `ajv`, otherwise the default Ajv instance. Existing users notice nothing. Part of #1498 Co-Authored-By: Claude Fable 5.1 --- packages/core/src/actions/actions.ts | 9 + packages/core/src/reducers/core.ts | 193 ++++++++--- packages/core/src/store/jsonFormsCore.ts | 8 + packages/core/src/store/store.ts | 10 + packages/core/src/util/formValidator.ts | 300 ++++++++++++++++++ packages/core/src/util/index.ts | 1 + packages/core/test/reducers/core.test.ts | 246 +++++++++++++- packages/core/test/util/formValidator.test.ts | 223 +++++++++++++ 8 files changed, 953 insertions(+), 37 deletions(-) create mode 100644 packages/core/src/util/formValidator.ts create mode 100644 packages/core/test/util/formValidator.test.ts diff --git a/packages/core/src/actions/actions.ts b/packages/core/src/actions/actions.ts index c02aece598..1ff517f38c 100644 --- a/packages/core/src/actions/actions.ts +++ b/packages/core/src/actions/actions.ts @@ -25,6 +25,7 @@ import type AJV from 'ajv'; import type { ErrorObject } from 'ajv'; +import type { ValidatorOption } from '../util/formValidator'; import { JsonSchema, UISchemaElement } from '../models'; import { generateDefaultUISchema, generateJsonSchema } from '../generators'; @@ -145,7 +146,15 @@ export interface UpdateCoreAction { } export interface InitActionOptions { + /** The AJV instance to validate with. Ignored for form validation when `validator` is set. */ ajv?: AJV; + /** + * A custom Form Validator: a factory producing one per schema, or a + * validator already bound to the schema. Takes precedence over `ajv`. + * Passing `undefined` explicitly switches back to AJV; leaving the key out + * keeps the validator currently in use. + */ + validator?: ValidatorOption; validationMode?: ValidationMode; additionalErrors?: ErrorObject[]; } diff --git a/packages/core/src/reducers/core.ts b/packages/core/src/reducers/core.ts index 940c9d3f14..0f2181f3bf 100644 --- a/packages/core/src/reducers/core.ts +++ b/packages/core/src/reducers/core.ts @@ -42,10 +42,20 @@ import { UpdateCoreAction, } from '../actions'; import { JsonFormsCore, Reducer, ValidationMode } from '../store'; +import type { JsonSchema } from '../models'; import type Ajv from 'ajv'; import type { ErrorObject } from 'ajv'; import isFunction from 'lodash/isFunction'; -import { createAjv, validate } from '../util'; +import { createAjv, validate } from '../util/validator'; +import { + compiledAjvValidator, + createAjvValidator, + FormValidator, + isAjvFormValidator, + issuesToErrors, + toFormValidatorFactory, + ValidatorOption, +} from '../util/formValidator'; export const initState: JsonFormsCore = { data: {}, @@ -56,6 +66,8 @@ export const initState: JsonFormsCore = { ajv: undefined, validationMode: 'ValidateAndShow', additionalErrors: [], + formValidator: undefined, + validatorOption: undefined, }; export const getValidationMode = ( @@ -119,6 +131,75 @@ const hasAjvOption = (option: any): option is InitActionOptions => { return false; }; +const hasValidatorOption = (option: any): option is InitActionOptions => + !!option && !isFunction(option.compile) && 'validator' in option; + +/** + * The custom `validator` option in effect for an action: the one given in the + * action's options (an explicit `validator: undefined` switches back to AJV), + * or the one already stored in state when the options do not mention a + * validator at all. Returns `undefined` when AJV performs the validation. + */ +export const getValidatorOption = ( + state: JsonFormsCore, + action?: InitAction | UpdateCoreAction +): ValidatorOption | undefined => { + if (action && hasValidatorOption(action.options)) { + return action.options.validator; + } + return state.validatorOption; +}; + +/** + * Creates the Form Validator for `schema`: through the custom option when one + * is configured, otherwise through the built-in AJV adapter. + */ +const createFormValidator = ( + option: ValidatorOption | undefined, + ajv: Ajv | undefined, + schema: JsonSchema, + validationMode: ValidationMode +): FormValidator | undefined => { + if (validationMode === 'NoValidation') { + return undefined; + } + const factory = + option === undefined + ? createAjvValidator(ajv) + : toFormValidatorFactory(option); + return factory(schema); +}; + +/** + * The Form Validator to validate with: the cached one, or, for states that + * only carry a compiled AJV function (e.g. constructed by hand), that + * function wrapped as a Form Validator. + */ +const currentFormValidator = ( + state: JsonFormsCore +): FormValidator | undefined => + state.formValidator ?? + (state.validator ? compiledAjvValidator(state.validator) : undefined); + +const runValidation = ( + formValidator: FormValidator | undefined, + data: any, + schema: JsonSchema +): ErrorObject[] => { + if (formValidator === undefined) { + return []; + } + if (isAjvFormValidator(formValidator)) { + // AJV errors already have the stored shape; hand them over untouched. + return validate(formValidator.validateFn, data); + } + return issuesToErrors(formValidator.validate(data), schema); +}; + +/** The compiled AJV function behind a Form Validator, for `state.validator`. */ +const legacyValidateFn = (formValidator: FormValidator | undefined) => + isAjvFormValidator(formValidator) ? formValidator.validateFn : undefined; + export const coreReducer: Reducer = ( state = initState, action @@ -126,13 +207,15 @@ export const coreReducer: Reducer = ( switch (action.type) { case INIT: { const thisAjv = getOrCreateAjv(state, action); - + const validatorOption = getValidatorOption(state, action); const validationMode = getValidationMode(state, action); - const v = - validationMode === 'NoValidation' - ? undefined - : thisAjv.compile(action.schema); - const e = validate(v, action.data); + const formValidator = createFormValidator( + validatorOption, + thisAjv, + action.schema, + validationMode + ); + const e = runValidation(formValidator, action.data, action.schema); const additionalErrors = getAdditionalErrors(state, action); return { @@ -142,30 +225,37 @@ export const coreReducer: Reducer = ( uischema: action.uischema, additionalErrors, errors: e, - validator: v, + validator: legacyValidateFn(formValidator), + formValidator, + validatorOption, ajv: thisAjv, validationMode, }; } case UPDATE_CORE: { const thisAjv = getOrCreateAjv(state, action); + const validatorOption = getValidatorOption(state, action); const validationMode = getValidationMode(state, action); - let validator = state.validator; + let formValidator = currentFormValidator(state); let errors = state.errors; if ( state.schema !== action.schema || state.validationMode !== validationMode || - state.ajv !== thisAjv + state.ajv !== thisAjv || + state.validatorOption !== validatorOption ) { // revalidate only if necessary - validator = - validationMode === 'NoValidation' - ? undefined - : thisAjv.compile(action.schema); - errors = validate(validator, action.data); + formValidator = createFormValidator( + validatorOption, + thisAjv, + action.schema, + validationMode + ); + errors = runValidation(formValidator, action.data, action.schema); } else if (state.data !== action.data) { - errors = validate(validator, action.data); + errors = runValidation(formValidator, action.data, action.schema); } + const validator = legacyValidateFn(formValidator); const additionalErrors = getAdditionalErrors(state, action); const stateChanged = @@ -175,6 +265,8 @@ export const coreReducer: Reducer = ( state.ajv !== thisAjv || state.errors !== errors || state.validator !== validator || + state.formValidator !== formValidator || + state.validatorOption !== validatorOption || state.validationMode !== validationMode || state.additionalErrors !== additionalErrors; return stateChanged @@ -185,7 +277,9 @@ export const coreReducer: Reducer = ( uischema: action.uischema, ajv: thisAjv, errors: isEqual(errors, state.errors) ? state.errors : errors, - validator: validator, + validator, + formValidator, + validatorOption, validationMode: validationMode, additionalErrors, } @@ -193,27 +287,39 @@ export const coreReducer: Reducer = ( } case SET_AJV: { const currentAjv = action.ajv; - const validator = - state.validationMode === 'NoValidation' - ? undefined - : currentAjv.compile(state.schema); - const errors = validate(validator, state.data); + const formValidator = createFormValidator( + state.validatorOption, + currentAjv, + state.schema, + state.validationMode + ); + const errors = runValidation(formValidator, state.data, state.schema); return { ...state, - validator, + ajv: currentAjv, + validator: legacyValidateFn(formValidator), + formValidator, errors, }; } case SET_SCHEMA: { const needsNewValidator = - action.schema && state.ajv && state.validationMode !== 'NoValidation'; - const v = needsNewValidator - ? state.ajv.compile(action.schema) - : state.validator; - const errors = validate(v, state.data); + action.schema && + (state.ajv || state.validatorOption) && + state.validationMode !== 'NoValidation'; + const formValidator = needsNewValidator + ? createFormValidator( + state.validatorOption, + state.ajv, + action.schema, + state.validationMode + ) + : currentFormValidator(state); + const errors = runValidation(formValidator, state.data, action.schema); return { ...state, - validator: v, + validator: legacyValidateFn(formValidator), + formValidator, schema: action.schema, errors, }; @@ -230,7 +336,11 @@ export const coreReducer: Reducer = ( } else if (action.path === '') { // empty path is ok const result = action.updater(cloneDeep(state.data)); - const errors = validate(state.validator, result); + const errors = runValidation( + currentFormValidator(state), + result, + state.schema + ); return { ...state, data: result, @@ -253,7 +363,11 @@ export const coreReducer: Reducer = ( action.path ); } - const errors = validate(state.validator, newState); + const errors = runValidation( + currentFormValidator(state), + newState, + state.schema + ); return { ...state, data: newState, @@ -272,19 +386,26 @@ export const coreReducer: Reducer = ( return state; } if (action.validationMode === 'NoValidation') { - const errors = validate(undefined, state.data); return { ...state, - errors, + errors: [], + validator: undefined, + formValidator: undefined, validationMode: action.validationMode, }; } if (state.validationMode === 'NoValidation') { - const validator = state.ajv.compile(state.schema); - const errors = validate(validator, state.data); + const formValidator = createFormValidator( + state.validatorOption, + state.ajv, + state.schema, + action.validationMode + ); + const errors = runValidation(formValidator, state.data, state.schema); return { ...state, - validator, + validator: legacyValidateFn(formValidator), + formValidator, errors, validationMode: action.validationMode, }; diff --git a/packages/core/src/store/jsonFormsCore.ts b/packages/core/src/store/jsonFormsCore.ts index 596f76c92a..ba88ac6b25 100644 --- a/packages/core/src/store/jsonFormsCore.ts +++ b/packages/core/src/store/jsonFormsCore.ts @@ -3,6 +3,7 @@ import type { ErrorObject } from 'ajv'; import { JsonSchema, UISchemaElement } from '../models'; import get from 'lodash/get'; import { errorsAt } from '../util'; +import type { FormValidator } from '../util/formValidator'; import { JsonFormsCellRendererRegistryEntry, JsonFormsCore, @@ -55,6 +56,10 @@ export const getUiSchema = (state: JsonFormsState): UISchemaElement => extractUiSchema(get(state, 'jsonforms.core')); export const getAjv = (state: JsonFormsState): Ajv => extractAjv(get(state, 'jsonforms.core')); +/** The Form Validator currently bound to the form schema, if validation is on. */ +export const getValidator = ( + state: JsonFormsState +): FormValidator | undefined => extractValidator(get(state, 'jsonforms.core')); export const getRenderers = ( state: JsonFormsState ): JsonFormsRendererRegistryEntry[] => get(state, 'jsonforms.renderers'); @@ -69,5 +74,8 @@ export const extractData = (state: JsonFormsCore) => get(state, 'data'); export const extractSchema = (state: JsonFormsCore) => get(state, 'schema'); export const extractUiSchema = (state: JsonFormsCore) => get(state, 'uischema'); export const extractAjv = (state: JsonFormsCore) => get(state, 'ajv'); +export const extractValidator = ( + state: JsonFormsCore +): FormValidator | undefined => get(state, 'formValidator'); export const getConfig = (state: JsonFormsState) => state.jsonforms.config; diff --git a/packages/core/src/store/store.ts b/packages/core/src/store/store.ts index 7415b69de2..b5d1361914 100644 --- a/packages/core/src/store/store.ts +++ b/packages/core/src/store/store.ts @@ -28,6 +28,7 @@ import { RankedTester, UISchemaTester } from '../testers'; import { JsonSchema, UISchemaElement } from '../models'; import type Ajv from 'ajv'; import type { ErrorObject, ValidateFunction } from 'ajv'; +import type { FormValidator, ValidatorOption } from '../util/formValidator'; import { JsonFormsI18nState } from './i18nTypes'; /** @@ -89,9 +90,18 @@ export interface JsonFormsCore { uischema: UISchemaElement; errors?: ErrorObject[]; additionalErrors?: ErrorObject[]; + /** + * The compiled AJV validate function behind the current Form Validator. + * Only set while AJV performs the validation; kept for backwards + * compatibility, prefer `formValidator`. + */ validator?: ValidateFunction; ajv?: Ajv; validationMode?: ValidationMode; + /** The Form Validator bound to `schema`. Not consulted while validation is off. */ + formValidator?: FormValidator; + /** The `validator` option in effect when a custom Form Validator is configured. */ + validatorOption?: ValidatorOption; } export interface JsonFormsRendererRegistryEntry { diff --git a/packages/core/src/util/formValidator.ts b/packages/core/src/util/formValidator.ts new file mode 100644 index 0000000000..b9cb37039f --- /dev/null +++ b/packages/core/src/util/formValidator.ts @@ -0,0 +1,300 @@ +/* + The MIT License + + Copyright (c) 2017-2019 EclipseSource Munich + https://github.com/eclipsesource/jsonforms + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +*/ +import type Ajv from 'ajv'; +import type { ErrorObject, ValidateFunction } from 'ajv'; +import type { JsonSchema } from '../models'; +import { createAjv } from './validator'; +import { resolveSchema } from './resolvers'; +import { decode } from './path'; + +/** + * One finding reported by a {@link FormValidator}, addressed to a data + * location by JSON Pointer. + * + * The required fields mirror the `ValidationIssue` of JSON Forms 4.x so a + * Form Validator written for either version works with both. The optional + * `params` and `parentSchema` carry the extra information JSON Forms 3.x + * uses for error placement and translation; core fills them in as far as it + * can when an adapter leaves them out. + */ +export interface ValidationIssue { + /** JSON Pointer of the value this issue belongs to, e.g. `/address/street`. */ + path: string; + /** + * Stable key identifying the kind of issue, usually the JSON Schema keyword + * (`required`, `minLength`, ...). It drives the `error.` translation + * lookups. Defaults to `custom` when left out. + */ + key?: string; + /** Human readable message, used when no translation is registered. */ + message: string; + /** + * JSON Forms 3.x stores errors only; issues with any other severity are + * dropped. + */ + severity?: 'error'; + /** + * Keyword parameters in AJV's shape, e.g. `{ missingProperty: 'name' }` for + * `required` or `{ additionalProperty: 'x' }` for `additionalProperties`. + */ + params?: Record; + /** + * The schema the issue was raised against. Resolved from the form schema by + * `path` when left out. + */ + parentSchema?: JsonSchema; +} + +/** + * Validates form data against one schema. This is the pluggable seam between + * JSON Forms and a validation library; AJV is the built-in implementation, + * see {@link createAjvValidator}. + * + * The shape mirrors the `FormValidator` of JSON Forms 4.x, restricted to + * synchronous results. Asynchronous validation stays with middleware and + * `updateErrors`. + */ +export interface FormValidator { + /** Validates `data` and returns every issue found, or an empty array. */ + validate: (data: unknown) => readonly ValidationIssue[]; + /** + * Optional fast path for rule conditions: whether `data` satisfies the + * (usually small) `schema`. When absent, core creates a Form Validator for + * the condition schema through the factory instead. + */ + matches?: (schema: JsonSchema, data: unknown) => boolean; +} + +/** + * Produces the {@link FormValidator} for one schema. Core calls it whenever + * the form schema changes and caches the result in its state. + */ +export type FormValidatorFactory = (schema: JsonSchema) => FormValidator; + +/** + * What adopters pass as the `validator` option: a {@link FormValidatorFactory}, + * or a {@link FormValidator} already bound to the form schema. A bound + * validator is reused for every schema, so the adopter owns swapping it when + * the schema changes. + */ +export type ValidatorOption = FormValidatorFactory | FormValidator; + +/** Whether `candidate` is a {@link FormValidator} object. */ +export const isFormValidator = ( + candidate: unknown +): candidate is FormValidator => + typeof candidate === 'object' && + candidate !== null && + typeof (candidate as FormValidator).validate === 'function'; + +/** Whether `candidate` is a {@link FormValidatorFactory}. */ +export const isFormValidatorFactory = ( + candidate: unknown +): candidate is FormValidatorFactory => typeof candidate === 'function'; + +/** Normalizes a {@link ValidatorOption} to a factory. */ +export const toFormValidatorFactory = ( + option: ValidatorOption +): FormValidatorFactory => + isFormValidatorFactory(option) ? option : () => option; + +/** + * Issue produced by the built-in AJV adapter. It carries every field of the + * original AJV error so that converting it back with {@link issuesToErrors} + * loses nothing. + */ +export interface AjvValidationIssue extends ValidationIssue { + schemaPath: string; + schema?: unknown; + data?: unknown; + propertyName?: string; +} + +/** Maps AJV errors to {@link AjvValidationIssue}s. */ +export const ajvErrorsToIssues = ( + errors: readonly ErrorObject[] | null | undefined +): AjvValidationIssue[] => + (errors ?? []).map((error) => { + const { instancePath, keyword, message, params, parentSchema, ...rest } = + error; + return { + ...rest, + path: instancePath, + key: keyword, + message: message ?? '', + severity: 'error', + params, + parentSchema: parentSchema as JsonSchema | undefined, + }; + }); + +/** A {@link FormValidator} backed by an AJV compiled validate function. */ +export interface AjvFormValidator extends FormValidator { + /** The compiled AJV validate function this Form Validator wraps. */ + readonly validateFn: ValidateFunction; +} + +/** Whether `validator` is an {@link AjvFormValidator}. */ +export const isAjvFormValidator = ( + validator: FormValidator | undefined +): validator is AjvFormValidator => + validator !== undefined && + typeof (validator as AjvFormValidator).validateFn === 'function'; + +/** + * Wraps an already compiled AJV validate function as a {@link FormValidator}, + * for example one generated ahead of time with AJV's standalone code + * generation for Content Security Policy setups without `unsafe-eval`. + * No AJV instance is involved at runtime. + * + * Standalone code generated without the `verbose` option carries no + * `parentSchema`; core then resolves it from the form schema. + */ +export const compiledAjvValidator = ( + validateFn: ValidateFunction, + matches?: FormValidator['matches'] +): AjvFormValidator => ({ + validateFn, + matches, + validate: (data) => { + validateFn(data); + return ajvErrorsToIssues(validateFn.errors); + }, +}); + +/** + * The built-in adapter: a {@link FormValidatorFactory} that compiles each + * schema with the given AJV instance, or with {@link createAjv} when none is + * given. This is what core uses when no `validator` option is configured. + */ +export const createAjvValidator = ( + ajv: Ajv = createAjv() +): FormValidatorFactory => { + const matches = (schema: JsonSchema, data: unknown): boolean => + ajv.validate(schema, data) as boolean; + return (schema) => compiledAjvValidator(ajv.compile(schema), matches); +}; + +const decodePointer = (pointer: string): string[] => + pointer === '' ? [] : pointer.split('/').slice(1).map(decode); + +const dereference = ( + schema: JsonSchema | undefined, + rootSchema: JsonSchema +): JsonSchema | undefined => + schema !== undefined && typeof schema.$ref === 'string' + ? resolveSchema(rootSchema, schema.$ref, rootSchema) + : schema; + +/** + * Best-effort lookup of the schema that describes the value at + * `instancePath`, walking `properties`, `items` and `additionalProperties` + * and following `$ref`s. Returns `undefined` when the path cannot be + * followed. + */ +export const resolveParentSchema = ( + rootSchema: JsonSchema | undefined, + instancePath: string +): JsonSchema | undefined => { + if (rootSchema === undefined) { + return undefined; + } + let current = dereference(rootSchema, rootSchema); + for (const segment of decodePointer(instancePath)) { + if (current === undefined) { + return undefined; + } + let next: JsonSchema | undefined; + if (current.properties && segment in current.properties) { + next = current.properties[segment]; + } else if (/^\d+$/.test(segment) && current.items !== undefined) { + next = Array.isArray(current.items) + ? current.items[Number(segment)] + : current.items; + } else if (typeof current.additionalProperties === 'object') { + next = current.additionalProperties; + } + current = dereference(next, rootSchema); + } + return current; +}; + +/** + * Converts {@link ValidationIssue}s to the AJV-shaped error objects JSON + * Forms 3.x stores in its state and hands to renderers, so that error + * placement, translation and every existing consumer keep working with any + * Form Validator. + * + * Normalization: issues whose severity is not `error` are dropped; a missing + * `key` becomes `custom`; a `required` issue addressed to the missing property + * itself is split into the parent's `instancePath` plus + * `params.missingProperty`; a missing `parentSchema` is resolved from + * `rootSchema` by path. Fields beyond the issue type (e.g. AJV's + * `schemaPath`) are passed through untouched. + * + * @param issues the issues reported by a Form Validator + * @param rootSchema the form schema, used to resolve a missing `parentSchema` + */ +export const issuesToErrors = ( + issues: readonly ValidationIssue[] | null | undefined, + rootSchema?: JsonSchema +): ErrorObject[] => + (issues ?? []) + .filter( + (issue) => issue.severity === undefined || issue.severity === 'error' + ) + .map((issue) => { + const { + path, + key, + severity: _severity, + message, + params, + parentSchema, + ...rest + } = issue as ValidationIssue & Record; + let instancePath = path ?? ''; + const keyword = key ?? 'custom'; + const errorParams: Record = { ...(params ?? {}) }; + if ( + keyword === 'required' && + errorParams.missingProperty === undefined && + instancePath.length > 0 + ) { + const cut = instancePath.lastIndexOf('/'); + errorParams.missingProperty = decode(instancePath.slice(cut + 1)); + instancePath = instancePath.slice(0, cut); + } + return { + schemaPath: '', + ...rest, + instancePath, + keyword, + params: errorParams, + message, + parentSchema: + parentSchema ?? resolveParentSchema(rootSchema, instancePath), + } as ErrorObject; + }); diff --git a/packages/core/src/util/index.ts b/packages/core/src/util/index.ts index 0d4ec3564f..beab6700bc 100644 --- a/packages/core/src/util/index.ts +++ b/packages/core/src/util/index.ts @@ -34,6 +34,7 @@ export * from './schema'; export * from './uischema'; export * from './util'; export * from './validator'; +export * from './formValidator'; export * from './defaultDateFormat'; export * from './errors'; export * from './helpers'; diff --git a/packages/core/test/reducers/core.test.ts b/packages/core/test/reducers/core.test.ts index 1df87b84b1..35b54cfa1a 100644 --- a/packages/core/test/reducers/core.test.ts +++ b/packages/core/test/reducers/core.test.ts @@ -27,6 +27,7 @@ import Ajv, { ErrorObject } from 'ajv'; import { coreReducer } from '../../src/reducers'; import { init, + setAjv, setSchema, setValidationMode, update, @@ -37,7 +38,13 @@ import { JsonSchema } from '../../src/models/jsonSchema'; import { cloneDeep } from 'lodash'; import { createAjv, validate } from '../../src/util/validator'; -import { JsonFormsCore, errorAt, subErrorsAt } from '../../src/store'; +import { + JsonFormsCore, + errorAt, + getValidator, + subErrorsAt, +} from '../../src/store'; +import type { FormValidator } from '../../src/util/formValidator'; import { getControlPath } from '../../src/util'; test('core reducer should support v7', (t) => { @@ -2147,6 +2154,243 @@ test('core reducer - setSchema - schema with id', (t) => { t.is(after.schema.properties.animal.minLength, 5); }); +const fakeValidator = (badValue = 'bad') => { + const calls = { compile: [] as JsonSchema[], validate: [] as unknown[] }; + const factory = (schema: JsonSchema) => { + calls.compile.push(schema); + return { + validate: (data: unknown) => { + calls.validate.push(data); + const value = (data as { foo?: string } | undefined)?.foo; + return value === badValue + ? [{ path: '/foo', key: 'const', message: 'must be good' }] + : []; + }, + }; + }; + return { factory, calls }; +}; + +const fooSchema: JsonSchema = { + type: 'object', + properties: { foo: { type: 'string' } }, +}; + +const explodingAjv = { + compile: () => { + throw new Error('AJV must not compile when a validator is given'); + }, +} as unknown as Ajv; + +test('core reducer - validator option - factory is called with the schema and validates the data', (t) => { + const { factory, calls } = fakeValidator(); + const after = coreReducer( + undefined, + init({ foo: 'bad' }, fooSchema, undefined, { + ajv: explodingAjv, + validator: factory, + }) + ); + t.deepEqual(calls.compile, [fooSchema]); + t.deepEqual(calls.validate, [{ foo: 'bad' }]); + t.is(after.errors.length, 1); + t.is(after.errors[0].instancePath, '/foo'); + t.is(after.errors[0].keyword, 'const'); + t.is(after.errors[0].message, 'must be good'); + t.deepEqual(after.errors[0].parentSchema, fooSchema.properties.foo); + t.is(after.validator, undefined); + t.truthy(after.formValidator); + t.is(after.validatorOption, factory); + t.is(after.ajv, explodingAjv); +}); + +test('core reducer - validator option - a bound FormValidator object is accepted', (t) => { + const bound = { + validate: () => [ + { + path: '', + key: 'required', + message: 'is required', + params: { missingProperty: 'foo' }, + }, + ], + }; + const after = coreReducer( + undefined, + init({}, fooSchema, undefined, { validator: bound }) + ); + t.is(after.formValidator, bound); + t.is(after.errors.length, 1); + t.deepEqual(after.errors[0].params, { missingProperty: 'foo' }); + t.deepEqual(after.errors[0].parentSchema, fooSchema); +}); + +test('core reducer - validator option - updateCore recompiles only when the schema changes', (t) => { + const { factory, calls } = fakeValidator(); + const before = coreReducer( + undefined, + init({ foo: 'ok' }, fooSchema, undefined, { validator: factory }) + ); + t.is(calls.compile.length, 1); + + const sameSchema = coreReducer( + before, + updateCore({ foo: 'bad' }, fooSchema, undefined, { validator: factory }) + ); + t.is(calls.compile.length, 1); + t.is(calls.validate.length, 2); + t.is(sameSchema.errors.length, 1); + + const newSchema = cloneDeep(fooSchema); + const after = coreReducer( + sameSchema, + updateCore({ foo: 'bad' }, newSchema, undefined, { validator: factory }) + ); + t.is(calls.compile.length, 2); + t.is(calls.compile[1], newSchema); + t.is(after.errors.length, 1); +}); + +test('core reducer - validator option - options without a validator key keep the current validator', (t) => { + const { factory, calls } = fakeValidator(); + const before = coreReducer( + undefined, + init({ foo: 'ok' }, fooSchema, undefined, { validator: factory }) + ); + const after = coreReducer( + before, + updateCore({ foo: 'bad' }, fooSchema, undefined, { + validationMode: 'ValidateAndShow', + }) + ); + t.is(after.validatorOption, factory); + t.is(calls.validate.length, 2); + t.is(after.errors.length, 1); +}); + +test('core reducer - validator option - an explicit undefined validator switches back to AJV', (t) => { + const { factory } = fakeValidator(); + const schema: JsonSchema = { + type: 'object', + properties: { foo: { type: 'string', const: 'bar' } }, + }; + const before = coreReducer( + undefined, + init({ foo: 'baz' }, schema, undefined, { validator: factory }) + ); + t.is(before.errors.length, 0); + const after = coreReducer( + before, + updateCore({ foo: 'baz' }, schema, undefined, { validator: undefined }) + ); + t.is(after.validatorOption, undefined); + t.is(after.errors.length, 1); + t.is(after.errors[0].keyword, 'const'); + t.truthy(after.validator); +}); + +test('core reducer - validator option - update revalidates through the validator', (t) => { + const { factory, calls } = fakeValidator(); + const before = coreReducer( + undefined, + init({ foo: 'ok' }, fooSchema, undefined, { validator: factory }) + ); + const after = coreReducer( + before, + update('foo', () => 'bad') + ); + t.is(calls.compile.length, 1); + t.is(calls.validate.length, 2); + t.is(after.errors.length, 1); +}); + +test('core reducer - validator option - setSchema compiles the new schema through the validator', (t) => { + const { factory, calls } = fakeValidator(); + const before = coreReducer( + undefined, + init({ foo: 'ok' }, fooSchema, undefined, { validator: factory }) + ); + const newSchema = cloneDeep(fooSchema); + const after = coreReducer(before, setSchema(newSchema)); + t.is(calls.compile.length, 2); + t.is(calls.compile[1], newSchema); + t.is(after.schema, newSchema); +}); + +test('core reducer - validator option - NoValidation skips the validator and re-enabling recompiles', (t) => { + const { factory, calls } = fakeValidator(); + const before = coreReducer( + undefined, + init({ foo: 'bad' }, fooSchema, undefined, { validator: factory }) + ); + t.is(before.errors.length, 1); + const hidden = coreReducer(before, setValidationMode('NoValidation')); + t.is(hidden.errors.length, 0); + t.is(hidden.formValidator, undefined); + t.is(hidden.validator, undefined); + const validateCalls = calls.validate.length; + const updated = coreReducer( + hidden, + update('foo', () => 'still bad') + ); + t.is(calls.validate.length, validateCalls); + t.is(updated.errors.length, 0); + const shown = coreReducer(updated, setValidationMode('ValidateAndShow')); + t.is(calls.compile.length, 2); + t.is(shown.errors.length, 0); +}); + +test('core reducer - validator option - setAjv does not override a custom validator', (t) => { + const { factory, calls } = fakeValidator(); + const before = coreReducer( + undefined, + init({ foo: 'bad' }, fooSchema, undefined, { validator: factory }) + ); + const after = coreReducer(before, setAjv(explodingAjv)); + t.is(after.ajv, explodingAjv); + t.is(calls.compile.length, 2); + t.is(after.errors.length, 1); + t.is(after.errors[0].keyword, 'const'); +}); + +test('core reducer - validator option - issues are normalized to the AJV error shape', (t) => { + const bound = { + validate: () => [ + { path: '/foo', message: 'no key' }, + { path: '/foo', key: 'required', message: 'is required' }, + { path: '/foo', key: 'x', message: 'warn', severity: 'warning' }, + ], + } as unknown as FormValidator; + const after = coreReducer( + undefined, + init({}, fooSchema, undefined, { validator: bound }) + ); + t.is(after.errors.length, 2); + t.is(after.errors[0].keyword, 'custom'); + t.is(after.errors[1].keyword, 'required'); + t.is(after.errors[1].instancePath, ''); + t.deepEqual(after.errors[1].params, { missingProperty: 'foo' }); + t.deepEqual(errorAt('foo', fooSchema)(after).length, 2); +}); + +test('core reducer - ajv path - formValidator is an AJV-backed validator and state.validator is kept', (t) => { + const after = coreReducer(undefined, init({ foo: 'bar' }, fooSchema)); + t.truthy(after.formValidator); + t.truthy(after.validator); + t.is(after.validatorOption, undefined); + t.is(after.formValidator.validate({ foo: 1 }).length, 1); +}); + +test('core reducer - validator option - getValidator selector exposes the bound validator', (t) => { + const { factory } = fakeValidator(); + const core = coreReducer( + undefined, + init({ foo: 'ok' }, fooSchema, undefined, { validator: factory }) + ); + t.is(getValidator({ jsonforms: { core } } as any), core.formValidator); + t.is(getValidator({ jsonforms: {} } as any), undefined); +}); + test('core reducer helpers - getControlPath - converts JSON Pointer notation to dot notation', (t) => { const errorObject = { instancePath: '/group/name' } as ErrorObject; const controlPath = getControlPath(errorObject); diff --git a/packages/core/test/util/formValidator.test.ts b/packages/core/test/util/formValidator.test.ts new file mode 100644 index 0000000000..a94b6e4b43 --- /dev/null +++ b/packages/core/test/util/formValidator.test.ts @@ -0,0 +1,223 @@ +/* + The MIT License + + Copyright (c) 2017-2019 EclipseSource Munich + https://github.com/eclipsesource/jsonforms + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +*/ +import test from 'ava'; +import type { ErrorObject } from 'ajv'; +import { JsonSchema } from '../../src/models'; +import { + ajvErrorsToIssues, + compiledAjvValidator, + createAjvValidator, + isFormValidator, + isFormValidatorFactory, + issuesToErrors, + resolveParentSchema, + toFormValidatorFactory, + ValidationIssue, +} from '../../src/util/formValidator'; +import { createAjv } from '../../src/util/validator'; + +const schema: JsonSchema = { + type: 'object', + properties: { + name: { type: 'string', minLength: 3 }, + tags: { type: 'array', items: { type: 'string' } }, + address: { + type: 'object', + properties: { street: { type: 'string' } }, + required: ['street'], + }, + pet: { $ref: '#/definitions/pet' }, + extra: { type: 'object', additionalProperties: { type: 'number' } }, + }, + required: ['name'], + definitions: { + pet: { type: 'object', properties: { kind: { type: 'string' } } }, + }, +}; + +test('ajvErrorsToIssues - maps AJV fields and keeps the rest', (t) => { + const validateFn = createAjv().compile(schema); + validateFn({ name: 'ab', tags: [1] }); + const issues = ajvErrorsToIssues(validateFn.errors); + t.is(issues.length, 2); + const [minLength, type] = issues; + t.is(minLength.path, '/name'); + t.is(minLength.key, 'minLength'); + t.is(minLength.severity, 'error'); + t.deepEqual(minLength.params, { limit: 3 }); + t.deepEqual(minLength.parentSchema, schema.properties.name); + t.is(minLength.schemaPath, '#/properties/name/minLength'); + t.is(type.path, '/tags/0'); + t.is(type.key, 'type'); + t.is(ajvErrorsToIssues(null).length, 0); +}); + +test('issuesToErrors - round trip of AJV errors is lossless', (t) => { + const validateFn = createAjv().compile(schema); + validateFn({ tags: ['ok', 2], address: {} }); + const original = validateFn.errors; + const roundTripped = issuesToErrors(ajvErrorsToIssues(original), schema); + t.deepEqual(roundTripped, original); +}); + +test('issuesToErrors - missing key becomes custom', (t) => { + const [error] = issuesToErrors([{ path: '/name', message: 'nope' }], schema); + t.is(error.keyword, 'custom'); + t.is(error.instancePath, '/name'); + t.is(error.message, 'nope'); + t.deepEqual(error.params, {}); + t.is(error.schemaPath, ''); + t.deepEqual(error.parentSchema, schema.properties.name); +}); + +test('issuesToErrors - required issue addressed to the missing property is split', (t) => { + const [error] = issuesToErrors( + [{ path: '/address/street', key: 'required', message: 'is required' }], + schema + ); + t.is(error.instancePath, '/address'); + t.deepEqual(error.params, { missingProperty: 'street' }); + t.deepEqual(error.parentSchema, schema.properties.address); +}); + +test('issuesToErrors - required split decodes JSON Pointer escapes', (t) => { + const [error] = issuesToErrors([ + { path: '/a~1b', key: 'required', message: 'is required' }, + ]); + t.is(error.instancePath, ''); + t.deepEqual(error.params, { missingProperty: 'a/b' }); +}); + +test('issuesToErrors - required issue with params is left alone', (t) => { + const issue: ValidationIssue = { + path: '', + key: 'required', + message: 'is required', + params: { missingProperty: 'name' }, + }; + const [error] = issuesToErrors([issue], schema); + t.is(error.instancePath, ''); + t.deepEqual(error.params, { missingProperty: 'name' }); + t.deepEqual(error.parentSchema, schema); +}); + +test('issuesToErrors - non-error severities are dropped', (t) => { + const issues = [ + { path: '/name', key: 'x', message: 'warn', severity: 'warning' }, + { path: '/name', key: 'y', message: 'err', severity: 'error' }, + { path: '/name', key: 'z', message: 'none' }, + ] as unknown as ValidationIssue[]; + const errors = issuesToErrors(issues, schema); + t.deepEqual( + errors.map((e) => e.keyword), + ['y', 'z'] + ); +}); + +test('issuesToErrors - explicit parentSchema wins over resolution', (t) => { + const parentSchema: JsonSchema = { type: 'string' }; + const [error] = issuesToErrors( + [{ path: '/name', key: 'type', message: 'm', parentSchema }], + schema + ); + t.is(error.parentSchema, parentSchema); +}); + +test('issuesToErrors - tolerates null and undefined', (t) => { + t.deepEqual(issuesToErrors(null), []); + t.deepEqual(issuesToErrors(undefined), []); +}); + +test('resolveParentSchema - walks properties, items, additionalProperties and $ref', (t) => { + t.deepEqual(resolveParentSchema(schema, ''), schema); + t.deepEqual(resolveParentSchema(schema, '/name'), schema.properties.name); + t.deepEqual(resolveParentSchema(schema, '/tags/3'), { type: 'string' }); + t.deepEqual( + resolveParentSchema(schema, '/address/street'), + schema.properties.address.properties.street + ); + t.deepEqual(resolveParentSchema(schema, '/pet'), schema.definitions.pet); + t.deepEqual(resolveParentSchema(schema, '/pet/kind'), { type: 'string' }); + t.deepEqual(resolveParentSchema(schema, '/extra/anything'), { + type: 'number', + }); + t.is(resolveParentSchema(schema, '/unknown/deeper'), undefined); + t.is(resolveParentSchema(undefined, '/name'), undefined); +}); + +test('compiledAjvValidator - wraps a compiled validate function', (t) => { + const validateFn = createAjv().compile(schema); + const validator = compiledAjvValidator(validateFn); + t.is(validator.validateFn, validateFn); + t.deepEqual(validator.validate({ name: 'Ada' }), []); + const issues = validator.validate({}); + t.is(issues.length, 1); + t.is(issues[0].key, 'required'); + t.deepEqual(issues[0].params, { missingProperty: 'name' }); + t.is(validator.matches, undefined); +}); + +test('createAjvValidator - compiles with the given instance and offers matches', (t) => { + const ajv = createAjv(); + let compiled = 0; + const originalCompile = ajv.compile.bind(ajv); + ajv.compile = ((s: JsonSchema) => { + compiled++; + return originalCompile(s); + }) as typeof ajv.compile; + const factory = createAjvValidator(ajv); + const validator = factory(schema); + t.is(compiled, 1); + t.is(validator.validate({ name: 'Ada' }).length, 0); + t.true(validator.matches({ const: 'a' }, 'a')); + t.false(validator.matches({ const: 'a' }, 'b')); +}); + +test('createAjvValidator - falls back to a default instance', (t) => { + const validator = createAjvValidator()(schema); + t.is(validator.validate({ name: 'Ada' }).length, 0); + t.is(validator.validate({ name: 'A' }).length, 1); +}); + +test('toFormValidatorFactory and guards', (t) => { + const bound = { validate: () => [] as ValidationIssue[] }; + const factory = () => bound; + t.true(isFormValidator(bound)); + t.false(isFormValidator(factory)); + t.false(isFormValidator(null)); + t.true(isFormValidatorFactory(factory)); + t.false(isFormValidatorFactory(bound)); + t.is(toFormValidatorFactory(factory), factory); + t.is(toFormValidatorFactory(bound)(schema), bound); +}); + +test('issuesToErrors - result satisfies the ErrorObject contract', (t) => { + const errors: ErrorObject[] = issuesToErrors([ + { path: '/name', key: 'minLength', message: 'short', params: { limit: 3 } }, + ]); + t.is(errors[0].keyword, 'minLength'); + t.is(errors[0].instancePath, '/name'); + t.is(typeof errors[0].schemaPath, 'string'); +});