diff --git a/packages/angular-material/example/app/app.component.ts b/packages/angular-material/example/app/app.component.ts index af34983e00..c3a18023b8 100644 --- a/packages/angular-material/example/app/app.component.ts +++ b/packages/angular-material/example/app/app.component.ts @@ -87,6 +87,7 @@ const itemTester: UISchemaTester = (_schema, schemaPath, _path) => { [(data)]="selectedExample.data" [schema]="selectedExample.schema" [uischema]="selectedExample.uischema" + [validator]="selectedExample.validator" [renderers]="renderers" [i18n]="i18n" [readonly]="readonly" diff --git a/packages/angular-material/src/library/layouts/categorization-layout.renderer.ts b/packages/angular-material/src/library/layouts/categorization-layout.renderer.ts index 9287d715e7..18992d7bc4 100644 --- a/packages/angular-material/src/library/layouts/categorization-layout.renderer.ts +++ b/packages/angular-material/src/library/layouts/categorization-layout.renderer.ts @@ -29,7 +29,7 @@ import { Category, defaultJsonFormsI18nState, deriveLabelForUISchemaElement, - getAjv, + getRuleValidator, getConfig, isVisible, JsonFormsState, @@ -93,7 +93,7 @@ export class CategorizationTabLayoutRenderer category, props.data, undefined, - getAjv(state), + getRuleValidator(state), getConfig(state) ) ); diff --git a/packages/angular-material/src/library/other/util.ts b/packages/angular-material/src/library/other/util.ts index 4d2fa04fb4..774b084c8e 100644 --- a/packages/angular-material/src/library/other/util.ts +++ b/packages/angular-material/src/library/other/util.ts @@ -24,7 +24,7 @@ */ import { ControlElement, - getAjv, + getRuleValidator, getConfig, getData, isVisible, @@ -49,7 +49,7 @@ export const mapStateToVisible = ( ownProps.uischema, getData(state), undefined, - getAjv(state), + getRuleValidator(state), getConfig(state) ); diff --git a/packages/angular/src/library/jsonforms-root.component.ts b/packages/angular/src/library/jsonforms-root.component.ts index c93cd05e4b..989019aaeb 100644 --- a/packages/angular/src/library/jsonforms-root.component.ts +++ b/packages/angular/src/library/jsonforms-root.component.ts @@ -44,6 +44,7 @@ import { UISchemaElement, UISchemaTester, ValidationMode, + ValidatorOption, } from '@jsonforms/core'; import type Ajv from 'ajv'; import type { ErrorObject } from 'ajv'; @@ -71,6 +72,7 @@ export class JsonForms implements DoCheck, OnChanges, OnInit, OnDestroy { @Input() readonly: boolean; @Input() validationMode: ValidationMode; @Input() ajv: Ajv; + @Input() validator: ValidatorOption; @Input() config: JsonFormsAngularConfig; @Input() i18n: JsonFormsI18nState; @Input() additionalErrors: ErrorObject[]; @@ -95,6 +97,7 @@ export class JsonForms implements DoCheck, OnChanges, OnInit, OnDestroy { uischema: this.uischema, schema: this.schema, ajv: this.ajv, + validatorOption: this.validator, validationMode: this.validationMode, additionalErrors: this.additionalErrors, }, @@ -160,6 +163,7 @@ export class JsonForms implements DoCheck, OnChanges, OnInit, OnDestroy { const newReadonly = changes.readonly; const newValidationMode = changes.validationMode; const newAjv = changes.ajv; + const newValidator = changes.validator; const newConfig = changes.config; const newAdditionalErrors = changes.additionalErrors; @@ -169,6 +173,7 @@ export class JsonForms implements DoCheck, OnChanges, OnInit, OnDestroy { newUiSchema || newValidationMode || newAjv || + newValidator || newAdditionalErrors ) { this.jsonformsService.updateCoreState( @@ -177,7 +182,10 @@ export class JsonForms implements DoCheck, OnChanges, OnInit, OnDestroy { newUiSchema ? newUiSchema.currentValue : USE_STATE_VALUE, newAjv ? newAjv.currentValue : USE_STATE_VALUE, newValidationMode ? newValidationMode.currentValue : USE_STATE_VALUE, - newAdditionalErrors ? newAdditionalErrors.currentValue : USE_STATE_VALUE + newAdditionalErrors + ? newAdditionalErrors.currentValue + : USE_STATE_VALUE, + newValidator ? newValidator.currentValue : USE_STATE_VALUE ); } diff --git a/packages/angular/src/library/jsonforms.service.ts b/packages/angular/src/library/jsonforms.service.ts index 294772731d..936f01aba3 100644 --- a/packages/angular/src/library/jsonforms.service.ts +++ b/packages/angular/src/library/jsonforms.service.ts @@ -46,6 +46,7 @@ import { updateI18n, Middleware, defaultMiddleware, + ValidatorOption, } from '@jsonforms/core'; import { BehaviorSubject, Observable } from 'rxjs'; import type { JsonFormsBaseRenderer } from './base.renderer'; @@ -293,7 +294,8 @@ export class JsonFormsAngularService { uischema: UISchemaElement | typeof USE_STATE_VALUE, ajv: Ajv | typeof USE_STATE_VALUE, validationMode: ValidationMode | typeof USE_STATE_VALUE, - additionalErrors: ErrorObject[] | typeof USE_STATE_VALUE + additionalErrors: ErrorObject[] | typeof USE_STATE_VALUE, + validator: ValidatorOption | typeof USE_STATE_VALUE = USE_STATE_VALUE ): void { const newData = data === USE_STATE_VALUE ? this._state.core.data : data; const newSchema = @@ -313,9 +315,14 @@ export class JsonFormsAngularService { additionalErrors === USE_STATE_VALUE ? this._state.core.additionalErrors : additionalErrors; + const newValidator = + validator === USE_STATE_VALUE + ? this._state.core.validatorOption + : validator; this.updateCore( Actions.updateCore(newData, newSchema, newUischema, { ajv: newAjv, + validator: newValidator, validationMode: newValidationMode, additionalErrors: newAdditionalErrors, }) 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/mappers/cell.ts b/packages/core/src/mappers/cell.ts index 7934c38cbc..3521968787 100644 --- a/packages/core/src/mappers/cell.ts +++ b/packages/core/src/mappers/cell.ts @@ -45,7 +45,7 @@ import type { JsonSchema } from '../models'; import { AnyAction, Dispatch, - getAjv, + getRuleValidator, getConfig, getData, getErrorAt, @@ -116,7 +116,13 @@ export const mapStateToCellProps = ( const visible = ownProps.visible !== undefined ? ownProps.visible - : isVisible(uischema, rootData, undefined, getAjv(state), config); + : isVisible( + uischema, + rootData, + undefined, + getRuleValidator(state), + config + ); const rootSchema = getSchema(state); diff --git a/packages/core/src/mappers/renderer.ts b/packages/core/src/mappers/renderer.ts index d80392c85f..5944abf98a 100644 --- a/packages/core/src/mappers/renderer.ts +++ b/packages/core/src/mappers/renderer.ts @@ -72,7 +72,7 @@ import { } from '../util'; import { Translator, - getAjv, + getRuleValidator, getCells, getConfig, getData, @@ -84,6 +84,7 @@ import { getUiSchema, } from '../store'; import { isInherentlyEnabled, isInherentlyReadonly } from './util'; +import { isStructuralMatch } from '../util/structural'; import { CombinatorKeyword } from './combinators'; import isEqual from 'lodash/isEqual'; @@ -596,7 +597,13 @@ export const mapStateToControlProps = ( const visible: boolean = ownProps.visible === undefined || hasShowRule(uischema) - ? isVisible(uischema, rootData, ownProps.path, getAjv(state), config) + ? isVisible( + uischema, + rootData, + ownProps.path, + getRuleValidator(state), + config + ) : ownProps.visible; const controlElement = uischema as ControlElement; const id = ownProps.id; @@ -1066,7 +1073,7 @@ export const mapStateToLayoutProps = ( ownProps.uischema, rootData, ownProps.path, - getAjv(state), + getRuleValidator(state), getConfig(state) ) : ownProps.visible; @@ -1163,41 +1170,14 @@ export const mapStateToCombinatorRendererProps = ( const { data, schema, rootSchema, i18nKeyPrefix, label, ...props } = mapStateToControlProps(state, ownProps); - const ajv = state.jsonforms.core.ajv; - const structuralKeywords = [ - 'required', - 'additionalProperties', - 'type', - 'enum', - 'const', - ]; - const dataIsValid = (errors: ErrorObject[]): boolean => { - return ( - !errors || - errors.length === 0 || - !errors.find((e) => structuralKeywords.indexOf(e.keyword) !== -1) - ); - }; + // Pick the first branch whose structural keywords fit the data. This is a + // pure structural check (see isStructuralMatch): no validator instance and + // no code generation are involved, so it also works under a strict CSP. let indexOfFittingSchema: number; - // TODO instead of compiling the combinator subschemas we can compile the original schema - // without the combinator alternatives and then revalidate and check the errors for the - // element for (let i = 0; i < schema[keyword]?.length; i++) { - try { - let _schema = schema[keyword][i]; - if (_schema.$ref) { - _schema = Resolve.schema(rootSchema, _schema.$ref, rootSchema); - } - const valFn = ajv.compile(_schema); - valFn(data); - if (dataIsValid(valFn.errors)) { - indexOfFittingSchema = i; - break; - } - } catch (error) { - console.debug( - "Combinator subschema is not self contained, can't hand it over to AJV" - ); + if (isStructuralMatch(schema[keyword][i], data, rootSchema)) { + indexOfFittingSchema = i; + break; } } @@ -1319,7 +1299,7 @@ export const mapStateToLabelProps = ( props.uischema, getData(state), props.path, - getAjv(state), + getRuleValidator(state), getConfig(state) ) : props.visible; diff --git a/packages/core/src/mappers/util.ts b/packages/core/src/mappers/util.ts index bd87bd2ab3..b6c403efd3 100644 --- a/packages/core/src/mappers/util.ts +++ b/packages/core/src/mappers/util.ts @@ -1,5 +1,5 @@ import { JsonSchema, UISchemaElement } from '../models'; -import { JsonFormsState, getAjv } from '../store'; +import { JsonFormsState, getRuleValidator } from '../store'; import { hasEnableRule, hasReadonlyRule, isEnabled, isReadonly } from '../util'; /** @@ -19,7 +19,13 @@ export const isInherentlyEnabled = ( return false; } if (uischema && hasEnableRule(uischema)) { - return isEnabled(uischema, rootData, ownProps?.path, getAjv(state), config); + return isEnabled( + uischema, + rootData, + ownProps?.path, + getRuleValidator(state), + config + ); } if (!config?.separateReadonlyFromDisabled) { if (typeof uischema?.options?.readonly === 'boolean') { @@ -66,7 +72,7 @@ export const isInherentlyReadonly = ( uischema, rootData, ownProps?.path, - getAjv(state), + getRuleValidator(state), config ); } 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..ba129d4bd7 100644 --- a/packages/core/src/store/jsonFormsCore.ts +++ b/packages/core/src/store/jsonFormsCore.ts @@ -3,6 +3,8 @@ 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 type { RuleValidator } from '../util/runtime'; import { JsonFormsCellRendererRegistryEntry, JsonFormsCore, @@ -55,6 +57,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 +75,23 @@ 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'); +/** + * What rule conditions are evaluated with: the configured Form Validator when + * a custom one is set (the bound one, or the option itself while validation is + * off), otherwise the AJV instance. + */ +export const getRuleValidator = (state: JsonFormsState): RuleValidator => { + const core: JsonFormsCore | undefined = get(state, 'jsonforms.core'); + if (core === undefined) { + return undefined; + } + if (core.validatorOption !== undefined) { + return core.formValidator ?? core.validatorOption; + } + return core.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..53e07114f1 100644 --- a/packages/core/src/util/index.ts +++ b/packages/core/src/util/index.ts @@ -34,6 +34,8 @@ export * from './schema'; export * from './uischema'; export * from './util'; export * from './validator'; +export * from './structural'; +export * from './formValidator'; export * from './defaultDateFormat'; export * from './errors'; export * from './helpers'; diff --git a/packages/core/src/util/runtime.ts b/packages/core/src/util/runtime.ts index d4fb4ff10b..149ad23fb6 100644 --- a/packages/core/src/util/runtime.ts +++ b/packages/core/src/util/runtime.ts @@ -38,6 +38,83 @@ import { import { resolveData } from './resolvers'; import type Ajv from 'ajv'; import { composeWithUi } from './uischema'; +import isFunction from 'lodash/isFunction'; +import type { JsonSchema } from '../models'; +import { + FormValidator, + FormValidatorFactory, + isFormValidator, + isFormValidatorFactory, +} from './formValidator'; +import { isSchemaMatch } from './structural'; + +/** + * What rule conditions are evaluated with: + * - an AJV instance (the historical argument): `ajv.validate(schema, data)`; + * - a Form Validator: its `matches`, or core's own schema matcher + * ({@link isSchemaMatch}) when it has none; + * - a Form Validator Factory: one Form Validator per condition schema, cached; + * - `undefined`: core's own schema matcher alone. + */ +export type RuleValidator = + | Ajv + | FormValidator + | FormValidatorFactory + | undefined; + +const isAjvInstance = (candidate: unknown): candidate is Ajv => + typeof candidate === 'object' && + candidate !== null && + isFunction((candidate as Ajv).compile) && + isFunction((candidate as Ajv).validate); + +const conditionValidators = new WeakMap< + FormValidatorFactory, + WeakMap +>(); + +const conditionValidatorFor = ( + factory: FormValidatorFactory, + schema: JsonSchema +): FormValidator => { + if (typeof schema !== 'object' || schema === null) { + return factory(schema); + } + let perSchema = conditionValidators.get(factory); + if (perSchema === undefined) { + perSchema = new WeakMap(); + conditionValidators.set(factory, perSchema); + } + let validator = perSchema.get(schema); + if (validator === undefined) { + validator = factory(schema); + perSchema.set(schema, validator); + } + return validator; +}; + +/** + * Whether `data` satisfies the rule condition `schema`, evaluated with the + * given {@link RuleValidator}. + */ +export const matchesConditionSchema = ( + schema: JsonSchema, + data: unknown, + validator: RuleValidator +): boolean => { + if (isAjvInstance(validator)) { + return validator.validate(schema, data) as boolean; + } + if (isFormValidatorFactory(validator)) { + return conditionValidatorFor(validator, schema).validate(data).length === 0; + } + if (isFormValidator(validator)) { + return validator.matches + ? validator.matches(schema, data) + : isSchemaMatch(schema, data); + } + return isSchemaMatch(schema, data); +}; const isOrCondition = (condition: Condition): condition is OrCondition => condition.type === 'OR'; @@ -67,19 +144,19 @@ const evaluateCondition = ( uischema: UISchemaElement, condition: Condition, path: string, - ajv: Ajv, + validator: RuleValidator, config: unknown ): boolean => { if (isAndCondition(condition)) { return condition.conditions.reduce( (acc, cur) => - acc && evaluateCondition(data, uischema, cur, path, ajv, config), + acc && evaluateCondition(data, uischema, cur, path, validator, config), true ); } else if (isOrCondition(condition)) { return condition.conditions.reduce( (acc, cur) => - acc || evaluateCondition(data, uischema, cur, path, ajv, config), + acc || evaluateCondition(data, uischema, cur, path, validator, config), false ); } else if (isLeafCondition(condition)) { @@ -90,7 +167,7 @@ const evaluateCondition = ( if (condition.failWhenUndefined && value === undefined) { return false; } - return ajv.validate(condition.schema, value) as boolean; + return matchesConditionSchema(condition.schema, value, validator); } else if (isValidateFunctionCondition(condition)) { const value = resolveData(data, getConditionScope(condition, path)); const context = { @@ -111,21 +188,21 @@ const isRuleFulfilled = ( uischema: UISchemaElement, data: any, path: string, - ajv: Ajv, + validator: RuleValidator, config: unknown ): boolean => { const condition = uischema.rule.condition; - return evaluateCondition(data, uischema, condition, path, ajv, config); + return evaluateCondition(data, uischema, condition, path, validator, config); }; export const evalVisibility = ( uischema: UISchemaElement, data: any, path: string = undefined, - ajv: Ajv, + validator: RuleValidator, config: unknown ): boolean => { - const fulfilled = isRuleFulfilled(uischema, data, path, ajv, config); + const fulfilled = isRuleFulfilled(uischema, data, path, validator, config); switch (uischema.rule.effect) { case RuleEffect.HIDE: @@ -142,10 +219,10 @@ export const evalEnablement = ( uischema: UISchemaElement, data: any, path: string = undefined, - ajv: Ajv, + validator: RuleValidator, config: unknown ): boolean => { - const fulfilled = isRuleFulfilled(uischema, data, path, ajv, config); + const fulfilled = isRuleFulfilled(uischema, data, path, validator, config); switch (uischema.rule.effect) { case RuleEffect.DISABLE: @@ -162,10 +239,10 @@ export const evalReadonly = ( uischema: UISchemaElement, data: any, path: string = undefined, - ajv: Ajv, + validator: RuleValidator, config: unknown ): boolean => { - const fulfilled = isRuleFulfilled(uischema, data, path, ajv, config); + const fulfilled = isRuleFulfilled(uischema, data, path, validator, config); switch (uischema.rule.effect) { case RuleEffect.WRITABLE: @@ -215,11 +292,11 @@ export const isVisible = ( uischema: UISchemaElement, data: any, path: string = undefined, - ajv: Ajv, + validator: RuleValidator, config: unknown ): boolean => { if (uischema.rule) { - return evalVisibility(uischema, data, path, ajv, config); + return evalVisibility(uischema, data, path, validator, config); } return true; @@ -229,11 +306,11 @@ export const isEnabled = ( uischema: UISchemaElement, data: any, path: string = undefined, - ajv: Ajv, + validator: RuleValidator, config: unknown ): boolean => { if (uischema.rule) { - return evalEnablement(uischema, data, path, ajv, config); + return evalEnablement(uischema, data, path, validator, config); } return true; @@ -243,11 +320,11 @@ export const isReadonly = ( uischema: UISchemaElement, data: any, path: string = undefined, - ajv: Ajv, + validator: RuleValidator, config: unknown ): boolean => { if (uischema.rule) { - return evalReadonly(uischema, data, path, ajv, config); + return evalReadonly(uischema, data, path, validator, config); } return false; diff --git a/packages/core/src/util/structural.ts b/packages/core/src/util/structural.ts new file mode 100644 index 0000000000..ede4db79d2 --- /dev/null +++ b/packages/core/src/util/structural.ts @@ -0,0 +1,385 @@ +/* + 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 isEqual from 'lodash/isEqual'; +import type { JsonSchema } from '../models'; +import { resolveSchema } from './resolvers'; + +/** + * The JSON Schema keywords the Structural Matcher evaluates. + * Combinator tab selection has always ignored every other keyword. + */ +export const structuralKeywords = [ + 'required', + 'additionalProperties', + 'type', + 'enum', + 'const', +] as const; + +type SchemaLike = JsonSchema & { + nullable?: boolean; + prefixItems?: JsonSchema[]; + if?: JsonSchema; + then?: JsonSchema; + else?: JsonSchema; +}; + +/** Schema/data pairs already being evaluated on the current path. */ +type Visited = ReadonlyArray; + +const isPlainObject = (data: unknown): data is Record => + typeof data === 'object' && data !== null && !Array.isArray(data); + +const matchesType = (type: string, data: unknown): boolean => { + switch (type) { + case 'null': + return data === null; + case 'boolean': + return typeof data === 'boolean'; + case 'string': + return typeof data === 'string'; + case 'number': + return typeof data === 'number'; + case 'integer': + return typeof data === 'number' && Number.isInteger(data); + case 'array': + return Array.isArray(data); + case 'object': + return isPlainObject(data); + default: + // Unknown type names cannot be decided structurally. + return true; + } +}; + +const isMultipleOf = (data: number, divisor: number): boolean => + Number.isInteger(data / divisor); + +/** String length in code points, as AJV counts it. */ +const codePointLength = (value: string): number => + // surrogate pairs count once; avoids string iteration for ES5 targets + value.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, '_').length; + +const compilePattern = (pattern: string): RegExp | undefined => { + try { + return new RegExp(pattern, 'u'); + } catch { + return undefined; + } +}; + +/** + * Structural Matcher: decides whether `data` fits `schema` by evaluating only + * the structural keywords `type`, `enum`, `const`, `required` and + * `additionalProperties`, recursively through `properties`, + * `patternProperties`, `items`, `$ref`, nested combinators and + * `if`/`then`/`else`. It needs no validator instance and generates no code, + * so it works under a Content Security Policy without `unsafe-eval`. + * + * Keywords outside that set (`format`, `pattern`, `minimum`, ...) are ignored, + * matching what combinator tab selection has always done. A `$ref` that + * cannot be resolved against `rootSchema` is treated as not matching. + * + * @param schema the schema to match against + * @param data the data to check + * @param rootSchema the root schema used to resolve `$ref`s; defaults to `schema` + * @returns `true` when no structural keyword rejects the data + */ +export const isStructuralMatch = ( + schema: JsonSchema | boolean | undefined, + data: unknown, + rootSchema?: JsonSchema +): boolean => { + const root = rootSchema ?? (typeof schema === 'object' ? schema : {}); + return match(schema, data, root, [], false); +}; + +/** + * The Structural Matcher extended with the value constraints rule conditions + * commonly use: `minimum`, `maximum`, `exclusiveMinimum`, + * `exclusiveMaximum`, `multipleOf`, `minLength`, `maxLength`, `pattern`, + * `minItems`, `maxItems`, `uniqueItems`, `minProperties`, `maxProperties` + * and `not`. Like {@link isStructuralMatch} it needs no validator instance + * and generates no code. `format` and `$data` references are not evaluated. + * + * Core uses it to evaluate schema based rule conditions when no validator is + * configured or the configured Form Validator has no `matches`. + * + * @param schema the schema to match against + * @param data the data to check + * @param rootSchema the root schema used to resolve `$ref`s; defaults to `schema` + * @returns `true` when no evaluated keyword rejects the data + */ +export const isSchemaMatch = ( + schema: JsonSchema | boolean | undefined, + data: unknown, + rootSchema?: JsonSchema +): boolean => { + const root = rootSchema ?? (typeof schema === 'object' ? schema : {}); + return match(schema, data, root, [], true); +}; + +const match = ( + schema: JsonSchema | boolean | undefined, + data: unknown, + rootSchema: JsonSchema, + visited: Visited, + extended: boolean +): boolean => { + if (schema === undefined || schema === null) { + return true; + } + if (typeof schema === 'boolean') { + return schema; + } + if (visited.some(([s, d]) => s === schema && d === data)) { + // The same schema applied to the same data again on this path: a + // recursive schema that cannot be decided any further structurally. + return true; + } + const nextVisited: Visited = [...visited, [schema, data]]; + + if (typeof schema.$ref === 'string') { + const resolved = resolveSchema(rootSchema, schema.$ref, rootSchema); + if (resolved === undefined) { + return false; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { $ref, ...siblings } = schema; + return ( + match(resolved, data, rootSchema, nextVisited, extended) && + matchKeywords(siblings, data, rootSchema, nextVisited, extended) + ); + } + return matchKeywords(schema, data, rootSchema, nextVisited, extended); +}; + +const matchKeywords = ( + schema: SchemaLike, + data: unknown, + rootSchema: JsonSchema, + visited: Visited, + extended: boolean +): boolean => { + const sub = (s: JsonSchema | boolean | undefined, d: unknown): boolean => + match(s, d, rootSchema, visited, extended); + + if (schema.nullable === true && data === null) { + return true; + } + if (schema.type !== undefined) { + const types = Array.isArray(schema.type) ? schema.type : [schema.type]; + if (!types.some((type) => matchesType(type, data))) { + return false; + } + } + if ( + Array.isArray(schema.enum) && + !schema.enum.some((value) => isEqual(value, data)) + ) { + return false; + } + if ('const' in schema && !isEqual(schema.const, data)) { + return false; + } + if (extended && !matchesValueConstraints(schema, data, sub)) { + return false; + } + if (Array.isArray(schema.allOf) && !schema.allOf.every((s) => sub(s, data))) { + return false; + } + if (Array.isArray(schema.anyOf) && !schema.anyOf.some((s) => sub(s, data))) { + return false; + } + // When several oneOf branches match, AJV reports only a `oneOf` error, which + // tab selection ignores. "At least one branch" is therefore the structural + // reading of oneOf. + if (Array.isArray(schema.oneOf) && !schema.oneOf.some((s) => sub(s, data))) { + return false; + } + if (schema.if !== undefined) { + const branch = sub(schema.if, data) ? schema.then : schema.else; + if (!sub(branch, data)) { + return false; + } + } + + if (isPlainObject(data)) { + if ( + Array.isArray(schema.required) && + !schema.required.every((key) => data[key] !== undefined) + ) { + return false; + } + const properties = schema.properties ?? {}; + const patterns = Object.entries(schema.patternProperties ?? {}) + .map(([pattern, s]) => ({ regex: compilePattern(pattern), schema: s })) + .filter( + (p): p is { regex: RegExp; schema: JsonSchema } => p.regex !== undefined + ); + for (const [key, value] of Object.entries(data)) { + if (value === undefined) { + continue; + } + const matchingPatterns = patterns.filter((p) => p.regex.test(key)); + const isDeclared = key in properties || matchingPatterns.length > 0; + if (key in properties && !sub(properties[key], value)) { + return false; + } + if (!matchingPatterns.every((p) => sub(p.schema, value))) { + return false; + } + if (!isDeclared && schema.additionalProperties !== undefined) { + if (schema.additionalProperties === false) { + return false; + } + if ( + typeof schema.additionalProperties === 'object' && + !sub(schema.additionalProperties, value) + ) { + return false; + } + } + } + } + + if (Array.isArray(data)) { + const tuple = Array.isArray(schema.prefixItems) + ? schema.prefixItems + : Array.isArray(schema.items) + ? schema.items + : undefined; + if (tuple !== undefined) { + if (!data.every((item, i) => i >= tuple.length || sub(tuple[i], item))) { + return false; + } + } else if ( + typeof schema.items === 'object' && + !data.every((item) => sub(schema.items as JsonSchema, item)) + ) { + return false; + } + } + return true; +}; + +/** + * The value constraints evaluated by {@link isSchemaMatch} on top of the + * structural keywords. Mirrors AJV's semantics for each keyword. + */ +const matchesValueConstraints = ( + schema: SchemaLike, + data: unknown, + sub: (s: JsonSchema | boolean | undefined, d: unknown) => boolean +): boolean => { + if (typeof data === 'number') { + if (typeof schema.minimum === 'number') { + if (schema.exclusiveMinimum === true) { + if (data <= schema.minimum) { + return false; + } + } else if (data < schema.minimum) { + return false; + } + } + if (typeof schema.maximum === 'number') { + if (schema.exclusiveMaximum === true) { + if (data >= schema.maximum) { + return false; + } + } else if (data > schema.maximum) { + return false; + } + } + if ( + typeof schema.exclusiveMinimum === 'number' && + data <= schema.exclusiveMinimum + ) { + return false; + } + if ( + typeof schema.exclusiveMaximum === 'number' && + data >= schema.exclusiveMaximum + ) { + return false; + } + if ( + typeof schema.multipleOf === 'number' && + !isMultipleOf(data, schema.multipleOf) + ) { + return false; + } + } + if (typeof data === 'string') { + const length = codePointLength(data); + if (typeof schema.minLength === 'number' && length < schema.minLength) { + return false; + } + if (typeof schema.maxLength === 'number' && length > schema.maxLength) { + return false; + } + if (typeof schema.pattern === 'string') { + const regex = compilePattern(schema.pattern); + if (regex !== undefined && !regex.test(data)) { + return false; + } + } + } + if (Array.isArray(data)) { + if (typeof schema.minItems === 'number' && data.length < schema.minItems) { + return false; + } + if (typeof schema.maxItems === 'number' && data.length > schema.maxItems) { + return false; + } + if ( + schema.uniqueItems === true && + data.some((item, i) => + data.slice(0, i).some((prev) => isEqual(prev, item)) + ) + ) { + return false; + } + } + if (isPlainObject(data)) { + const count = Object.values(data).filter((v) => v !== undefined).length; + if ( + typeof schema.minProperties === 'number' && + count < schema.minProperties + ) { + return false; + } + if ( + typeof schema.maxProperties === 'number' && + count > schema.maxProperties + ) { + return false; + } + } + if (schema.not !== undefined && sub(schema.not, data)) { + return false; + } + return true; +}; diff --git a/packages/core/src/util/util.ts b/packages/core/src/util/util.ts index ed4eae93d9..7bf2c4c8ef 100644 --- a/packages/core/src/util/util.ts +++ b/packages/core/src/util/util.ts @@ -29,7 +29,7 @@ import find from 'lodash/find'; import { resolveData, resolveSchema } from './resolvers'; import { composePaths, toDataPathSegments } from './path'; import { isEnabled, isVisible } from './runtime'; -import type Ajv from 'ajv'; +import type { RuleValidator } from './runtime'; import type { JsonSchema, Scoped, UISchemaElement } from '../models'; /** @@ -165,17 +165,17 @@ export const Runtime = { isEnabled( uischema: UISchemaElement, data: any, - ajv: Ajv, + validator: RuleValidator, config: unknown ): boolean { - return isEnabled(uischema, data, undefined, ajv, config); + return isEnabled(uischema, data, undefined, validator, config); }, isVisible( uischema: UISchemaElement, data: any, - ajv: Ajv, + validator: RuleValidator, config: unknown ): boolean { - return isVisible(uischema, data, undefined, ajv, config); + return isVisible(uischema, data, undefined, validator, config); }, }; diff --git a/packages/core/test/mappers/renderer.test.ts b/packages/core/test/mappers/renderer.test.ts index 9d0dd516cc..c466260866 100644 --- a/packages/core/test/mappers/renderer.test.ts +++ b/packages/core/test/mappers/renderer.test.ts @@ -1741,6 +1741,172 @@ test('mapStateToAnyOfProps - const constraint in anyOf schema should return corr t.is(props.indexOfFittingSchema, 2); }); +test('mapStateToOneOfProps - indexOfFittingSchema selects the branch whose required properties are present', (t) => { + const uischema: ControlElement = { type: 'Control', scope: '#' }; + const schema: JsonSchema7 = { + oneOf: [ + { + type: 'object', + properties: { iban: { type: 'string' } }, + required: ['iban'], + }, + { + type: 'object', + properties: { cardNumber: { type: 'string' } }, + required: ['cardNumber'], + }, + ], + }; + const state = { + jsonforms: { + core: { + ajv: createAjv(), + schema, + data: { cardNumber: '4111' }, + uischema, + errors: [] as ErrorObject[], + }, + }, + }; + const props = mapStateToOneOfProps(state, { uischema }); + t.is(props.indexOfFittingSchema, 1); +}); + +test('mapStateToOneOfProps - indexOfFittingSchema honours additionalProperties: false', (t) => { + const uischema: ControlElement = { type: 'Control', scope: '#' }; + const schema: JsonSchema7 = { + oneOf: [ + { + type: 'object', + properties: { name: { type: 'string' } }, + additionalProperties: false, + }, + { + type: 'object', + properties: { name: { type: 'string' }, age: { type: 'integer' } }, + }, + ], + }; + const state = { + jsonforms: { + core: { + ajv: createAjv(), + schema, + data: { name: 'Ada', age: 36 }, + uischema, + errors: [] as ErrorObject[], + }, + }, + }; + const props = mapStateToOneOfProps(state, { uischema }); + t.is(props.indexOfFittingSchema, 1); +}); + +test('mapStateToAnyOfProps - indexOfFittingSchema selects by primitive type', (t) => { + const uischema: ControlElement = { type: 'Control', scope: '#' }; + const schema: JsonSchema7 = { + anyOf: [{ type: 'string' }, { type: 'number' }, { type: 'boolean' }], + }; + const state = { + jsonforms: { + core: { + ajv: createAjv(), + schema, + data: true, + uischema, + errors: [] as ErrorObject[], + }, + }, + }; + const props = mapStateToAnyOfProps(state, { uischema }); + t.is(props.indexOfFittingSchema, 2); +}); + +test('mapStateToOneOfProps - indexOfFittingSchema resolves $ref branches against the root schema', (t) => { + const uischema: ControlElement = { + type: 'Control', + scope: '#/properties/pet', + }; + const schema: JsonSchema7 = { + type: 'object', + properties: { + pet: { + oneOf: [{ $ref: '#/definitions/cat' }, { $ref: '#/definitions/dog' }], + }, + }, + definitions: { + cat: { + type: 'object', + properties: { kind: { const: 'cat' } }, + required: ['kind'], + }, + dog: { + type: 'object', + properties: { kind: { const: 'dog' } }, + required: ['kind'], + }, + }, + }; + const state = { + jsonforms: { + core: { + ajv: createAjv(), + schema, + data: { pet: { kind: 'dog' } }, + uischema, + errors: [] as ErrorObject[], + }, + }, + }; + const props = mapStateToOneOfProps(state, { uischema }); + t.is(props.indexOfFittingSchema, 1); +}); + +test('mapStateToOneOfProps - indexOfFittingSchema works without an ajv instance in state', (t) => { + const uischema: ControlElement = { type: 'Control', scope: '#' }; + const schema: JsonSchema7 = { + oneOf: [ + { type: 'object', properties: { kind: { const: 'a' } } }, + { type: 'object', properties: { kind: { const: 'b' } } }, + ], + }; + const state = { + jsonforms: { + core: { + schema, + data: { kind: 'b' }, + uischema, + errors: [] as ErrorObject[], + }, + }, + }; + const props = mapStateToOneOfProps(state, { uischema }); + t.is(props.indexOfFittingSchema, 1); +}); + +test('mapStateToOneOfProps - indexOfFittingSchema is undefined when no branch fits', (t) => { + const uischema: ControlElement = { type: 'Control', scope: '#' }; + const schema: JsonSchema7 = { + oneOf: [ + { type: 'object', properties: { kind: { const: 'a' } } }, + { type: 'object', properties: { kind: { const: 'b' } } }, + ], + }; + const state = { + jsonforms: { + core: { + ajv: createAjv(), + schema, + data: { kind: 'c' }, + uischema, + errors: [] as ErrorObject[], + }, + }, + }; + const props = mapStateToOneOfProps(state, { uischema }); + t.is(props.indexOfFittingSchema, undefined); +}); + test('mapStateToControlProps - i18n - mapStateToControlProps should not crash without i18n', (t) => { const ownProps = { uischema: coreUISchema, diff --git a/packages/core/test/reducers/core.test.ts b/packages/core/test/reducers/core.test.ts index 1df87b84b1..cc9b38c297 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,313 @@ test('core reducer - setSchema - schema with id', (t) => { t.is(after.schema.properties.animal.minLength, 5); }); +test('core reducer - setAjv - stores the new ajv instance in state', (t) => { + const schema: JsonSchema = { + type: 'object', + properties: { + foo: { type: 'string' }, + }, + }; + const before: JsonFormsCore = coreReducer( + undefined, + init({ foo: 'bar' }, schema, undefined, undefined) + ); + const newAjv = createAjv(); + t.not(before.ajv, newAjv); + + const after: JsonFormsCore = coreReducer(before, setAjv(newAjv)); + t.is(after.ajv, newAjv); + t.not(after.validator, before.validator); +}); + +test('core reducer - setAjv - later setSchema compiles with the new ajv instance', (t) => { + // Two violated constraints: the default ajv (allErrors: true) reports both, + // an ajv with allErrors: false reports only the first one. + const schema: JsonSchema = { + type: 'object', + properties: { + foo: { type: 'string', minLength: 5, pattern: '^a' }, + }, + }; + const before: JsonFormsCore = coreReducer( + undefined, + init({ foo: 'b' }, schema, undefined, undefined) + ); + t.is(before.errors.length, 2); + + const firstErrorOnlyAjv = createAjv({ allErrors: false }); + const afterSetAjv: JsonFormsCore = coreReducer( + before, + setAjv(firstErrorOnlyAjv) + ); + t.is(afterSetAjv.errors.length, 1); + + const updatedSchema = cloneDeep(schema); + updatedSchema.properties.foo.minLength = 6; + const afterSetSchema: JsonFormsCore = coreReducer( + afterSetAjv, + setSchema(updatedSchema) + ); + t.is(afterSetSchema.errors.length, 1); +}); + +test('core reducer - setAjv - re-enabling validation compiles with the new ajv instance', (t) => { + const schema: JsonSchema = { + type: 'object', + properties: { + foo: { type: 'string', minLength: 5, pattern: '^a' }, + }, + }; + const before: JsonFormsCore = coreReducer( + undefined, + init({ foo: 'b' }, schema, undefined, undefined) + ); + const firstErrorOnlyAjv = createAjv({ allErrors: false }); + const afterSetAjv = coreReducer(before, setAjv(firstErrorOnlyAjv)); + const hidden = coreReducer(afterSetAjv, setValidationMode('NoValidation')); + t.is(hidden.errors.length, 0); + + const shown = coreReducer(hidden, setValidationMode('ValidateAndShow')); + t.is(shown.errors.length, 1); +}); + +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'); +}); diff --git a/packages/core/test/util/runtime.test.ts b/packages/core/test/util/runtime.test.ts index c72e45f98a..727317efb1 100644 --- a/packages/core/test/util/runtime.test.ts +++ b/packages/core/test/util/runtime.test.ts @@ -43,7 +43,12 @@ import { evalEnablement, evalReadonly, evalVisibility, + isVisible, + matchesConditionSchema, } from '../../src/util/runtime'; +import type { FormValidator } from '../../src/util/formValidator'; +import { getRuleValidator } from '../../src/store'; +import { JsonSchema } from '../../src/models'; test('evalVisibility show valid case', (t) => { const leafCondition: LeafCondition = { @@ -1115,7 +1120,7 @@ test('isInherentlyEnabled disabled by rule', (t) => { t.false( isInherentlyEnabled( - { jsonforms: { core: { ajv: createAjv() } as JsonFormsCore } }, + { jsonforms: { core: { ajv: createAjv() } as unknown as JsonFormsCore } }, null, uischema, undefined, @@ -1148,7 +1153,7 @@ test('isInherentlyEnabled disabled by global over rule ', (t) => { { jsonforms: { readonly: true, - core: { ajv: createAjv() } as JsonFormsCore, + core: { ajv: createAjv() } as unknown as JsonFormsCore, }, }, null, @@ -1344,7 +1349,7 @@ test('isInherentlyReadonly evaluates readonly and writable rules', (t) => { }; const state = { jsonforms: { - core: { ajv: createAjv() } as JsonFormsCore, + core: { ajv: createAjv() } as unknown as JsonFormsCore, }, }; const data = { @@ -1369,3 +1374,422 @@ test('isInherentlyReadonly evaluates readonly and writable rules', (t) => { ) ); }); + +const ruleUischema = ( + condition: SchemaBasedCondition | AndCondition | OrCondition +): ControlElement => ({ + type: 'Control', + scope: '#/properties/value', + rule: { effect: RuleEffect.SHOW, condition }, +}); + +const barCondition: SchemaBasedCondition = { + scope: '#/properties/ruleValue', + schema: { const: 'bar' }, +}; + +test('rules - schema condition uses the Form Validator matches when present', (t) => { + const calls: Array<[JsonSchema, unknown]> = []; + const validator: FormValidator = { + validate: () => [], + matches: (schema, data) => { + calls.push([schema, data]); + return data === 'bar'; + }, + }; + const uischema = ruleUischema(barCondition); + t.true( + evalVisibility( + uischema, + { ruleValue: 'bar' }, + undefined, + validator, + undefined + ) + ); + t.false( + evalVisibility( + uischema, + { ruleValue: 'baz' }, + undefined, + validator, + undefined + ) + ); + t.deepEqual(calls, [ + [barCondition.schema, 'bar'], + [barCondition.schema, 'baz'], + ]); +}); + +test('rules - a Form Validator without matches falls back to the Structural Matcher', (t) => { + const validator: FormValidator = { validate: () => [] }; + const uischema = ruleUischema(barCondition); + t.true( + evalVisibility( + uischema, + { ruleValue: 'bar' }, + undefined, + validator, + undefined + ) + ); + t.false( + evalVisibility( + uischema, + { ruleValue: 'baz' }, + undefined, + validator, + undefined + ) + ); +}); + +test('rules - no validator at all also evaluates value constraints', (t) => { + const uischema = ruleUischema({ + scope: '#/properties/ruleValue', + schema: { type: 'number', minimum: 18 }, + }); + t.true( + evalVisibility(uischema, { ruleValue: 18 }, undefined, undefined, undefined) + ); + t.false( + evalVisibility(uischema, { ruleValue: 17 }, undefined, undefined, undefined) + ); + const pattern = ruleUischema({ + scope: '#/properties/ruleValue', + schema: { pattern: '^DE' }, + }); + t.true( + evalVisibility( + pattern, + { ruleValue: 'DE123' }, + undefined, + undefined, + undefined + ) + ); + t.false( + evalVisibility( + pattern, + { ruleValue: 'FR123' }, + undefined, + undefined, + undefined + ) + ); +}); + +test('rules - a Form Validator without matches gets value constraints evaluated too', (t) => { + const validator: FormValidator = { validate: () => [] }; + const uischema = ruleUischema({ + scope: '#/properties/ruleValue', + schema: { not: { const: 'hidden' } }, + }); + t.true( + evalVisibility( + uischema, + { ruleValue: 'shown' }, + undefined, + validator, + undefined + ) + ); + t.false( + evalVisibility( + uischema, + { ruleValue: 'hidden' }, + undefined, + validator, + undefined + ) + ); +}); + +test('rules - no validator at all uses the Structural Matcher', (t) => { + const uischema = ruleUischema({ + scope: '#/properties/ruleValue', + schema: { type: 'string', enum: ['a', 'b'] }, + }); + t.true( + evalVisibility( + uischema, + { ruleValue: 'a' }, + undefined, + undefined, + undefined + ) + ); + t.false( + evalVisibility( + uischema, + { ruleValue: 'c' }, + undefined, + undefined, + undefined + ) + ); + t.false( + evalVisibility(uischema, { ruleValue: 1 }, undefined, undefined, undefined) + ); +}); + +test('rules - a Form Validator Factory is called once per condition schema', (t) => { + const compiled: JsonSchema[] = []; + const factory = (schema: JsonSchema): FormValidator => { + compiled.push(schema); + return { + validate: (data) => + data === (schema as { const: unknown }).const + ? [] + : [{ path: '', key: 'const', message: 'mismatch' }], + }; + }; + const uischema = ruleUischema(barCondition); + t.true( + evalVisibility( + uischema, + { ruleValue: 'bar' }, + undefined, + factory, + undefined + ) + ); + t.false( + evalVisibility( + uischema, + { ruleValue: 'baz' }, + undefined, + factory, + undefined + ) + ); + t.true( + evalVisibility( + uischema, + { ruleValue: 'bar' }, + undefined, + factory, + undefined + ) + ); + t.is(compiled.length, 1); + t.is(compiled[0], barCondition.schema); + + const other = ruleUischema({ + scope: '#/properties/ruleValue', + schema: { const: 'other' }, + }); + t.true( + evalVisibility(other, { ruleValue: 'other' }, undefined, factory, undefined) + ); + t.is(compiled.length, 2); +}); + +test('rules - AND and OR compose with a Form Validator', (t) => { + const validator: FormValidator = { + validate: () => [], + matches: (schema, data) => data === (schema as { const: unknown }).const, + }; + const and: AndCondition = { + type: 'AND', + conditions: [ + barCondition, + { scope: '#/properties/other', schema: { const: 1 } }, + ], + }; + const or: OrCondition = { type: 'OR', conditions: and.conditions }; + t.true( + evalVisibility( + ruleUischema(and), + { ruleValue: 'bar', other: 1 }, + undefined, + validator, + undefined + ) + ); + t.false( + evalVisibility( + ruleUischema(and), + { ruleValue: 'bar', other: 2 }, + undefined, + validator, + undefined + ) + ); + t.true( + evalVisibility( + ruleUischema(or), + { ruleValue: 'x', other: 1 }, + undefined, + validator, + undefined + ) + ); + t.false( + evalVisibility( + ruleUischema(or), + { ruleValue: 'x', other: 2 }, + undefined, + validator, + undefined + ) + ); +}); + +test('rules - an AJV instance keeps working as the rule validator', (t) => { + const uischema = ruleUischema({ + scope: '#/properties/ruleValue', + schema: { type: 'string', minLength: 3 }, + }); + t.true( + evalVisibility( + uischema, + { ruleValue: 'long' }, + undefined, + createAjv(), + undefined + ) + ); + t.false( + evalVisibility( + uischema, + { ruleValue: 'no' }, + undefined, + createAjv(), + undefined + ) + ); +}); + +test('rules - enablement and readonly go through the Form Validator too', (t) => { + const validator: FormValidator = { + validate: () => [], + matches: (_schema, data) => data === 'bar', + }; + const enable: ControlElement = { + type: 'Control', + scope: '#/properties/value', + rule: { effect: RuleEffect.ENABLE, condition: barCondition }, + }; + const readonly: ControlElement = { + type: 'Control', + scope: '#/properties/value', + rule: { effect: RuleEffect.READONLY, condition: barCondition }, + }; + t.true( + evalEnablement( + enable, + { ruleValue: 'bar' }, + undefined, + validator, + undefined + ) + ); + t.false( + evalEnablement(enable, { ruleValue: 'x' }, undefined, validator, undefined) + ); + t.true( + evalReadonly( + readonly, + { ruleValue: 'bar' }, + undefined, + validator, + undefined + ) + ); + t.false( + evalReadonly(readonly, { ruleValue: 'x' }, undefined, validator, undefined) + ); +}); + +test('matchesConditionSchema - dispatches on the validator kind', (t) => { + const schema: JsonSchema = { const: 'a' }; + t.true(matchesConditionSchema(schema, 'a', undefined)); + t.true(matchesConditionSchema(schema, 'a', createAjv())); + t.false(matchesConditionSchema(schema, 'b', createAjv())); + t.true(matchesConditionSchema(schema, 'a', { validate: () => [] })); + t.false( + matchesConditionSchema(schema, 'a', { + validate: () => [], + matches: () => false, + }) + ); + t.false( + matchesConditionSchema(schema, 'a', () => ({ + validate: () => [{ path: '', message: 'nope' }], + })) + ); +}); + +test('getRuleValidator - AJV instance when no custom validator is configured', (t) => { + const ajv = createAjv(); + const state = { jsonforms: { core: { ajv } as unknown as JsonFormsCore } }; + t.is(getRuleValidator(state as any), ajv); + t.is(getRuleValidator({ jsonforms: {} } as any), undefined); +}); + +test('getRuleValidator - the bound Form Validator when a custom one is configured', (t) => { + const ajv = createAjv(); + const bound: FormValidator = { validate: () => [] }; + const factory = () => bound; + const state = { + jsonforms: { + core: { + ajv, + validatorOption: factory, + formValidator: bound, + } as unknown as JsonFormsCore, + }, + }; + t.is(getRuleValidator(state as any), bound); + const off = { + jsonforms: { + core: { + ajv, + validatorOption: factory, + formValidator: undefined, + } as unknown as JsonFormsCore, + }, + }; + t.is(getRuleValidator(off as any), factory); +}); + +test('isVisible - integrates with getRuleValidator for a custom validator', (t) => { + const bound: FormValidator = { + validate: () => [], + matches: (_schema, data) => data === 'bar', + }; + const core = { + ajv: { + validate: () => { + throw new Error('AJV must not evaluate rules when a validator is set'); + }, + compile: () => { + throw new Error('no compile'); + }, + }, + validatorOption: bound, + formValidator: bound, + } as unknown as JsonFormsCore; + const state = { jsonforms: { core } } as any; + const uischema = ruleUischema(barCondition); + t.true( + isVisible( + uischema, + { ruleValue: 'bar' }, + undefined, + getRuleValidator(state), + undefined + ) + ); + t.false( + isVisible( + uischema, + { ruleValue: 'x' }, + undefined, + getRuleValidator(state), + undefined + ) + ); +}); diff --git a/packages/core/test/util/structural.test.ts b/packages/core/test/util/structural.test.ts new file mode 100644 index 0000000000..ce21b98258 --- /dev/null +++ b/packages/core/test/util/structural.test.ts @@ -0,0 +1,453 @@ +/* + 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 { + isSchemaMatch, + isStructuralMatch, + structuralKeywords, +} from '../../src/util/structural'; +import { createAjv } from '../../src/util/validator'; + +/** + * The behaviour the Structural Matcher replaces: compile the branch with AJV, + * validate, and ignore every error whose keyword is not structural. + */ +const ajvStructuralMatch = (schema: JsonSchema, data: unknown): boolean => { + const validate = createAjv().compile(schema); + validate(data); + const errors: ErrorObject[] = validate.errors ?? []; + return !errors.some((e) => + (structuralKeywords as readonly string[]).includes(e.keyword) + ); +}; + +const cases: Array<{ name: string; schema: JsonSchema; data: unknown }> = [ + { name: 'type string ok', schema: { type: 'string' }, data: 'a' }, + { name: 'type string fail', schema: { type: 'string' }, data: 1 }, + { name: 'type integer ok', schema: { type: 'integer' }, data: 2 }, + { name: 'type integer fail', schema: { type: 'integer' }, data: 2.5 }, + { name: 'type number ok', schema: { type: 'number' }, data: 2.5 }, + { name: 'type null ok', schema: { type: 'null' }, data: null }, + { name: 'type object fail on array', schema: { type: 'object' }, data: [] }, + { name: 'type array ok', schema: { type: 'array' }, data: [] }, + { + name: 'type union ok', + schema: { type: ['string', 'null'] }, + data: null, + }, + { + name: 'type union fail', + schema: { type: ['string', 'null'] }, + data: 1, + }, + { name: 'enum ok', schema: { enum: ['a', 'b'] }, data: 'b' }, + { name: 'enum fail', schema: { enum: ['a', 'b'] }, data: 'c' }, + { + name: 'enum deep equal ok', + schema: { enum: [{ x: 1 }] }, + data: { x: 1 }, + }, + { name: 'const ok', schema: { const: 5 }, data: 5 }, + { name: 'const fail', schema: { const: 5 }, data: 6 }, + { name: 'const null ok', schema: { const: null }, data: null }, + { + name: 'required ok', + schema: { type: 'object', required: ['a'] }, + data: { a: 1 }, + }, + { + name: 'required fail', + schema: { type: 'object', required: ['a'] }, + data: { b: 1 }, + }, + { + name: 'required ignored on non-object', + schema: { required: ['a'] }, + data: 'str', + }, + { + name: 'nested properties type fail', + schema: { + type: 'object', + properties: { + a: { type: 'object', properties: { b: { type: 'string' } } }, + }, + }, + data: { a: { b: 1 } }, + }, + { + name: 'nested properties ok', + schema: { + type: 'object', + properties: { + a: { type: 'object', properties: { b: { type: 'string' } } }, + }, + }, + data: { a: { b: 'x' } }, + }, + { + name: 'additionalProperties false fail', + schema: { + type: 'object', + properties: { a: { type: 'string' } }, + additionalProperties: false, + }, + data: { a: 'x', b: 1 }, + }, + { + name: 'additionalProperties false ok', + schema: { + type: 'object', + properties: { a: { type: 'string' } }, + additionalProperties: false, + }, + data: { a: 'x' }, + }, + { + name: 'additionalProperties schema fail', + schema: { + type: 'object', + properties: { a: { type: 'string' } }, + additionalProperties: { type: 'number' }, + }, + data: { a: 'x', b: 'not a number' }, + }, + { + name: 'additionalProperties with patternProperties ok', + schema: { + type: 'object', + patternProperties: { '^x-': { type: 'string' } }, + additionalProperties: false, + }, + data: { 'x-a': 'ok' }, + }, + { + name: 'patternProperties type fail', + schema: { + type: 'object', + patternProperties: { '^x-': { type: 'string' } }, + }, + data: { 'x-a': 1 }, + }, + { + name: 'items type fail', + schema: { type: 'array', items: { type: 'string' } }, + data: ['a', 1], + }, + { + name: 'items type ok', + schema: { type: 'array', items: { type: 'string' } }, + data: ['a', 'b'], + }, + { + name: 'tuple items fail', + schema: { type: 'array', items: [{ type: 'string' }, { type: 'number' }] }, + data: ['a', 'b'], + }, + { + name: 'nested anyOf ok', + schema: { + type: 'object', + properties: { v: { anyOf: [{ type: 'string' }, { type: 'number' }] } }, + }, + data: { v: 1 }, + }, + { + name: 'nested anyOf fail', + schema: { + type: 'object', + properties: { v: { anyOf: [{ type: 'string' }, { type: 'number' }] } }, + }, + data: { v: true }, + }, + { + name: 'nested allOf fail', + schema: { allOf: [{ type: 'object' }, { required: ['a'] }] }, + data: { b: 1 }, + }, + { + name: 'nested oneOf none fail', + schema: { oneOf: [{ const: 'a' }, { const: 'b' }] }, + data: 'c', + }, + { + name: 'nested oneOf several match is structurally fine', + schema: { oneOf: [{ type: 'string' }, { const: 'a' }] }, + data: 'a', + }, + { + name: 'not is ignored like AJV filtering does', + schema: { not: { type: 'string' } }, + data: 'a', + }, + { + name: 'non-structural keywords are ignored (pattern, minLength, format)', + schema: { + type: 'string', + pattern: '^a', + minLength: 10, + format: 'email', + }, + data: 'b', + }, + { + name: 'if then else uses then branch', + schema: { + type: 'object', + if: { properties: { kind: { const: 'a' } } }, + then: { required: ['aValue'] }, + else: { required: ['bValue'] }, + }, + data: { kind: 'a', bValue: 1 }, + }, + { + name: 'if then else uses else branch', + schema: { + type: 'object', + if: { properties: { kind: { const: 'a' } } }, + then: { required: ['aValue'] }, + else: { required: ['bValue'] }, + }, + data: { kind: 'b', bValue: 1 }, + }, + { + name: '$ref resolved against root', + schema: { + type: 'object', + properties: { pet: { $ref: '#/definitions/cat' } }, + definitions: { + cat: { type: 'object', required: ['meow'] }, + }, + }, + data: { pet: { bark: true } }, + }, + { + name: 'nullable with type allows null', + schema: { type: 'string', nullable: true } as JsonSchema, + data: null, + }, +]; + +for (const c of cases) { + test(`isStructuralMatch agrees with filtered AJV: ${c.name}`, (t) => { + t.is( + isStructuralMatch(c.schema, c.data), + ajvStructuralMatch(c.schema, c.data) + ); + }); +} + +test('isStructuralMatch - boolean schemas', (t) => { + t.true(isStructuralMatch(true, 'anything')); + t.false(isStructuralMatch(false, 'anything')); + t.true(isStructuralMatch(undefined, 'anything')); +}); + +test('isStructuralMatch - unresolvable $ref does not match', (t) => { + const root: JsonSchema = { definitions: {} }; + t.false(isStructuralMatch({ $ref: '#/definitions/missing' }, {}, root)); +}); + +test('isStructuralMatch - $ref branch resolved via explicit root schema', (t) => { + const root: JsonSchema = { + definitions: { + dog: { + type: 'object', + properties: { kind: { const: 'dog' } }, + required: ['kind'], + }, + }, + }; + t.true( + isStructuralMatch({ $ref: '#/definitions/dog' }, { kind: 'dog' }, root) + ); + t.false( + isStructuralMatch({ $ref: '#/definitions/dog' }, { kind: 'cat' }, root) + ); +}); + +test('isStructuralMatch - recursive schema terminates', (t) => { + const root: JsonSchema = { + definitions: { + node: { + type: 'object', + properties: { + children: { type: 'array', items: { $ref: '#/definitions/node' } }, + }, + }, + }, + }; + const data: unknown = { children: [{ children: [{ children: [] }] }] }; + t.true(isStructuralMatch({ $ref: '#/definitions/node' }, data, root)); + t.false( + isStructuralMatch( + { $ref: '#/definitions/node' }, + { children: [{ children: 'not an array' }] }, + root + ) + ); +}); + +test('isStructuralMatch - invalid patternProperties regex is ignored', (t) => { + const schema: JsonSchema = { + type: 'object', + patternProperties: { '(': { type: 'string' } }, + }; + t.true(isStructuralMatch(schema, { a: 1 })); +}); + +test('isStructuralMatch - undefined property values do not count as present', (t) => { + const schema: JsonSchema = { + type: 'object', + required: ['a'], + properties: { a: { type: 'string' } }, + additionalProperties: false, + }; + t.false(isStructuralMatch(schema, { a: undefined })); + t.true(isStructuralMatch(schema, { a: 'x', b: undefined })); +}); + +/** Full AJV validity, the reference for isSchemaMatch. */ +const ajvValid = (schema: JsonSchema, data: unknown): boolean => + createAjv().validate(schema, data) as boolean; + +const valueCases: Array<{ name: string; schema: JsonSchema; data: unknown }> = [ + { name: 'minimum ok', schema: { minimum: 3 }, data: 3 }, + { name: 'minimum fail', schema: { minimum: 3 }, data: 2.5 }, + { name: 'maximum ok', schema: { maximum: 3 }, data: 3 }, + { name: 'maximum fail', schema: { maximum: 3 }, data: 4 }, + { name: 'exclusiveMinimum ok', schema: { exclusiveMinimum: 3 }, data: 3.1 }, + { name: 'exclusiveMinimum fail', schema: { exclusiveMinimum: 3 }, data: 3 }, + { name: 'exclusiveMaximum ok', schema: { exclusiveMaximum: 3 }, data: 2 }, + { name: 'exclusiveMaximum fail', schema: { exclusiveMaximum: 3 }, data: 3 }, + { name: 'multipleOf ok', schema: { multipleOf: 0.5 }, data: 2.5 }, + { name: 'multipleOf fail', schema: { multipleOf: 2 }, data: 3 }, + { + name: 'numeric keywords ignore strings', + schema: { minimum: 3 }, + data: 'a', + }, + { name: 'minLength ok', schema: { minLength: 2 }, data: 'ab' }, + { name: 'minLength fail', schema: { minLength: 2 }, data: 'a' }, + { + name: 'minLength counts code points', + schema: { minLength: 2 }, + data: '😀', + }, + { name: 'maxLength ok', schema: { maxLength: 2 }, data: 'ab' }, + { name: 'maxLength fail', schema: { maxLength: 2 }, data: 'abc' }, + { name: 'pattern ok', schema: { pattern: '^a.c$' }, data: 'abc' }, + { name: 'pattern fail', schema: { pattern: '^a.c$' }, data: 'abd' }, + { name: 'pattern is unanchored', schema: { pattern: 'b' }, data: 'abc' }, + { name: 'minItems fail', schema: { minItems: 2 }, data: [1] }, + { name: 'maxItems fail', schema: { maxItems: 1 }, data: [1, 2] }, + { + name: 'uniqueItems ok', + schema: { uniqueItems: true }, + data: [1, 2, { a: 1 }], + }, + { + name: 'uniqueItems fail', + schema: { uniqueItems: true }, + data: [{ a: 1 }, { a: 1 }], + }, + { name: 'minProperties fail', schema: { minProperties: 2 }, data: { a: 1 } }, + { + name: 'maxProperties fail', + schema: { maxProperties: 1 }, + data: { a: 1, b: 2 }, + }, + { name: 'not ok', schema: { not: { type: 'string' } }, data: 1 }, + { name: 'not fail', schema: { not: { type: 'string' } }, data: 'a' }, + { name: 'not with const fail', schema: { not: { const: 'x' } }, data: 'x' }, + { + name: 'nested value constraint in properties', + schema: { + type: 'object', + properties: { age: { type: 'integer', minimum: 18 } }, + }, + data: { age: 17 }, + }, + { + name: 'value constraint inside anyOf', + schema: { + anyOf: [ + { type: 'string', minLength: 5 }, + { type: 'number', minimum: 10 }, + ], + }, + data: 12, + }, + { + name: 'value constraint inside anyOf fail', + schema: { + anyOf: [ + { type: 'string', minLength: 5 }, + { type: 'number', minimum: 10 }, + ], + }, + data: 3, + }, + { + name: 'rule-like condition: range', + schema: { type: 'number', minimum: 1, maximum: 10 }, + data: 5, + }, +]; + +for (const c of valueCases) { + test(`isSchemaMatch agrees with AJV: ${c.name}`, (t) => { + t.is(isSchemaMatch(c.schema, c.data), ajvValid(c.schema, c.data)); + }); +} + +test('isSchemaMatch - draft-04 boolean exclusiveMinimum/exclusiveMaximum', (t) => { + const min = { minimum: 3, exclusiveMinimum: true } as unknown as JsonSchema; + t.false(isSchemaMatch(min, 3)); + t.true(isSchemaMatch(min, 3.1)); + const max = { maximum: 3, exclusiveMaximum: true } as unknown as JsonSchema; + t.false(isSchemaMatch(max, 3)); + t.true(isSchemaMatch(max, 2.9)); +}); + +test('isSchemaMatch - invalid pattern is ignored, format is not evaluated', (t) => { + t.true(isSchemaMatch({ pattern: '(' }, 'anything')); + t.true(isSchemaMatch({ type: 'string', format: 'email' }, 'not an email')); +}); + +test('isStructuralMatch - still ignores value constraints', (t) => { + t.true(isStructuralMatch({ minimum: 3 }, 1)); + t.true(isStructuralMatch({ pattern: '^a' }, 'b')); + t.true(isStructuralMatch({ not: { type: 'string' } }, 'a')); + t.true(isStructuralMatch({ minLength: 10 }, 'short')); +}); + +test('isSchemaMatch - also enforces every structural keyword', (t) => { + t.false(isSchemaMatch({ type: 'string' }, 1)); + t.false(isSchemaMatch({ required: ['a'] }, {})); + t.true(isSchemaMatch({ enum: ['a'] }, 'a')); +}); diff --git a/packages/examples-react/src/App.tsx b/packages/examples-react/src/App.tsx index 269ccb3d6e..152f5209cb 100644 --- a/packages/examples-react/src/App.tsx +++ b/packages/examples-react/src/App.tsx @@ -59,6 +59,7 @@ const getProps = ( const uischemas = example.uischemas; const config = example.config; const i18n = example.i18n; + const validator = example.validator; return { schema, uischema, @@ -68,6 +69,7 @@ const getProps = ( cells, renderers, i18n, + validator, }; }; diff --git a/packages/examples/src/example.ts b/packages/examples/src/example.ts index f4e1407010..d5f2d83aa4 100644 --- a/packages/examples/src/example.ts +++ b/packages/examples/src/example.ts @@ -29,6 +29,7 @@ import { JsonSchema, UISchemaElement, JsonFormsI18nState, + ValidatorOption, } from '@jsonforms/core'; export interface ExampleDescription { @@ -42,6 +43,8 @@ export interface ExampleDescription { actions?: { label: string; apply: (props: StateProps) => any }[]; i18n?: JsonFormsI18nState; readonly?: boolean; + /** A custom Form Validator for this example; the apps pass it to JSON Forms' `validator` prop. */ + validator?: ValidatorOption; } export interface StateProps { @@ -53,4 +56,6 @@ export interface StateProps { config?: any; uischemas?: JsonFormsUISchemaRegistryEntry[]; readonly?: boolean; + /** A custom Form Validator for this example; the apps pass it to JSON Forms' `validator` prop. */ + validator?: ValidatorOption; } diff --git a/packages/examples/src/examples/custom-validator.ts b/packages/examples/src/examples/custom-validator.ts new file mode 100644 index 0000000000..97248c9f28 --- /dev/null +++ b/packages/examples/src/examples/custom-validator.ts @@ -0,0 +1,141 @@ +/* + 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 { + FormValidatorFactory, + JsonSchema, + UISchemaElement, + ValidationIssue, +} from '@jsonforms/core'; +import { registerExamples } from '../register'; + +export const schema: JsonSchema = { + type: 'object', + properties: { + username: { + type: 'string', + description: 'Lowercase letters only, checked by the custom validator', + }, + email: { + type: 'string', + description: 'Must contain a dot, checked by the custom validator', + }, + newsletter: { + type: 'boolean', + }, + frequency: { + type: 'string', + enum: ['daily', 'weekly', 'monthly'], + }, + }, + required: ['username'], +}; + +export const uischema: UISchemaElement = { + type: 'VerticalLayout', + elements: [ + { type: 'Control', scope: '#/properties/username' }, + { type: 'Control', scope: '#/properties/email' }, + { type: 'Control', scope: '#/properties/newsletter' }, + { + type: 'Control', + scope: '#/properties/frequency', + rule: { + effect: 'SHOW', + condition: { + scope: '#/properties/newsletter', + schema: { const: true }, + }, + }, + }, + ], +} as UISchemaElement; + +export const data = { + username: 'Ada Lovelace', + email: 'ada@example', + newsletter: true, +}; + +/** + * A Form Validator written by hand, without any validation library: it + * checks `required`, that string values are lowercase and that an `email` + * contains a dot. It compiles nothing and evaluates nothing, so it also works + * under a Content Security Policy without `unsafe-eval`. + * + * It has no `matches`, so the rule on `frequency` is evaluated by core's + * Structural Matcher. + */ +export const createHandwrittenValidator: FormValidatorFactory = ( + formSchema +) => ({ + validate: (formData) => { + const issues: ValidationIssue[] = []; + const values = (formData ?? {}) as Record; + for (const key of formSchema.required ?? []) { + if (values[key] === undefined || values[key] === '') { + issues.push({ + path: '', + key: 'required', + message: 'is a required property', + params: { missingProperty: key }, + }); + } + } + for (const [key, propertySchema] of Object.entries( + formSchema.properties ?? {} + )) { + const value = values[key]; + if (propertySchema.type !== 'string' || typeof value !== 'string') { + continue; + } + if (value !== value.toLowerCase()) { + issues.push({ + path: `/${key}`, + key: 'lowercase', + message: 'must be lowercase', + }); + } + if (key === 'email' && value !== '' && !value.includes('.')) { + issues.push({ + path: `/${key}`, + key: 'format', + message: 'must contain a dot', + }); + } + } + return issues; + }, +}); + +registerExamples([ + { + name: 'custom-validator', + label: 'Custom validator', + data, + schema, + uischema, + validator: createHandwrittenValidator, + }, +]); diff --git a/packages/examples/src/index.ts b/packages/examples/src/index.ts index cf75ae737f..eb2a6886ce 100644 --- a/packages/examples/src/index.ts +++ b/packages/examples/src/index.ts @@ -25,6 +25,7 @@ import * as issue_1884 from './examples/1884'; import * as issue_1948 from './examples/1948'; import * as additionalErrors from './examples/additional-errors'; +import * as customValidator from './examples/custom-validator'; import * as additionalProperties from './examples/additional-properties'; import * as allOf from './examples/allOf'; import * as anyOf from './examples/anyOf'; @@ -91,6 +92,7 @@ import * as jsonschema from './examples/jsonschema'; export { additionalErrors, + customValidator, additionalProperties, allOf, anyOf, diff --git a/packages/material-renderers/src/layouts/MaterialCategorizationLayout.tsx b/packages/material-renderers/src/layouts/MaterialCategorizationLayout.tsx index 976f953ed1..bc20a4d24c 100644 --- a/packages/material-renderers/src/layouts/MaterialCategorizationLayout.tsx +++ b/packages/material-renderers/src/layouts/MaterialCategorizationLayout.tsx @@ -100,6 +100,7 @@ export const MaterialCategorizationLayoutRenderer = ( onChange, config, ajv, + ruleValidator, t, } = props; const categorization = uischema as Categorization; @@ -109,9 +110,9 @@ export const MaterialCategorizationLayoutRenderer = ( const categories = useMemo( () => categorization.elements.filter((category: Category) => - isVisible(category, data, undefined, ajv, config) + isVisible(category, data, undefined, ruleValidator ?? ajv, config) ), - [categorization, data, ajv, config] + [categorization, data, ajv, ruleValidator, config] ); if (categorization !== previousCategorization) { diff --git a/packages/material-renderers/src/layouts/MaterialCategorizationStepperLayout.tsx b/packages/material-renderers/src/layouts/MaterialCategorizationStepperLayout.tsx index e8622494ea..fe5281e432 100644 --- a/packages/material-renderers/src/layouts/MaterialCategorizationStepperLayout.tsx +++ b/packages/material-renderers/src/layouts/MaterialCategorizationStepperLayout.tsx @@ -92,6 +92,7 @@ export const MaterialCategorizationStepperLayoutRenderer = ( cells, config, ajv, + ruleValidator, t, } = props; const categorization = uischema as Categorization; @@ -110,9 +111,9 @@ export const MaterialCategorizationStepperLayoutRenderer = ( const categories = useMemo( () => categorization.elements.filter((category: Category) => - isVisible(category, data, undefined, ajv, config) + isVisible(category, data, undefined, ruleValidator ?? ajv, config) ), - [categorization, data, ajv, config] + [categorization, data, ajv, ruleValidator, config] ); const childProps: MaterialLayoutRendererProps = { elements: categories[activeCategory].elements, diff --git a/packages/material-renderers/src/util/layout.tsx b/packages/material-renderers/src/util/layout.tsx index 9adac5f825..fa514c4c2e 100644 --- a/packages/material-renderers/src/util/layout.tsx +++ b/packages/material-renderers/src/util/layout.tsx @@ -32,6 +32,8 @@ import { JsonFormsRendererRegistryEntry, JsonSchema, OwnPropsOfRenderer, + getRuleValidator, + RuleValidator, } from '@jsonforms/core'; import { JsonFormsDispatch, useJsonForms } from '@jsonforms/react'; import Grid from '@mui/material/Grid'; @@ -110,6 +112,11 @@ export const MaterialLayoutRenderer = React.memo( export interface AjvProps { ajv: Ajv; + /** + * What rule conditions are evaluated with: the configured Form Validator + * when a custom one is set, otherwise the Ajv instance. + */ + ruleValidator?: RuleValidator; } // TODO fix @typescript-eslint/ban-types @@ -119,9 +126,11 @@ export const withAjvProps =

( ) => function WithAjvProps(props: P) { const ctx = useJsonForms(); - const ajv = getAjv({ jsonforms: { ...ctx } }); + const state = { jsonforms: { ...ctx } }; + const ajv = getAjv(state); + const ruleValidator = getRuleValidator(state); - return ; + return ; }; export interface MaterialLabelableLayoutRendererProps diff --git a/packages/react/src/JsonForms.tsx b/packages/react/src/JsonForms.tsx index 3538de0af8..53db7a96b9 100644 --- a/packages/react/src/JsonForms.tsx +++ b/packages/react/src/JsonForms.tsx @@ -42,6 +42,7 @@ import { OwnPropsOfJsonFormsRenderer, UISchemaElement, ValidationMode, + ValidatorOption, } from '@jsonforms/core'; import { JsonFormsStateProvider, @@ -190,6 +191,11 @@ export interface JsonFormsInitStateProps { renderers: JsonFormsRendererRegistryEntry[]; cells?: JsonFormsCellRendererRegistryEntry[]; ajv?: Ajv; + /** + * A custom Form Validator: a factory producing one per schema, or a + * validator already bound to the schema. Takes precedence over `ajv`. + */ + validator?: ValidatorOption; config?: any; uischemas?: JsonFormsUISchemaRegistryEntry[]; readonly?: boolean; @@ -203,6 +209,7 @@ export const JsonForms = ( ) => { const { ajv, + validator, data, schema, uischema, @@ -234,6 +241,7 @@ export const JsonForms = ( initState={{ core: { ajv, + validatorOption: validator, data, schema: schemaToUse, uischema: uischemaToUse, diff --git a/packages/react/src/JsonFormsContext.tsx b/packages/react/src/JsonFormsContext.tsx index 9006ffbe6a..f5c0ab9c95 100644 --- a/packages/react/src/JsonFormsContext.tsx +++ b/packages/react/src/JsonFormsContext.tsx @@ -138,8 +138,15 @@ export const JsonFormsStateProvider = ({ onChange, middleware, }: any) => { - const { data, schema, uischema, ajv, validationMode, additionalErrors } = - initState.core; + const { + data, + schema, + uischema, + ajv, + validatorOption, + validationMode, + additionalErrors, + } = initState.core; const middlewareRef = useRef(middleware ?? defaultMiddleware); middlewareRef.current = middleware ?? defaultMiddleware; @@ -149,6 +156,7 @@ export const JsonFormsStateProvider = ({ initState.core, Actions.init(data, schema, uischema, { ajv, + validator: validatorOption, validationMode, additionalErrors, }), @@ -163,13 +171,22 @@ export const JsonFormsStateProvider = ({ currentCore, Actions.updateCore(data, schema, uischema, { ajv, + validator: validatorOption, validationMode, additionalErrors, }), coreReducer ) ), - [data, schema, uischema, ajv, validationMode, additionalErrors] + [ + data, + schema, + uischema, + ajv, + validatorOption, + validationMode, + additionalErrors, + ] ); const [config, configDispatch] = useReducer(configReducer, undefined, () => diff --git a/packages/react/test/renderers/JsonForms.test.tsx b/packages/react/test/renderers/JsonForms.test.tsx index fb142d4f16..2a91e04ff5 100644 --- a/packages/react/test/renderers/JsonForms.test.tsx +++ b/packages/react/test/renderers/JsonForms.test.tsx @@ -1042,6 +1042,43 @@ test('JsonForms should use additionalErrors if provided', () => { wrapper.unmount(); }); +test('JsonForms should pass the validator option to core and show its errors', () => { + const CustomRendererWithError: React.FC = ({ + errors, + }: ControlProps) => { + return

{errors}
; + }; + const renderers = [ + { + tester: () => 1000, + renderer: withJsonFormsControlProps(CustomRendererWithError), + }, + ]; + const compiled: JsonSchema[] = []; + const validator = (schema: JsonSchema) => { + compiled.push(schema); + return { + validate: () => [ + { path: '/foo', key: 'custom', message: 'Custom validator says no' }, + ], + }; + }; + const wrapper = mount( + + ); + const initState = wrapper.find(JsonFormsStateProvider).props().initState; + expect(initState.core.validatorOption).toBe(validator); + expect(compiled).toEqual([fixture.schema]); + expect(wrapper.find('h5').text()).toBe('Custom validator says no'); + wrapper.unmount(); +}); + test('JsonForms should use react to additionalErrors update', () => { const CustomRendererWithError: React.FC = ({ errors, diff --git a/packages/vanilla-renderers/src/complex/categorization/CategorizationList.tsx b/packages/vanilla-renderers/src/complex/categorization/CategorizationList.tsx index 4b354b432d..91d7a1b139 100644 --- a/packages/vanilla-renderers/src/complex/categorization/CategorizationList.tsx +++ b/packages/vanilla-renderers/src/complex/categorization/CategorizationList.tsx @@ -60,13 +60,14 @@ export const CategorizationList = ({ groupClassName, t, ajv, + ruleValidator, config, }: CategorizationProps & AjvProps) => { const filteredElements = useMemo(() => { return elements.filter((category: Category | Categorization) => - isVisible(category, data, undefined, ajv, config) + isVisible(category, data, undefined, ruleValidator ?? ajv, config) ); - }, [elements, data, ajv, config]); + }, [elements, data, ajv, ruleValidator, config]); const categoryLabels = useMemo( () => filteredElements.map((cat) => deriveLabelForUISchemaElement(cat, t)), @@ -85,6 +86,7 @@ export const CategorizationList = ({ elements={category.elements} data={data} ajv={ajv} + ruleValidator={ruleValidator} config={config} depth={depth + 1} onSelect={onSelect} diff --git a/packages/vanilla-renderers/src/complex/categorization/CategorizationRenderer.tsx b/packages/vanilla-renderers/src/complex/categorization/CategorizationRenderer.tsx index 695af5a5ab..9e23c1d8c2 100644 --- a/packages/vanilla-renderers/src/complex/categorization/CategorizationRenderer.tsx +++ b/packages/vanilla-renderers/src/complex/categorization/CategorizationRenderer.tsx @@ -54,6 +54,7 @@ export const CategorizationRenderer = ({ getStyleAsClassName, onChange, ajv, + ruleValidator, config, }: LayoutProps & VanillaRendererProps & @@ -98,6 +99,7 @@ export const CategorizationRenderer = ({ selectedCategory={elements[safeCategory] as Category} data={data} ajv={ajv} + ruleValidator={ruleValidator} config={config} depth={0} onSelect={onCategorySelected} diff --git a/packages/vanilla-renderers/src/util/props.tsx b/packages/vanilla-renderers/src/util/props.tsx index 2da1a1607a..795a49406a 100644 --- a/packages/vanilla-renderers/src/util/props.tsx +++ b/packages/vanilla-renderers/src/util/props.tsx @@ -35,8 +35,14 @@ import type { RendererProps, StatePropsOfCell, StatePropsOfControl, + RuleValidator, +} from '@jsonforms/core'; +import { + convertToValidClassName, + getAjv, + getConfig, + getRuleValidator, } from '@jsonforms/core'; -import { convertToValidClassName, getAjv, getConfig } from '@jsonforms/core'; import { useJsonForms } from '@jsonforms/react'; import { getStyle, getStyleAsClassName } from '../reducers'; import { findStyle, findStyleAsClassName } from '../reducers/styling'; @@ -48,6 +54,11 @@ export interface WithClassname { export interface AjvProps { ajv: Ajv; + /** + * What rule conditions are evaluated with: the configured Form Validator + * when a custom one is set, otherwise the Ajv instance. + */ + ruleValidator?: RuleValidator; } export interface WithChildren { @@ -278,9 +289,11 @@ export const withAjvProps =

( ) => function WithAjvProps(props: P) { const ctx = useJsonForms(); - const ajv = getAjv({ jsonforms: { ...ctx } }); + const state = { jsonforms: { ...ctx } }; + const ajv = getAjv(state); + const ruleValidator = getRuleValidator(state); - return ; + return ; }; export const withVanillaCellProps = diff --git a/packages/vue-vanilla/dev/components/App.vue b/packages/vue-vanilla/dev/components/App.vue index 59dc1afd0b..7e9ac65059 100644 --- a/packages/vue-vanilla/dev/components/App.vue +++ b/packages/vue-vanilla/dev/components/App.vue @@ -136,6 +136,7 @@ export default defineComponent({ :data="example.data" :schema="example.schema" :uischema="example.uischema" + :validator="example.validator" :renderers="renderers" :i18n="example.i18n" :additional-errors="additionalErrors" @@ -208,7 +209,6 @@ main article { } -