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); + }); + }); +});