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..e0377c1ce6 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 = ( @@ -94,10 +106,17 @@ export const getAdditionalErrors = ( return state.additionalErrors; }; +/** + * The AJV instance for an action: the one given in the action's options, the + * one already in state, or, when `createDefault` is true, a new default + * instance. Core passes `createDefault = false` while a custom Form Validator + * is configured, so no AJV instance exists unless the adopter provides one. + */ export const getOrCreateAjv = ( state: JsonFormsCore, - action?: InitAction | UpdateCoreAction -): Ajv => { + action?: InitAction | UpdateCoreAction, + createDefault = true +): Ajv | undefined => { if (action) { if (hasAjvOption(action.options)) { // options object with ajv @@ -109,7 +128,10 @@ export const getOrCreateAjv = ( } } } - return state.ajv ? state.ajv : createAjv(); + if (state.ajv) { + return state.ajv; + } + return createDefault ? createAjv() : undefined; }; const hasAjvOption = (option: any): option is InitActionOptions => { @@ -119,20 +141,95 @@ 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 ) => { switch (action.type) { case INIT: { - const thisAjv = getOrCreateAjv(state, action); - + const validatorOption = getValidatorOption(state, action); + const thisAjv = getOrCreateAjv( + state, + action, + validatorOption === undefined + ); 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 +239,41 @@ 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 thisAjv = getOrCreateAjv( + state, + action, + validatorOption === undefined + ); 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 +283,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 +295,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 +305,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 +354,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 +381,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 +404,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..9e3b8646ba 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,22 @@ 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; + /** + * The AJV instance. Not created by default while a custom Form Validator + * is configured; then only set when one is passed explicitly. + */ 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..2d0f5bddb7 100644 --- a/packages/core/test/reducers/core.test.ts +++ b/packages/core/test/reducers/core.test.ts @@ -24,9 +24,10 @@ */ import test from 'ava'; import Ajv, { ErrorObject } from 'ajv'; -import { coreReducer } from '../../src/reducers'; +import { coreReducer, getOrCreateAjv } 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,371 @@ 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 - no default ajv instance is created', (t) => { + const { factory } = fakeValidator(); + const after = coreReducer( + undefined, + init({ foo: 'bad' }, fooSchema, undefined, { validator: factory }) + ); + t.is(after.ajv, undefined); + t.is(after.errors.length, 1); + const updated = coreReducer( + after, + updateCore({ foo: 'ok' }, fooSchema, undefined, { validator: factory }) + ); + t.is(updated.ajv, undefined); + t.is(updated.errors.length, 0); + const newSchema = coreReducer(updated, setSchema(cloneDeep(fooSchema))); + t.is(newSchema.ajv, undefined); + const toggled = coreReducer( + coreReducer(newSchema, setValidationMode('NoValidation')), + setValidationMode('ValidateAndShow') + ); + t.is(toggled.ajv, undefined); + t.truthy(toggled.formValidator); +}); + +test('core reducer - validator option - an explicitly passed ajv is still kept', (t) => { + const { factory } = fakeValidator(); + const ajv = createAjv(); + const after = coreReducer( + undefined, + init({}, fooSchema, undefined, { ajv, validator: factory }) + ); + t.is(after.ajv, ajv); +}); + +test('core reducer - validator option - switching back to ajv creates the default instance', (t) => { + const { factory } = fakeValidator(); + const before = coreReducer( + undefined, + init({ foo: 1 }, fooSchema, undefined, { validator: factory }) + ); + t.is(before.ajv, undefined); + const after = coreReducer( + before, + updateCore({ foo: 1 }, fooSchema, undefined, { validator: undefined }) + ); + t.truthy(after.ajv); + t.truthy(after.validator); + t.is(after.errors.length, 1); + t.is(after.errors[0].keyword, 'type'); +}); + +test('core reducer - no validator option - default ajv is still created', (t) => { + const after = coreReducer(undefined, init({}, fooSchema)); + t.truthy(after.ajv); + t.is(getOrCreateAjv(after), after.ajv); + t.is(getOrCreateAjv({} as JsonFormsCore, undefined, false), undefined); +}); + +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/package.json b/packages/examples/package.json index 4cb250a307..11a9addc8b 100644 --- a/packages/examples/package.json +++ b/packages/examples/package.json @@ -47,14 +47,18 @@ "doc": "typedoc --name 'JSON Forms Examples' --excludeExternals --out docs src" }, "dependencies": { + "@valibot/to-json-schema": "^1.8.0", "ajv-i18n": "^4.2.0", - "lodash": "^4.17.21" + "lodash": "^4.17.21", + "valibot": "^1.5.0" }, "peerDependencies": { - "@jsonforms/core": "3.9.0-alpha.1" + "@jsonforms/core": "3.9.0-alpha.1", + "@jsonforms/standard-schema": "3.9.0-alpha.1" }, "devDependencies": { "@jsonforms/core": "workspace:*", + "@jsonforms/standard-schema": "workspace:*", "@types/node": "^24.12.2", "@typescript-eslint/eslint-plugin": "^5.54.1", "@typescript-eslint/parser": "^5.54.1", diff --git a/packages/examples/rollup.config.js b/packages/examples/rollup.config.js index e9f14fc976..eb2893ff9d 100644 --- a/packages/examples/rollup.config.js +++ b/packages/examples/rollup.config.js @@ -4,13 +4,29 @@ import { visualizer } from 'rollup-plugin-visualizer'; const packageJson = require('./package.json'); +const externalPackages = [ + ...Object.keys(packageJson.dependencies), + ...Object.keys(packageJson.peerDependencies), +]; + +const isExternal = (id) => + externalPackages.some((pkg) => id === pkg || id.startsWith(pkg + '/')) || + /^lodash\//.test(id); + +/** + * rollup-plugin-typescript2 loads every file TypeScript resolves (to + * type-check type-only imports) but only recognizes `.d.ts` as declarations. + * Packages shipping `.d.mts` / `.d.cts` typings (e.g. valibot) would then be + * parsed as JavaScript; hand Rollup an empty module for them instead. + */ +const ignoreModernDeclarations = { + name: 'ignore-modern-declarations', + load: (id) => (/\.d\.[mc]ts$/.test(id) ? 'export {};' : null), +}; + const baseConfig = { input: 'src/index.ts', - external: [ - ...Object.keys(packageJson.dependencies), - ...Object.keys(packageJson.peerDependencies), - /^lodash\/.*/, - ], + external: isExternal, }; export default [ @@ -22,6 +38,7 @@ export default [ sourcemap: true, }, plugins: [ + ignoreModernDeclarations, typescript(), cleanup({ extensions: ['js', 'ts', 'jsx', 'tsx'] }), visualizer({ open: false }), @@ -35,6 +52,7 @@ export default [ sourcemap: true, }, plugins: [ + ignoreModernDeclarations, typescript({ tsconfigOverride: { compilerOptions: { 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/examples/standard-schema.ts b/packages/examples/src/examples/standard-schema.ts new file mode 100644 index 0000000000..59e22724e4 --- /dev/null +++ b/packages/examples/src/examples/standard-schema.ts @@ -0,0 +1,94 @@ +/* + 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 * as v from 'valibot'; +import { toStandardJsonSchema } from '@valibot/to-json-schema'; +import { fromStandardSchema } from '@jsonforms/standard-schema'; +import type { UISchemaElement } from '@jsonforms/core'; +import { registerExamples } from '../register'; + +/** + * The schema is written in Valibot. JSON Forms renders from the JSON Schema + * derived through Standard JSON Schema and validates with Valibot itself, so + * no schema is compiled at runtime and the form works under a Content + * Security Policy without `unsafe-eval`. + */ +export const valibotSchema = v.object({ + name: v.pipe( + v.string(), + v.minLength(2), + v.description('At least two characters, checked by Valibot') + ), + email: v.pipe(v.string(), v.email()), + age: v.optional(v.pipe(v.number(), v.integer(), v.minValue(18))), + plan: v.picklist(['free', 'team', 'enterprise']), + seats: v.optional(v.pipe(v.number(), v.integer(), v.minValue(2))), +}); + +const form = fromStandardSchema(valibotSchema, { + jsonSchema: toStandardJsonSchema(valibotSchema), +}); + +export const schema = form.jsonSchema; +export const validator = form.validator; + +export const uischema: UISchemaElement = { + type: 'VerticalLayout', + elements: [ + { type: 'Control', scope: '#/properties/name' }, + { type: 'Control', scope: '#/properties/email' }, + { type: 'Control', scope: '#/properties/age' }, + { type: 'Control', scope: '#/properties/plan' }, + { + type: 'Control', + scope: '#/properties/seats', + rule: { + effect: 'SHOW', + condition: { + scope: '#/properties/plan', + schema: { enum: ['team', 'enterprise'] }, + }, + }, + }, + ], +} as UISchemaElement; + +export const data = { + name: 'A', + email: 'ada@example', + age: 17, + plan: 'team', + seats: 1, +}; + +registerExamples([ + { + name: 'standard-schema', + label: 'Standard Schema (Valibot)', + data, + schema, + uischema, + validator, + }, +]); diff --git a/packages/examples/src/index.ts b/packages/examples/src/index.ts index cf75ae737f..f9da5af0ad 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'; @@ -75,6 +76,7 @@ import * as prependAppendSlots from './examples/prepend-append-slots'; import * as radioGroupExample from './examples/radioGroup'; import * as readonly from './examples/readonly'; import * as rule from './examples/rule'; +import * as standardSchema from './examples/standard-schema'; import * as ruleInheritance from './examples/ruleInheritance'; import * as scope from './examples/scope'; import * as specialPropertyNames from './examples/special-property-names'; @@ -91,6 +93,7 @@ import * as jsonschema from './examples/jsonschema'; export { additionalErrors, + customValidator, additionalProperties, allOf, anyOf, @@ -144,6 +147,7 @@ export { radioGroupExample, readonly, rule, + standardSchema, ruleInheritance, scope, specialPropertyNames, 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/standard-schema/.eslintrc.js b/packages/standard-schema/.eslintrc.js new file mode 100644 index 0000000000..2c25b4f1fb --- /dev/null +++ b/packages/standard-schema/.eslintrc.js @@ -0,0 +1,48 @@ +/* eslint-env node */ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + root: true, + parser: '@typescript-eslint/parser', + // There is no file include in ESLint. Thus, ignore all and include files via negative ignore (!) + ignorePatterns: ['/*', '!/src', '!/test'], + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:import/recommended', + 'plugin:import/typescript', + 'plugin:prettier/recommended', + ], + rules: { + // Allow extending interfaces without additional properties to create marker interfaces + '@typescript-eslint/no-empty-interface': [ + 'error', + { + allowSingleExtends: true, + }, + ], + '@typescript-eslint/no-explicit-any': 'off', + // Base rule must be disabled to avoid incorrect errors + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], + 'import/no-unresolved': [ + 'error', + { + // Ignore ava import because it is incorrectly reported as unresolved despite working as expected. + ignore: ['^ava$'], + }, + ], + 'import/no-cycle': [ + 'error', + { + ignoreExternal: true, + }, + ], + }, +}; diff --git a/packages/standard-schema/.prettierrc.js b/packages/standard-schema/.prettierrc.js new file mode 100644 index 0000000000..2c26853c4a --- /dev/null +++ b/packages/standard-schema/.prettierrc.js @@ -0,0 +1,6 @@ +module.exports = { + $schema: 'http://json.schemastore.org/prettierrc', + singleQuote: true, + jsxSingleQuote: true, + endOfLine: 'auto', +}; diff --git a/packages/standard-schema/README.md b/packages/standard-schema/README.md new file mode 100644 index 0000000000..d368e8206f --- /dev/null +++ b/packages/standard-schema/README.md @@ -0,0 +1,95 @@ +# @jsonforms/standard-schema + +Use any [Standard Schema](https://standardschema.dev) library as the validator of JSON Forms: Valibot, Zod, ArkType and every other library implementing the spec. + +The adapter gives JSON Forms the two things it needs from a schema written in such a library: + +- the **JSON Schema** to render the form from, derived through [Standard JSON Schema](https://standardschema.dev/json-schema); +- a **Form Validator** that validates with the library itself. + +Validation runs in the library, so no JSON Schema is compiled at runtime and no code is generated. That makes it work under a Content Security Policy without `unsafe-eval`, which Ajv's runtime compilation needs (see [eclipsesource/jsonforms#1498](https://github.com/eclipsesource/jsonforms/issues/1498)). + +## Usage + +```ts +import * as v from 'valibot'; +import { toStandardJsonSchema } from '@valibot/to-json-schema'; +import { fromStandardSchema } from '@jsonforms/standard-schema'; + +const person = v.object({ + name: v.pipe(v.string(), v.minLength(2)), + age: v.optional(v.pipe(v.number(), v.integer(), v.minValue(18))), +}); + +const { jsonSchema, validator } = fromStandardSchema(person, { + // Valibot exposes Standard JSON Schema through a separate package. + // Zod 4.2+ and ArkType 2.1.28+ implement it on the schema itself, so the + // option can be left out for them. + jsonSchema: toStandardJsonSchema(person), +}); +``` + +Then pass both to JSON Forms: + +```tsx + +``` + +```html + + +``` + +### Options + +| Option | Default | Meaning | +| ------------ | ------------ | --------------------------------------------------------------------------------------- | +| `target` | `'draft-07'` | JSON Schema dialect to derive. `draft-07` is what JSON Forms' default Ajv understands. | +| `io` | `'input'` | Derive the schema's input type (what the user enters) or its output type. | +| `jsonSchema` | | A Standard JSON Schema object, or a ready JSON Schema, when the schema has none itself. | +| `keyFor` | | Override how a library issue maps to a JSON Schema keyword (drives `error.`). | + +### How issues become JSON Forms errors + +Each library issue becomes a JSON Forms `ValidationIssue`: its path turns into a JSON Pointer, and its kind is mapped to the JSON Schema keyword JSON Forms uses for translation (`minLength`, `minimum`, `pattern`, `format`, `enum`, `const`, `required`, ...). Valibot, Zod and ArkType issue shapes are recognised; anything else is reported as `custom`. JSON Forms core resolves the `parentSchema` of each error from the derived JSON Schema, so error placement and i18n work as with Ajv. + +### Rules + +Rule conditions in the UI schema are JSON Schema fragments. The library cannot evaluate them, so JSON Forms core evaluates them itself with its built-in schema matcher (`type`, `enum`, `const`, `required`, `minimum`, `maximum`, `pattern`, `not`, ...), again without code generation. Rules with `ValidateFunctionCondition` work as always. + +### Limits + +- Synchronous schemas only. JSON Forms 3.x validates synchronously; a schema returning a Promise throws on the first validation. Run asynchronous validation in a middleware and dispatch `updateErrors`. +- The validator is bound to the schema you pass. Call `fromStandardSchema` again when the schema changes. +- `format` is not evaluated in rule conditions. + +## Proving the Content Security Policy case + +1. Build an example app, for example `pnpm lerna run build:examples-app --scope=@jsonforms/vue-vanilla`. +2. Serve it with a policy that forbids `unsafe-eval`: + + ```sh + node packages/standard-schema/scripts/serve-csp.cjs packages/vue-vanilla/example/dist 9091 + ``` + +3. Open and pick the **Standard Schema (Valibot)** example: typing invalid values shows errors, rules work. +4. Pick any other example: the browser console reports that Ajv's schema compilation was blocked by the policy. + +## License + +MIT diff --git a/packages/standard-schema/package.json b/packages/standard-schema/package.json new file mode 100644 index 0000000000..9c5c8b24b4 --- /dev/null +++ b/packages/standard-schema/package.json @@ -0,0 +1,97 @@ +{ + "name": "@jsonforms/standard-schema", + "version": "3.9.0-alpha.1", + "description": "Use any Standard Schema library (Valibot, Zod, ArkType, ...) as the validator of JSON Forms", + "repository": { + "type": "git", + "url": "git+https://github.com/eclipsesource/jsonforms.git", + "directory": "packages/standard-schema" + }, + "bugs": "https://github.com/eclipsesource/jsonforms/issues", + "homepage": "http://jsonforms.io/", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "directories": { + "src": "src", + "test": "test" + }, + "files": [ + "lib", + "src" + ], + "keywords": [ + "form", + "forms", + "json", + "jsonforms", + "validation", + "standard-schema", + "valibot", + "zod", + "arktype", + "jsonschema", + "csp" + ], + "main": "lib/jsonforms-standard-schema.cjs.js", + "module": "lib/jsonforms-standard-schema.esm.js", + "typings": "./lib/index.d.ts", + "scripts": { + "build": "rollup -c rollup.config.js", + "clean": "rimraf -rf lib coverage dist .nyc_output 2> /dev/null", + "lint": "eslint .", + "lint:fix": "eslint --fix .", + "test": "ava", + "test-cov": "rimraf -rf .nyc_output && nyc ava", + "doc": "typedoc --name 'JSON Forms Standard Schema' --excludeExternals --out docs src", + "serve-csp": "node scripts/serve-csp.cjs" + }, + "ava": { + "files": [ + "test/**/*" + ], + "extensions": [ + "ts" + ], + "require": [ + "./test-config/ts-node.config.js" + ] + }, + "nyc": { + "reporter": [ + "lcov", + "text-summary" + ] + }, + "dependencies": { + "@standard-schema/spec": "^1.1.0" + }, + "peerDependencies": { + "@jsonforms/core": "3.9.0-alpha.1" + }, + "devDependencies": { + "@istanbuljs/nyc-config-typescript": "^1.0.2", + "@jsonforms/core": "workspace:*", + "@typescript-eslint/eslint-plugin": "^5.54.1", + "@typescript-eslint/parser": "^5.54.1", + "@valibot/to-json-schema": "^1.8.0", + "ava": "^6.1.2", + "eslint": "^8.56.0", + "eslint-config-prettier": "^8.7.0", + "eslint-plugin-import": "^2.27.5", + "eslint-plugin-prettier": "^4.2.1", + "nyc": "^15.1.0", + "prettier": "^2.8.4", + "rimraf": "^6.1.0", + "rollup": "^2.78.0", + "rollup-plugin-cleanup": "^3.2.1", + "rollup-plugin-typescript2": "^0.34.1", + "rollup-plugin-visualizer": "^5.4.1", + "ts-node": "^10.4.0", + "tslib": "^2.5.0", + "typedoc": "~0.25.3", + "typescript": "~5.8.3", + "valibot": "^1.5.0" + } +} diff --git a/packages/standard-schema/rollup.config.js b/packages/standard-schema/rollup.config.js new file mode 100644 index 0000000000..af41899e5e --- /dev/null +++ b/packages/standard-schema/rollup.config.js @@ -0,0 +1,47 @@ +import typescript from 'rollup-plugin-typescript2'; +import cleanup from 'rollup-plugin-cleanup'; +import { visualizer } from 'rollup-plugin-visualizer'; + +const packageJson = require('./package.json'); + +const baseConfig = { + input: 'src/index.ts', + external: [ + ...Object.keys(packageJson.dependencies), + ...Object.keys(packageJson.peerDependencies), + ], +}; + +export default [ + { + ...baseConfig, + output: { + file: packageJson.module, + format: 'esm', + sourcemap: true, + }, + plugins: [ + typescript(), + cleanup({ extensions: ['js', 'ts', 'jsx', 'tsx'] }), + visualizer({ open: false }), + ], + }, + { + ...baseConfig, + output: { + file: packageJson.main, + format: 'cjs', + sourcemap: true, + }, + plugins: [ + typescript({ + tsconfigOverride: { + compilerOptions: { + target: 'ES5', + }, + }, + }), + cleanup({ extensions: ['js', 'ts', 'jsx', 'tsx'] }), + ], + }, +]; diff --git a/packages/standard-schema/scripts/serve-csp.cjs b/packages/standard-schema/scripts/serve-csp.cjs new file mode 100644 index 0000000000..b25052126b --- /dev/null +++ b/packages/standard-schema/scripts/serve-csp.cjs @@ -0,0 +1,75 @@ +#!/usr/bin/env node +/* + Serves a built example app with a Content Security Policy that forbids + `unsafe-eval`, to prove a form validated through @jsonforms/standard-schema + (or compiledAjvValidator) works where Ajv's runtime compilation cannot. + + Usage: + node scripts/serve-csp.cjs [port] + Example: + pnpm lerna run build:examples-app --scope=@jsonforms/vue-vanilla + node packages/standard-schema/scripts/serve-csp.cjs packages/vue-vanilla/example/dist 9091 +*/ +const http = require('http'); +const fs = require('fs'); +const path = require('path'); + +const root = path.resolve(process.argv[2] || '.'); +const port = Number(process.argv[3] || 9091); + +const CSP = [ + "default-src 'self'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "font-src 'self' data:", + "img-src 'self' data:", + "connect-src 'self'", +].join('; '); + +const types = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.map': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.ico': 'image/x-icon', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.ttf': 'font/ttf', +}; + +http + .createServer((req, res) => { + const url = decodeURIComponent((req.url || '/').split('?')[0]); + let file = path.join(root, url); + if (!file.startsWith(root)) { + res.writeHead(403).end(); + return; + } + if (fs.existsSync(file) && fs.statSync(file).isDirectory()) { + file = path.join(file, 'index.html'); + } + if (!fs.existsSync(file)) { + file = path.join(root, 'index.html'); + } + res.writeHead(200, { + 'Content-Type': types[path.extname(file)] || 'application/octet-stream', + 'Content-Security-Policy': CSP, + 'Cache-Control': 'no-store', + }); + fs.createReadStream(file).pipe(res); + }) + .listen(port, () => { + console.log(`Serving ${root}`); + console.log(` http://localhost:${port}/`); + console.log(` Content-Security-Policy: ${CSP}`); + console.log( + 'Open the "Standard Schema (Valibot)" or "Custom validator" example: it validates.' + ); + console.log( + 'Open any other example: Ajv compilation is blocked by the CSP (see the console).' + ); + }); diff --git a/packages/standard-schema/src/index.ts b/packages/standard-schema/src/index.ts new file mode 100644 index 0000000000..270b0118e8 --- /dev/null +++ b/packages/standard-schema/src/index.ts @@ -0,0 +1,25 @@ +/* + 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. +*/ +export * from './standardSchema'; diff --git a/packages/standard-schema/src/standardSchema.ts b/packages/standard-schema/src/standardSchema.ts new file mode 100644 index 0000000000..f538bb2583 --- /dev/null +++ b/packages/standard-schema/src/standardSchema.ts @@ -0,0 +1,353 @@ +/* + 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 { + StandardJSONSchemaV1, + StandardSchemaV1, +} from '@standard-schema/spec'; +import type { + FormValidator, + JsonSchema, + ValidationIssue, +} from '@jsonforms/core'; + +/** JSON Schema dialect a Standard JSON Schema converter can produce. */ +export type JsonSchemaTarget = StandardJSONSchemaV1.Options['target']; + +/** + * Decides which JSON Schema keyword a library issue stands for. Return + * `undefined` to fall back to the built-in mapping. + */ +export type IssueKeyMapper = ( + issue: StandardSchemaV1.Issue, + vendor: string +) => string | undefined; + +export interface StandardSchemaFormOptions { + /** + * JSON Schema dialect to derive. Defaults to `draft-07`, the dialect JSON + * Forms' default validator understands. + */ + target?: JsonSchemaTarget; + /** + * Whether the JSON Schema describes the schema's input type (what the user + * enters) or its output type (after transformations). Defaults to `input`. + */ + io?: 'input' | 'output'; + /** + * Where the JSON Schema comes from when the validating schema itself does not + * implement Standard JSON Schema: a Standard JSON Schema object (for + * Valibot: `toStandardJsonSchema(schema)` from `@valibot/to-json-schema`), + * or a ready JSON Schema. + */ + jsonSchema?: StandardJSONSchemaV1 | JsonSchema; + /** Overrides the mapping from library issues to JSON Schema keywords. */ + keyFor?: IssueKeyMapper; +} + +export interface StandardSchemaForm { + /** The JSON Schema JSON Forms renders the form from. */ + jsonSchema: JsonSchema; + /** + * The Form Validator validating with the library; pass it as JSON Forms' + * `validator`. It is bound to the given schema, so derive a new one when the + * schema changes. + */ + validator: FormValidator; +} + +const isStandardJsonSchema = ( + candidate: unknown +): candidate is StandardJSONSchemaV1 => + typeof candidate === 'object' && + candidate !== null && + typeof (candidate as StandardJSONSchemaV1)['~standard']?.jsonSchema?.input === + 'function'; + +const vendorOf = (schema: StandardSchemaV1): string => + schema['~standard'].vendor ?? 'unknown'; + +/** + * Derives the JSON Schema JSON Forms renders from, through Standard JSON + * Schema: from `options.jsonSchema` when given, otherwise from the schema + * itself (Zod 4.2+, ArkType 2.1.28+ implement it directly; Valibot needs + * `toStandardJsonSchema(schema)` passed as `options.jsonSchema`). + */ +export const deriveJsonSchema = ( + schema: StandardSchemaV1, + options: StandardSchemaFormOptions = {} +): JsonSchema => { + const source = options.jsonSchema ?? schema; + if (isStandardJsonSchema(source)) { + const converter = source['~standard'].jsonSchema; + const convert = + options.io === 'output' ? converter.output : converter.input; + return convert({ target: options.target ?? 'draft-07' }) as JsonSchema; + } + if (options.jsonSchema !== undefined) { + return options.jsonSchema as JsonSchema; + } + throw new Error( + `The ${vendorOf(schema)} schema does not implement Standard JSON Schema. ` + + 'Pass options.jsonSchema: a Standard JSON Schema object ' + + '(for Valibot: toStandardJsonSchema(schema)) or a JSON Schema.' + ); +}; + +const encodeSegment = (segment: string): string => + segment.replace(/~/g, '~0').replace(/\//g, '~1'); + +/** Turns a Standard Schema issue path into a JSON Pointer. */ +export const pathToPointer = (path: StandardSchemaV1.Issue['path']): string => + (path ?? []) + .map((segment) => { + const key = + typeof segment === 'object' && segment !== null && 'key' in segment + ? segment.key + : segment; + return '/' + encodeSegment(String(key)); + }) + .join(''); + +type LibraryIssue = StandardSchemaV1.Issue & { + // Valibot + kind?: string; + type?: string; + received?: unknown; + // Zod + code?: string; + origin?: string; + format?: string; + input?: unknown; +}; + +const valibotKeys: Record = { + min_length: 'minLength', + max_length: 'maxLength', + length: 'minLength', + non_empty: 'minLength', + min_value: 'minimum', + max_value: 'maximum', + gt_value: 'exclusiveMinimum', + lt_value: 'exclusiveMaximum', + multiple_of: 'multipleOf', + regex: 'pattern', + min_entries: 'minProperties', + max_entries: 'maxProperties', + picklist: 'enum', + enum: 'enum', + literal: 'const', + strict_object: 'additionalProperties', +}; + +const valibotFormats = new Set([ + 'email', + 'url', + 'uuid', + 'ipv4', + 'ipv6', + 'ip', + 'iso_date', + 'iso_date_time', + 'iso_time', + 'iso_timestamp', + 'iso_week', + 'hex_color', + 'credit_card', + 'imei', + 'isbn', + 'mac', + 'emoji', +]); + +const zodKeys: Record = { + invalid_type: 'type', + invalid_value: 'enum', + invalid_enum_value: 'enum', + invalid_literal: 'const', + invalid_union: 'anyOf', + unrecognized_keys: 'additionalProperties', + not_multiple_of: 'multipleOf', + custom: 'custom', +}; + +const zodSizeKeys = ( + code: string, + origin: string | undefined +): string | undefined => { + const small = code === 'too_small'; + switch (origin) { + case 'string': + return small ? 'minLength' : 'maxLength'; + case 'number': + case 'int': + case 'bigint': + case 'date': + return small ? 'minimum' : 'maximum'; + case 'array': + case 'set': + return small ? 'minItems' : 'maxItems'; + case 'object': + case 'map': + case 'record': + return small ? 'minProperties' : 'maxProperties'; + default: + return small ? 'minimum' : 'maximum'; + } +}; + +const arktypeKeys: Record = { + missing: 'required', + domain: 'type', + proto: 'type', + unit: 'const', + pattern: 'pattern', + min: 'minimum', + max: 'maximum', + minLength: 'minLength', + maxLength: 'maxLength', + exactLength: 'minLength', + divisor: 'multipleOf', + extraneous: 'additionalProperties', + union: 'anyOf', + predicate: 'custom', +}; + +/** + * Maps a library issue to the JSON Schema keyword JSON Forms uses for error + * translation (`error.`). Knows Valibot, Zod and ArkType issue + * shapes; anything else becomes `custom`. A missing property always maps to + * `required`, with the issue path pointing at the missing property itself, + * which JSON Forms normalizes. + */ +export const keyForIssue = ( + issue: StandardSchemaV1.Issue, + vendor: string +): string => { + const i = issue as LibraryIssue; + switch (vendor) { + case 'valibot': { + if (i.kind === 'schema' && i.received === 'undefined') { + return 'required'; + } + if (i.type !== undefined) { + if (valibotFormats.has(i.type)) { + return 'format'; + } + if (valibotKeys[i.type] !== undefined) { + return valibotKeys[i.type]; + } + } + return i.kind === 'schema' ? 'type' : 'custom'; + } + case 'zod': { + if ( + i.code === 'invalid_type' && + (i.received === 'undefined' || + (i.received === undefined && i.input === undefined)) + ) { + return 'required'; + } + if (i.code === 'too_small' || i.code === 'too_big') { + return zodSizeKeys(i.code, i.origin) ?? 'custom'; + } + if (i.code === 'invalid_format' || i.code === 'invalid_string') { + return i.format === 'regex' ? 'pattern' : 'format'; + } + return (i.code !== undefined && zodKeys[i.code]) || 'custom'; + } + case 'arktype': { + return (i.code !== undefined && arktypeKeys[i.code]) || 'custom'; + } + default: + return 'custom'; + } +}; + +const isPromise = (value: unknown): value is Promise => + typeof value === 'object' && + value !== null && + typeof (value as Promise).then === 'function'; + +/** + * Wraps a Standard Schema as a JSON Forms Form Validator. Validation runs in + * the library itself: no JSON Schema compilation, no code generation, so it + * works under a Content Security Policy without `unsafe-eval`. + * + * Only synchronous schemas are supported, as JSON Forms 3.x validates + * synchronously; a schema returning a Promise throws on the first validation. + */ +export const standardSchemaValidator = ( + schema: StandardSchemaV1, + options: Pick = {} +): FormValidator => { + const vendor = vendorOf(schema); + return { + validate: (data) => { + const result = schema['~standard'].validate(data); + if (isPromise(result)) { + throw new Error( + `The ${vendor} schema validates asynchronously. JSON Forms 3.x ` + + 'supports synchronous validation only; run asynchronous validation ' + + 'in a middleware and dispatch updateErrors.' + ); + } + if (!result.issues) { + return []; + } + return result.issues.map( + (issue): ValidationIssue => ({ + path: pathToPointer(issue.path), + key: options.keyFor?.(issue, vendor) ?? keyForIssue(issue, vendor), + message: issue.message, + severity: 'error', + }) + ); + }, + }; +}; + +/** + * Everything JSON Forms needs from a Standard Schema: the JSON Schema to + * render the form from and the Form Validator to validate with. + * + * @example + * ```ts + * import * as v from 'valibot'; + * import { toStandardJsonSchema } from '@valibot/to-json-schema'; + * + * const person = v.object({ name: v.pipe(v.string(), v.minLength(2)) }); + * const { jsonSchema, validator } = fromStandardSchema(person, { + * jsonSchema: toStandardJsonSchema(person), + * }); + * // + * ``` + */ +export const fromStandardSchema = ( + schema: StandardSchemaV1, + options: StandardSchemaFormOptions = {} +): StandardSchemaForm => ({ + jsonSchema: deriveJsonSchema(schema, options), + validator: standardSchemaValidator(schema, options), +}); diff --git a/packages/standard-schema/test-config/ts-node.config.js b/packages/standard-schema/test-config/ts-node.config.js new file mode 100644 index 0000000000..b3e3352fe9 --- /dev/null +++ b/packages/standard-schema/test-config/ts-node.config.js @@ -0,0 +1,7 @@ +// Register ts-node and override ts options for ava +require('ts-node').register({ + compilerOptions: { + module: 'commonjs', + target: 'es5', + }, +}); diff --git a/packages/standard-schema/test/standardSchema.test.ts b/packages/standard-schema/test/standardSchema.test.ts new file mode 100644 index 0000000000..88593488d3 --- /dev/null +++ b/packages/standard-schema/test/standardSchema.test.ts @@ -0,0 +1,207 @@ +/* + 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 * as v from 'valibot'; +import { toStandardJsonSchema } from '@valibot/to-json-schema'; +import type { StandardSchemaV1 } from '@standard-schema/spec'; +import { coreReducer, errorAt, init, JsonSchema } from '@jsonforms/core'; +import { + deriveJsonSchema, + fromStandardSchema, + keyForIssue, + pathToPointer, + standardSchemaValidator, +} from '../src'; + +const person = v.object({ + name: v.pipe(v.string(), v.minLength(2)), + age: v.optional(v.pipe(v.number(), v.integer(), v.minValue(18))), + email: v.optional(v.pipe(v.string(), v.email())), + role: v.optional(v.picklist(['admin', 'user'])), + kind: v.optional(v.literal('person')), + address: v.optional( + v.object({ + street: v.string(), + 'zip/code': v.pipe(v.string(), v.regex(/^\d{5}$/)), + }) + ), +}); + +const form = fromStandardSchema(person, { + jsonSchema: toStandardJsonSchema(person), +}); + +test('deriveJsonSchema - produces a draft-07 object schema from Valibot', (t) => { + const schema = form.jsonSchema as JsonSchema & { $schema?: string }; + t.is(schema.type, 'object'); + t.deepEqual(schema.required, ['name']); + t.deepEqual(schema.properties.name, { type: 'string', minLength: 2 }); + t.deepEqual(schema.properties.role.enum, ['admin', 'user']); + t.deepEqual(schema.properties.kind, { const: 'person' }); + t.is(schema.properties.address.properties['zip/code'].type, 'string'); + t.true((schema.$schema ?? '').includes('draft-07')); +}); + +test('deriveJsonSchema - honours target and io', (t) => { + const schema = deriveJsonSchema(person, { + jsonSchema: toStandardJsonSchema(person), + target: 'draft-2020-12', + io: 'output', + }) as JsonSchema & { $schema?: string }; + t.true((schema.$schema ?? '').includes('2020-12')); +}); + +test('deriveJsonSchema - accepts a ready JSON Schema', (t) => { + const given: JsonSchema = { type: 'object' }; + t.is(deriveJsonSchema(person, { jsonSchema: given }), given); +}); + +test('deriveJsonSchema - explains what to do when nothing provides a JSON Schema', (t) => { + const error = t.throws(() => deriveJsonSchema(person)); + t.regex(error.message, /valibot/); + t.regex(error.message, /toStandardJsonSchema/); +}); + +test('validator - no issues for valid data', (t) => { + t.deepEqual(form.validator.validate({ name: 'Ada', age: 36 }), []); +}); + +test('validator - maps Valibot issues to paths and keywords', (t) => { + const issues = form.validator.validate({ + name: 'A', + age: 17.5, + email: 'nope', + role: 'guest', + kind: 'robot', + address: { 'zip/code': '12' }, + }); + const byPath = Object.fromEntries( + issues.map((issue) => [`${issue.path} ${issue.key}`, issue]) + ); + t.truthy(byPath['/name minLength']); + t.truthy(byPath['/age integer'] ?? byPath['/age minimum']); + t.truthy(byPath['/email format']); + t.truthy(byPath['/role enum']); + t.truthy(byPath['/kind const']); + t.truthy(byPath['/address/street required']); + t.truthy(byPath['/address/zip~1code pattern']); + for (const issue of issues) { + t.is(issue.severity, 'error'); + t.is(typeof issue.message, 'string'); + } +}); + +test('validator - a missing required property points at the property', (t) => { + const issues = form.validator.validate({}); + t.deepEqual( + issues.map((i) => [i.path, i.key]), + [['/name', 'required']] + ); +}); + +test('validator - keyFor override wins', (t) => { + const validator = standardSchemaValidator(person, { + keyFor: () => 'overridden', + }); + t.is(validator.validate({})[0].key, 'overridden'); +}); + +test('validator - asynchronous schemas are rejected with guidance', (t) => { + const asyncSchema: StandardSchemaV1 = { + '~standard': { + version: 1, + vendor: 'test', + validate: async () => ({ value: undefined }), + }, + }; + const validator = standardSchemaValidator(asyncSchema); + const error = t.throws(() => validator.validate({})); + t.regex(error.message, /asynchronously/); + t.regex(error.message, /middleware/); +}); + +test('pathToPointer - handles keys, segments and escaping', (t) => { + t.is(pathToPointer(undefined), ''); + t.is(pathToPointer([]), ''); + t.is(pathToPointer(['a', 0, 'b']), '/a/0/b'); + t.is(pathToPointer([{ key: 'a' }, { key: 1 }]), '/a/1'); + t.is(pathToPointer(['a/b', 'c~d']), '/a~1b/c~0d'); +}); + +test('keyForIssue - Zod issue shapes', (t) => { + const zod = (issue: object) => + keyForIssue({ message: '', ...issue } as StandardSchemaV1.Issue, 'zod'); + t.is(zod({ code: 'invalid_type', received: 'undefined' }), 'required'); + t.is(zod({ code: 'invalid_type', input: undefined }), 'required'); + t.is(zod({ code: 'invalid_type', received: 'string' }), 'type'); + t.is(zod({ code: 'too_small', origin: 'string' }), 'minLength'); + t.is(zod({ code: 'too_big', origin: 'number' }), 'maximum'); + t.is(zod({ code: 'too_small', origin: 'array' }), 'minItems'); + t.is(zod({ code: 'invalid_format', format: 'email' }), 'format'); + t.is(zod({ code: 'invalid_format', format: 'regex' }), 'pattern'); + t.is(zod({ code: 'unrecognized_keys' }), 'additionalProperties'); + t.is(zod({ code: 'invalid_value' }), 'enum'); + t.is(zod({ code: 'something_new' }), 'custom'); +}); + +test('keyForIssue - ArkType and unknown vendors', (t) => { + const ark = (code: string) => + keyForIssue({ message: '', code } as StandardSchemaV1.Issue, 'arktype'); + t.is(ark('missing'), 'required'); + t.is(ark('domain'), 'type'); + t.is(ark('unit'), 'const'); + t.is(ark('pattern'), 'pattern'); + t.is(ark('min'), 'minimum'); + t.is(ark('extraneous'), 'additionalProperties'); + t.is(ark('whatever'), 'custom'); + t.is(keyForIssue({ message: '' }, 'somebody-else'), 'custom'); +}); + +test('end to end - JSON Forms core stores the issues as errors with parentSchema', (t) => { + const core = coreReducer( + undefined, + init( + { name: 'A', address: { 'zip/code': '12' } }, + form.jsonSchema, + undefined, + { + validator: form.validator, + } + ) + ); + const nameErrors = errorAt('name', form.jsonSchema)(core); + t.is(nameErrors.length, 1); + t.is(nameErrors[0].keyword, 'minLength'); + t.deepEqual(nameErrors[0].parentSchema, { type: 'string', minLength: 2 }); + + const streetErrors = errorAt('address.street', form.jsonSchema)(core); + t.is(streetErrors.length, 1); + t.is(streetErrors[0].keyword, 'required'); + t.deepEqual(streetErrors[0].params, { missingProperty: 'street' }); + + // No AJV instance is created while a Form Validator is configured. + t.is(core.ajv, undefined); + t.is(core.validator, undefined); +}); diff --git a/packages/standard-schema/tsconfig.json b/packages/standard-schema/tsconfig.json new file mode 100644 index 0000000000..9af7ef8c91 --- /dev/null +++ b/packages/standard-schema/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base", + "compilerOptions": { + "outDir": "./lib", + "sourceMap": true + }, + "exclude": ["node_modules"], + "files": ["./src/index.ts"] +} 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 { } -