Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

## Migration to JSON Forms 3.9

### `validator` in the core state is a Form Validator

JSON Forms 3.9 adds a `validator` option to plug in a custom validation library. It takes a factory `(schema) => FormValidator` and takes precedence over `ajv`. Without it, Ajv validates as before.

As a consequence, `JsonFormsCore.validator` now holds a `FormValidator` instead of the compiled Ajv `ValidateFunction`.
If you read `state.validator` or build the core state by hand, call `validator.validate(data)`, which returns `ValidationIssue`s, or wrap a compiled Ajv function with `compiledAjvValidator(validateFn)`.

### JSON Schema generation supports non-object root values

`generateJsonSchema` now generates a schema matching the root value's actual type. In particular, arrays produce an array schema with an `items` schema, primitives produce their corresponding primitive schema, and `undefined` produces an empty schema.
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/actions/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

import type AJV from 'ajv';
import type { ErrorObject } from 'ajv';
import type { FormValidatorFactory } from '../util/formValidator';
import { JsonSchema, UISchemaElement } from '../models';
import { generateDefaultUISchema, generateJsonSchema } from '../generators';

Expand Down Expand Up @@ -146,6 +147,8 @@ export interface UpdateCoreAction {

export interface InitActionOptions {
ajv?: AJV;
/** Creates the Form Validator for the schema. Takes precedence over `ajv`. */
validator?: FormValidatorFactory;
validationMode?: ValidationMode;
additionalErrors?: ErrorObject[];
}
Expand Down
10 changes: 8 additions & 2 deletions packages/core/src/mappers/cell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import type { JsonSchema } from '../models';
import {
AnyAction,
Dispatch,
getAjv,
getRuleValidator,
getConfig,
getData,
getErrorAt,
Expand Down Expand Up @@ -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);

Expand Down
54 changes: 17 additions & 37 deletions packages/core/src/mappers/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ import {
} from '../util';
import {
Translator,
getAjv,
getRuleValidator,
getCells,
getConfig,
getData,
Expand All @@ -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';

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1068,7 +1075,7 @@ export const mapStateToLayoutProps = (
ownProps.uischema,
rootData,
ownProps.path,
getAjv(state),
getRuleValidator(state),
getConfig(state)
)
: ownProps.visible;
Expand Down Expand Up @@ -1165,41 +1172,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;
}
}

Expand Down Expand Up @@ -1321,7 +1301,7 @@ export const mapStateToLabelProps = (
props.uischema,
getData(state),
props.path,
getAjv(state),
getRuleValidator(state),
getConfig(state)
)
: props.visible;
Expand Down
12 changes: 9 additions & 3 deletions packages/core/src/mappers/util.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand All @@ -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') {
Expand Down Expand Up @@ -66,7 +72,7 @@ export const isInherentlyReadonly = (
uischema,
rootData,
ownProps?.path,
getAjv(state),
getRuleValidator(state),
config
);
}
Expand Down
103 changes: 85 additions & 18 deletions packages/core/src/reducers/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,14 @@ import { JsonFormsCore, Reducer, ValidationMode } from '../store';
import type Ajv from 'ajv';
import type { ErrorObject } from 'ajv';
import isFunction from 'lodash/isFunction';
import { createAjv, validate } from '../util';
import { createAjv } from '../util';
import {
createAjvValidator,
FormValidator,
FormValidatorFactory,
issuesToErrors,
} from '../util/formValidator';
import type { JsonSchema } from '../models';

export const initState: JsonFormsCore = {
data: {},
Expand All @@ -54,6 +61,7 @@ export const initState: JsonFormsCore = {
errors: [],
validator: undefined,
ajv: undefined,
validatorFactory: undefined,
validationMode: 'ValidateAndShow',
additionalErrors: [],
};
Expand Down Expand Up @@ -119,19 +127,56 @@ const hasAjvOption = (option: any): option is InitActionOptions => {
return false;
};

const hasValidatorOption = (option: any): option is InitActionOptions => {
if (option) {
return option.validator !== undefined;
}
return false;
};

export const getValidatorFactory = (
state: JsonFormsCore,
action?: InitAction | UpdateCoreAction
): FormValidatorFactory | undefined => {
if (action && hasValidatorOption(action.options)) {
return action.options.validator;
}
return state.validatorFactory;
};

const createValidator = (
validatorFactory: FormValidatorFactory | undefined,
ajv: Ajv,
schema: JsonSchema,
validationMode: ValidationMode
): FormValidator | undefined => {
if (validationMode === 'NoValidation') {
return undefined;
}
return (validatorFactory ?? createAjvValidator(ajv))(schema);
};

const validate = (
validator: FormValidator | undefined,
data: any
): ErrorObject[] =>
validator === undefined ? [] : issuesToErrors(validator.validate(data));

export const coreReducer: Reducer<JsonFormsCore, CoreActions> = (
state = initState,
action
) => {
switch (action.type) {
case INIT: {
const thisAjv = getOrCreateAjv(state, action);

const validatorFactory = getValidatorFactory(state, action);
const validationMode = getValidationMode(state, action);
const v =
validationMode === 'NoValidation'
? undefined
: thisAjv.compile(action.schema);
const v = createValidator(
validatorFactory,
thisAjv,
action.schema,
validationMode
);
const e = validate(v, action.data);
const additionalErrors = getAdditionalErrors(state, action);

Expand All @@ -144,24 +189,29 @@ export const coreReducer: Reducer<JsonFormsCore, CoreActions> = (
errors: e,
validator: v,
ajv: thisAjv,
validatorFactory,
validationMode,
};
}
case UPDATE_CORE: {
const thisAjv = getOrCreateAjv(state, action);
const validatorFactory = getValidatorFactory(state, action);
const validationMode = getValidationMode(state, action);
let validator = state.validator;
let errors = state.errors;
if (
state.schema !== action.schema ||
state.validationMode !== validationMode ||
state.ajv !== thisAjv
state.ajv !== thisAjv ||
state.validatorFactory !== validatorFactory
) {
// revalidate only if necessary
validator =
validationMode === 'NoValidation'
? undefined
: thisAjv.compile(action.schema);
validator = createValidator(
validatorFactory,
thisAjv,
action.schema,
validationMode
);
errors = validate(validator, action.data);
} else if (state.data !== action.data) {
errors = validate(validator, action.data);
Expand All @@ -173,6 +223,7 @@ export const coreReducer: Reducer<JsonFormsCore, CoreActions> = (
state.schema !== action.schema ||
state.uischema !== action.uischema ||
state.ajv !== thisAjv ||
state.validatorFactory !== validatorFactory ||
state.errors !== errors ||
state.validator !== validator ||
state.validationMode !== validationMode ||
Expand All @@ -184,6 +235,7 @@ export const coreReducer: Reducer<JsonFormsCore, CoreActions> = (
schema: action.schema,
uischema: action.uischema,
ajv: thisAjv,
validatorFactory,
errors: isEqual(errors, state.errors) ? state.errors : errors,
validator: validator,
validationMode: validationMode,
Expand All @@ -193,10 +245,12 @@ export const coreReducer: Reducer<JsonFormsCore, CoreActions> = (
}
case SET_AJV: {
const currentAjv = action.ajv;
const validator =
state.validationMode === 'NoValidation'
? undefined
: currentAjv.compile(state.schema);
const validator = createValidator(
state.validatorFactory,
currentAjv,
state.schema,
state.validationMode
);
const errors = validate(validator, state.data);
return {
...state,
Expand All @@ -207,9 +261,16 @@ export const coreReducer: Reducer<JsonFormsCore, CoreActions> = (
}
case SET_SCHEMA: {
const needsNewValidator =
action.schema && state.ajv && state.validationMode !== 'NoValidation';
action.schema &&
(state.validatorFactory || state.ajv) &&
state.validationMode !== 'NoValidation';
const v = needsNewValidator
? state.ajv.compile(action.schema)
? createValidator(
state.validatorFactory,
state.ajv,
action.schema,
state.validationMode
)
: state.validator;
const errors = validate(v, state.data);
return {
Expand Down Expand Up @@ -276,12 +337,18 @@ export const coreReducer: Reducer<JsonFormsCore, CoreActions> = (
const errors = validate(undefined, state.data);
return {
...state,
validator: undefined,
errors,
validationMode: action.validationMode,
};
}
if (state.validationMode === 'NoValidation') {
const validator = state.ajv.compile(state.schema);
const validator = createValidator(
state.validatorFactory,
state.ajv,
state.schema,
action.validationMode
);
const errors = validate(validator, state.data);
return {
...state,
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/store/jsonFormsCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { ErrorObject } from 'ajv';
import { JsonSchema, UISchemaElement } from '../models';
import get from 'lodash/get';
import { errorsAt } from '../util';
import type { RuleValidator } from '../util/runtime';
import {
JsonFormsCellRendererRegistryEntry,
JsonFormsCore,
Expand Down Expand Up @@ -69,5 +70,10 @@ 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 validator factory, else Ajv. */
export const getRuleValidator = (state: JsonFormsState): RuleValidator => {
const core: JsonFormsCore | undefined = get(state, 'jsonforms.core');
return core?.validatorFactory ?? core?.ajv;
};

export const getConfig = (state: JsonFormsState) => state.jsonforms.config;
Loading