diff --git a/CHANGELOG.md b/CHANGELOG.md index 2596637263..5233dddb83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,10 @@ should change the heading of the (upcoming) version to include a major version b - esbuild for CJS bundle - rollup for UMD bundle +## @rjsf/validator-ajv8 + +- Exposing new function `compileSchemaValidatorsCode` to allow creating precompiled validator without a file. This is useful in case when precompiled validator is to be created dynamically. [#3793](https://github.com/rjsf-team/react-jsonschema-form/pull/3793) + # 5.11.2 ## @rjsf/material-ui diff --git a/packages/docs/docs/usage/validation.md b/packages/docs/docs/usage/validation.md index ae3c23a74e..dbe6f76813 100644 --- a/packages/docs/docs/usage/validation.md +++ b/packages/docs/docs/usage/validation.md @@ -90,6 +90,144 @@ const validator = createPrecompiledValidator(precompiledValidator as ValidatorFu render(
, document.getElementById('app')); ``` +### Dynamically pre-compiling validators + +For more advanced cases when schema needs to be precompiled on request - `compileSchemaValidatorsCode` can be used. +```ts +import { compileSchemaValidatorsCode } from '@rjsf/validator-ajv8/dist/compileSchemaValidators'; + +const code = compileSchemaValidatorsCode(schema, options); +``` + +For the most part it is the same as `compileSchemaValidators`, but instead of writing the file - it returns generated code directly. + +To use it on browser side - some modifications are needed to provide runtime dependencies in generated code needs to be provided. + +Example implementation of it: + +```tsx +import type { ValidatorFunctions } from '@rjsf/validator-ajv8'; + +import ajvRuntimeEqual from 'ajv/dist/runtime/equal'; +import { + parseJson as ajvRuntimeparseJson, + parseJsonNumber as ajvRuntimeparseJsonNumber, + parseJsonString as ajvRuntimeparseJsonString, +} from 'ajv/dist/runtime/parseJson'; +import ajvRuntimeQuote from 'ajv/dist/runtime/quote'; +// import ajvRuntimeRe2 from 'ajv/dist/runtime/re2'; +import ajvRuntimeTimestamp from 'ajv/dist/runtime/timestamp'; +import ajvRuntimeUcs2length from 'ajv/dist/runtime/ucs2length'; +import ajvRuntimeUri from 'ajv/dist/runtime/uri'; +import * as ajvFormats from 'ajv-formats/dist/formats'; + +// dependencies to replace in generated code, to be provided by at runtime +const validatorsBundleReplacements: Record': ['', ],
+ 'require("ajv/dist/runtime/equal").default': ['ajvRuntimeEqual', ajvRuntimeEqual],
+ 'require("ajv/dist/runtime/parseJson").parseJson': ['ajvRuntimeparseJson', ajvRuntimeparseJson],
+ 'require("ajv/dist/runtime/parseJson").parseJsonNumber': [
+ 'ajvRuntimeparseJsonNumber',
+ ajvRuntimeparseJsonNumber,
+ ],
+ 'require("ajv/dist/runtime/parseJson").parseJsonString': [
+ 'ajvRuntimeparseJsonString',
+ ajvRuntimeparseJsonString,
+ ],
+ 'require("ajv/dist/runtime/quote").default': ['ajvRuntimeQuote', ajvRuntimeQuote],
+ // re2 by default is not in dependencies for ajv and so is likely not normally used
+ // 'require("ajv/dist/runtime/re2").default': ['ajvRuntimeRe2', ajvRuntimeRe2],
+ 'require("ajv/dist/runtime/timestamp").default': ['ajvRuntimeTimestamp', ajvRuntimeTimestamp],
+ 'require("ajv/dist/runtime/ucs2length").default': ['ajvRuntimeUcs2length', ajvRuntimeUcs2length],
+ 'require("ajv/dist/runtime/uri").default': ['ajvRuntimeUri', ajvRuntimeUri],
+ // formats
+ 'require("ajv-formats/dist/formats")': ['ajvFormats', ajvFormats],
+};
+
+const regexp = new RegExp(
+ Object.keys(validatorsBundleReplacements)
+ .map((key) => key.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'))
+ .join('|'),
+ 'g'
+);
+
+function wrapAjvBundle(code: string) {
+ return `function(${Object.values(validatorsBundleReplacements)
+ .map(([name]) => name)
+ .join(', ')}){\nvar exports = {};\n${code.replace(
+ regexp,
+ (req) => validatorsBundleReplacements[req][0]
+ )};\nreturn exports;\n}`;
+}
+
+const windowValidatorOnLoad = '__rjsf_validatorOnLoad';
+const schemas = new Map<
+ string,
+ { promise: Promise; resolve: (result: ValidatorFunctions) => void }
+>();
+if (typeof window !== 'undefined') {
+ // @ts-ignore
+ window[windowValidatorOnLoad] = (
+ loadedId: string,
+ fn: (...args: unknown[]) => ValidatorFunctions
+ ) => {
+ const validator = fn(...Object.values(validatorsBundleReplacements).map(([, dep]) => dep));
+ let validatorLoader = schemas.get(loadedId);
+ if (validatorLoader) {
+ validatorLoader.resolve(validator);
+ } else {
+ throw new Error(`Unknown validator loaded id="${loadedId}"`);
+ }
+ };
+}
+
+/**
+ * Evaluate precompiled validator in browser using script tag
+ * @param id Identifier to avoid evaluating the same code multiple times
+ * @param code Code generated server side using `compileSchemaValidatorsCode`
+ * @param nonce nonce attribute to be added to script tag (https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/nonce#using_nonce_to_allowlist_a_script_element)
+ */
+export function evaluateValidator(id: string, code: string, nonce: string): Promise {
+ let maybeValidator = schemas.get(id);
+ if (maybeValidator) return maybeValidator.promise;
+ let resolveValidator: (result: ValidatorFunctions) => void;
+ const validatorPromise = new Promise((resolve) => {
+ resolveValidator = resolve;
+ });
+ schemas.set(id, {
+ promise: validatorPromise,
+ resolve: resolveValidator!,
+ });
+
+ const scriptElement = document.createElement('script');
+
+ scriptElement.setAttribute('nonce', nonce);
+ scriptElement.text = `window["${windowValidatorOnLoad}"]("${id}", ${wrapAjvBundle(code)})`;
+
+ document.body.appendChild(scriptElement);
+ return validatorPromise;
+}
+
+```
+
+From React component this can be used as following:
+
+```tsx
+let [precompiledValidator, setPrecompiledValidator] = React.useState();
+React.useEffect(() => {
+ evaluateValidator(
+ schemaId, // some schema id to avoid evaluating it multiple times
+ code, // result of compileSchemaValidatorsCode returned from the server
+ nonce // nonce script tag attribute to allow this ib content security policy for the page
+ ).then(setPrecompiledValidator);
+}, [entityType.id]);
+
+if (!precompiledValidator) {
+ // render loading screen
+}
+const validator = createPrecompiledValidator(precompiledValidator, schema);
+```
+
## Live validation
diff --git a/packages/validator-ajv8/src/compileSchemaValidators.ts b/packages/validator-ajv8/src/compileSchemaValidators.ts
index 16e78e0d44..2f9fe2ae60 100644
--- a/packages/validator-ajv8/src/compileSchemaValidators.ts
+++ b/packages/validator-ajv8/src/compileSchemaValidators.ts
@@ -1,9 +1,9 @@
import fs from 'fs';
-import standaloneCode from 'ajv/dist/standalone';
-import { RJSFSchema, StrictRJSFSchema, schemaParser } from '@rjsf/utils';
-
-import createAjvInstance from './createAjvInstance';
+import { RJSFSchema, StrictRJSFSchema } from '@rjsf/utils';
import { CustomValidatorOptionsType } from './types';
+import { compileSchemaValidatorsCode } from './compileSchemaValidatorsCode';
+
+export { compileSchemaValidatorsCode };
/** The function used to compile a schema into an output file in the form that allows it to be used as a precompiled
* validator. The main reasons for using a precompiled validator is reducing code size, improving validation speed and,
@@ -22,19 +22,8 @@ export default function compileSchemaValidators(
+ schema: S,
+ options: CustomValidatorOptionsType = {}
+) {
+ const schemaMaps = schemaParser(schema);
+ const schemas = Object.values(schemaMaps);
+
+ const { additionalMetaSchemas, customFormats, ajvOptionsOverrides = {}, ajvFormatOptions, AjvClass } = options;
+ // Allow users to turn off the `lines: true` feature in their own overrides, but NOT the `source: true`
+ const compileOptions = {
+ ...ajvOptionsOverrides,
+ code: { lines: true, ...ajvOptionsOverrides.code, source: true },
+ schemas,
+ };
+ const ajv = createAjvInstance(additionalMetaSchemas, customFormats, compileOptions, ajvFormatOptions, AjvClass);
+
+ return standaloneCode(ajv);
+}
diff --git a/packages/validator-ajv8/test/compileSchemaValidators.test.ts b/packages/validator-ajv8/test/compileSchemaValidators.test.ts
index f984a87b1c..368e3944ae 100644
--- a/packages/validator-ajv8/test/compileSchemaValidators.test.ts
+++ b/packages/validator-ajv8/test/compileSchemaValidators.test.ts
@@ -1,9 +1,8 @@
-import { readFileSync, writeFileSync } from 'fs';
-import { RJSFSchema, schemaParser } from '@rjsf/utils';
+import { writeFileSync } from 'fs';
+import { RJSFSchema } from '@rjsf/utils';
+
+import compileSchemaValidators, { compileSchemaValidatorsCode } from '../src/compileSchemaValidators';
-import compileSchemaValidators from '../src/compileSchemaValidators';
-import createAjvInstance from '../src/createAjvInstance';
-import superSchema from './harness/superSchema.json';
import { CUSTOM_OPTIONS } from './harness/testData';
jest.mock('fs', () => ({
@@ -11,12 +10,15 @@ jest.mock('fs', () => ({
writeFileSync: jest.fn(),
}));
-jest.mock('../src/createAjvInstance', () =>
- jest.fn().mockImplementation((...args) => jest.requireActual('../src/createAjvInstance').default(...args))
-);
+jest.mock('../src/compileSchemaValidatorsCode', () => {
+ return {
+ compileSchemaValidatorsCode: jest.fn(),
+ };
+});
const OUTPUT_FILE = 'test.js';
+const testSchema = { $id: 'test-schema' } as RJSFSchema;
describe('compileSchemaValidators()', () => {
let consoleLogSpy: jest.SpyInstance;
let expectedCode: string;
@@ -27,14 +29,14 @@ describe('compileSchemaValidators()', () => {
consoleLogSpy.mockRestore();
});
describe('compiling without additional options', () => {
- let schemas: RJSFSchema[];
beforeAll(() => {
- schemas = Object.values(schemaParser(superSchema as RJSFSchema));
- expectedCode = readFileSync('./test/harness/superSchema.js').toString();
- compileSchemaValidators(superSchema as RJSFSchema, OUTPUT_FILE);
+ expectedCode = 'test output 1';
+ (compileSchemaValidatorsCode as jest.Mock).mockImplementation(() => expectedCode);
+ compileSchemaValidators(testSchema, OUTPUT_FILE);
});
afterAll(() => {
consoleLogSpy.mockClear();
+ (compileSchemaValidatorsCode as jest.Mock).mockClear();
(writeFileSync as jest.Mock).mockClear();
});
it('called console.log twice', () => {
@@ -46,23 +48,21 @@ describe('compileSchemaValidators()', () => {
it('the second time relates to writing the output file', () => {
expect(consoleLogSpy).toHaveBeenNthCalledWith(2, `writing ${OUTPUT_FILE}`);
});
- it('create AJV instance was called with the expected options', () => {
- const expectedCompileOpts = { code: { source: true, lines: true }, schemas };
- expect(createAjvInstance).toHaveBeenCalledWith(undefined, undefined, expectedCompileOpts, undefined, undefined);
+ it('compileSchemaValidatorsCode was called with the expected options', () => {
+ expect(compileSchemaValidatorsCode).toHaveBeenCalledWith(testSchema, {});
});
it('wrote the expected output', () => {
expect(writeFileSync).toHaveBeenCalledWith(OUTPUT_FILE, expectedCode);
});
});
describe('compiling WITH additional options', () => {
- let schemas: RJSFSchema[];
+ const customOptions = {
+ ...CUSTOM_OPTIONS,
+ ajvOptionsOverrides: { ...CUSTOM_OPTIONS.ajvOptionsOverrides, code: { lines: false } },
+ };
beforeAll(() => {
- schemas = Object.values(schemaParser(superSchema as RJSFSchema));
- expectedCode = readFileSync('./test/harness/superSchemaOptions.js').toString();
- compileSchemaValidators(superSchema as RJSFSchema, OUTPUT_FILE, {
- ...CUSTOM_OPTIONS,
- ajvOptionsOverrides: { ...CUSTOM_OPTIONS.ajvOptionsOverrides, code: { lines: false } },
- });
+ expectedCode = 'expected code 2';
+ compileSchemaValidators(testSchema, OUTPUT_FILE, customOptions);
});
afterAll(() => {
consoleLogSpy.mockClear();
@@ -77,22 +77,8 @@ describe('compileSchemaValidators()', () => {
it('the second time relates to writing the output file', () => {
expect(consoleLogSpy).toHaveBeenNthCalledWith(2, `writing ${OUTPUT_FILE}`);
});
- it('create AJV instance was called with the expected options', () => {
- const {
- additionalMetaSchemas,
- customFormats,
- ajvOptionsOverrides = {},
- ajvFormatOptions,
- AjvClass,
- } = CUSTOM_OPTIONS;
- const expectedCompileOpts = { ...ajvOptionsOverrides, code: { source: true, lines: false }, schemas };
- expect(createAjvInstance).toHaveBeenCalledWith(
- additionalMetaSchemas,
- customFormats,
- expectedCompileOpts,
- ajvFormatOptions,
- AjvClass
- );
+ it('compileSchemaValidatorsCode was called with the expected options', () => {
+ expect(compileSchemaValidatorsCode).toHaveBeenCalledWith(testSchema, customOptions);
});
it('wrote the expected output', () => {
expect(writeFileSync).toHaveBeenCalledWith(OUTPUT_FILE, expectedCode);
diff --git a/packages/validator-ajv8/test/compileSchemaValidatorsCode.test.ts b/packages/validator-ajv8/test/compileSchemaValidatorsCode.test.ts
new file mode 100644
index 0000000000..93b655f1d4
--- /dev/null
+++ b/packages/validator-ajv8/test/compileSchemaValidatorsCode.test.ts
@@ -0,0 +1,64 @@
+import { readFileSync } from 'fs';
+import { RJSFSchema, schemaParser } from '@rjsf/utils';
+
+import { compileSchemaValidatorsCode } from '../src/compileSchemaValidators';
+import createAjvInstance from '../src/createAjvInstance';
+import superSchema from './harness/superSchema.json';
+import { CUSTOM_OPTIONS } from './harness/testData';
+
+jest.mock('../src/createAjvInstance', () =>
+ jest.fn().mockImplementation((...args) => jest.requireActual('../src/createAjvInstance').default(...args))
+);
+
+describe('compileSchemaValidatorsCode()', () => {
+ let expectedCode: string;
+ let generatedCode: string;
+
+ describe('compiling without additional options', () => {
+ let schemas: RJSFSchema[];
+ beforeAll(() => {
+ schemas = Object.values(schemaParser(superSchema as RJSFSchema));
+ expectedCode = readFileSync('./test/harness/superSchema.js').toString();
+ generatedCode = compileSchemaValidatorsCode(superSchema as RJSFSchema);
+ });
+ it('create AJV instance was called with the expected options', () => {
+ const expectedCompileOpts = { code: { source: true, lines: true }, schemas };
+ expect(createAjvInstance).toHaveBeenCalledWith(undefined, undefined, expectedCompileOpts, undefined, undefined);
+ });
+ it('generates the expected output', () => {
+ expect(generatedCode).toBe(expectedCode);
+ });
+ });
+ describe('compiling WITH additional options', () => {
+ let schemas: RJSFSchema[];
+ let expectedCode: string;
+ beforeAll(() => {
+ schemas = Object.values(schemaParser(superSchema as RJSFSchema));
+ expectedCode = readFileSync('./test/harness/superSchemaOptions.js').toString();
+ generatedCode = compileSchemaValidatorsCode(superSchema as RJSFSchema, {
+ ...CUSTOM_OPTIONS,
+ ajvOptionsOverrides: { ...CUSTOM_OPTIONS.ajvOptionsOverrides, code: { lines: false } },
+ });
+ });
+ it('create AJV instance was called with the expected options', () => {
+ const {
+ additionalMetaSchemas,
+ customFormats,
+ ajvOptionsOverrides = {},
+ ajvFormatOptions,
+ AjvClass,
+ } = CUSTOM_OPTIONS;
+ const expectedCompileOpts = { ...ajvOptionsOverrides, code: { source: true, lines: false }, schemas };
+ expect(createAjvInstance).toHaveBeenCalledWith(
+ additionalMetaSchemas,
+ customFormats,
+ expectedCompileOpts,
+ ajvFormatOptions,
+ AjvClass
+ );
+ });
+ it('generates expected output', () => {
+ expect(generatedCode).toBe(expectedCode);
+ });
+ });
+});