Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

expose function to compile schema validators without creating a file #3793

Merged
merged 5 commits into from
Aug 4, 2023
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
138 changes: 138 additions & 0 deletions packages/docs/docs/usage/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,144 @@ const validator = createPrecompiledValidator(precompiledValidator as ValidatorFu

render(<Form schema={schema} validator={validator} />, 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<string, [string, unknown]> = {
zxbodya marked this conversation as resolved.
Show resolved Hide resolved
// '<code to be replaced>': ['<variable name to use as replacement>', <runtime dependency>],
'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<ValidatorFunctions>; 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<ValidatorFunctions> {
let maybeValidator = schemas.get(id);
if (maybeValidator) return maybeValidator.promise;
let resolveValidator: (result: ValidatorFunctions) => void;
const validatorPromise = new Promise<ValidatorFunctions>((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<ValidatorFunctions>();
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

Expand Down
21 changes: 5 additions & 16 deletions packages/validator-ajv8/src/compileSchemaValidators.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -22,19 +22,8 @@ export default function compileSchemaValidators<S extends StrictRJSFSchema = RJS
options: CustomValidatorOptionsType = {}
) {
console.log('parsing the schema');
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);

const moduleCode = standaloneCode(ajv);
const moduleCode = compileSchemaValidatorsCode(schema, options);
console.log(`writing ${output}`);
fs.writeFileSync(output, moduleCode);
}
34 changes: 34 additions & 0 deletions packages/validator-ajv8/src/compileSchemaValidatorsCode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import standaloneCode from 'ajv/dist/standalone';
import { RJSFSchema, StrictRJSFSchema, schemaParser } from '@rjsf/utils';

import createAjvInstance from './createAjvInstance';
import { CustomValidatorOptionsType } from './types';

/** The function used to compile a schema into javascript code 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,
* most importantly, avoiding dynamic code compilation when prohibited by a browser's Content Security Policy. For more
* information about AJV code compilation see: https://ajv.js.org/standalone.html
*
* @param schema - The schema to be compiled into a set of precompiled validators functions
* @param [options={}] - The set of `CustomValidatorOptionsType` information used to alter the AJV validator used for
* compiling the schema. They are the same options that are passed to the `customizeValidator()` function in
* order to modify the behavior of the regular AJV-based validator.
*/
export function compileSchemaValidatorsCode<S extends StrictRJSFSchema = RJSFSchema>(
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);
}
62 changes: 24 additions & 38 deletions packages/validator-ajv8/test/compileSchemaValidators.test.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
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', () => ({
...jest.requireActual('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;
Expand All @@ -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', () => {
Expand All @@ -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();
Expand All @@ -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);
Expand Down
64 changes: 64 additions & 0 deletions packages/validator-ajv8/test/compileSchemaValidatorsCode.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});