diff --git a/README.md b/README.md index 5949c104..0e6b5242 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,7 @@ const stringify = fastJson(mySchema, { - `schema`: external schemas references by $ref property. [More details](#ref) - `ajv`: [ajv v8 instance's settings](https://ajv.js.org/options.html) for those properties that require `ajv`. [More details](#anyof) - `rounding`: setup how the `integer` types will be rounded when not integers. [More details](#integer) +- `inlineValidators`: when using standalone mode, embed Ajv-generated validator functions in the output instead of compiling schemas at runtime. [More details](#standalone) - `largeArrayMechanism`: set the mechanism that should be used to handle large (by default `20000` or more items) arrays. [More details](#largearrays) @@ -724,13 +725,21 @@ const code = fastJson({ type: 'string' } } -}, { mode: 'standalone' }) +}, { mode: 'standalone', inlineValidators: true }) fs.writeFileSync('stringify.js', code) const stringify = require('stringify.js') console.log(stringify({ firstName: 'Foo', surname: 'bar' })) // '{"firstName":"Foo"}' ``` +Set `inlineValidators` to `true` to include the Ajv-generated functions used by +`anyOf`, `oneOf`, and `if/then/else` in the same output file. This avoids +rebuilding an Ajv instance and compiling their schemas at runtime. The +generated module still requires `fast-json-stringify` for its serializer and +may require Ajv runtime helpers used by the generated validation code. Custom +Ajv formats used by these schemas must support Ajv's standalone code +generation. + ## Acknowledgments diff --git a/index.js b/index.js index b900ad7e..0d0ff9de 100644 --- a/index.js +++ b/index.js @@ -113,6 +113,7 @@ function build (schema, options) { refResolver: new RefResolver(), rootSchemaId: schema.$id || `__fjs_root_${schemaIdCounter++}`, validatorSchemasIds: new Set(), + validatorSchemaRefs: new Set(), mergedSchemasIds: new Map(), recursiveSchemas: new Set(), recursivePaths: new Set(), @@ -208,7 +209,10 @@ function build (schema, options) { } const serializer = new Serializer(options) - const validator = new Validator(options.ajv) + const validator = new Validator( + options.ajv, + options.mode === 'standalone' && options.inlineValidators + ) for (const schemaId of context.validatorSchemasIds) { const schema = context.refResolver.getSchema(schemaId) @@ -1149,6 +1153,7 @@ function buildOneOf (context, location, input) { const nestedResult = buildValue(context, mergedLocation, input) const schemaRef = optionLocation.getSchemaRef() + context.validatorSchemaRefs.add(schemaRef) code += ` ${index === 0 ? 'if' : 'else if'}(validator.validate("${schemaRef}", ${input})) { @@ -1182,6 +1187,7 @@ function buildIfThenElse (context, location, input) { const ifLocation = location.getPropertyLocation('if') const ifSchemaRef = ifLocation.getSchemaRef() + context.validatorSchemaRefs.add(ifSchemaRef) const thenLocation = location.getPropertyLocation('then') let thenMergedSchemaId = context.mergedSchemasIds.get(thenSchema) diff --git a/lib/standalone.js b/lib/standalone.js index 0ba3ac3f..e5719aef 100644 --- a/lib/standalone.js +++ b/lib/standalone.js @@ -3,9 +3,28 @@ function buildStandaloneCode (contextFunc, context, serializer, validator) { let ajvDependencyCode = '' if (context.validatorSchemasIds.size > 0) { - ajvDependencyCode += 'const Validator = require(\'fast-json-stringify/lib/validator\')\n' - ajvDependencyCode += `const validatorState = ${JSON.stringify(validator.getState())}\n` - ajvDependencyCode += 'const validator = Validator.restoreFromState(validatorState)\n' + if (context.options.inlineValidators) { + const standaloneCode = require('ajv/dist/standalone').default + const schemaRefs = Object.fromEntries( + [...context.validatorSchemaRefs].map(schemaRef => [schemaRef, schemaRef]) + ) + + ajvDependencyCode += `const validator = (() => { + const validators = {} + const exports = validators + ${standaloneCode(validator.ajv, schemaRefs)} + return { + validate (schemaRef, data) { + const validate = validators[schemaRef] + return validate(data) + } + } + })()\n` + } else { + ajvDependencyCode += 'const Validator = require(\'fast-json-stringify/lib/validator\')\n' + ajvDependencyCode += `const validatorState = ${JSON.stringify(validator.getState())}\n` + ajvDependencyCode += 'const validator = Validator.restoreFromState(validatorState)\n' + } } else { ajvDependencyCode += 'const validator = null\n' } diff --git a/lib/validator.js b/lib/validator.js index 77bbd101..5bc8f32d 100644 --- a/lib/validator.js +++ b/lib/validator.js @@ -1,14 +1,20 @@ 'use strict' const Ajv = require('ajv') +const { _ } = Ajv const fastUri = require('fast-uri') const ajvFormats = require('ajv-formats') const clone = require('rfdc')({ proto: true }) class Validator { - constructor (ajvOptions) { + constructor (ajvOptions, inlineValidators) { + const codeOptions = inlineValidators + ? { code: { ...ajvOptions?.code, source: true, esm: false } } + : {} + this.ajv = new Ajv({ ...ajvOptions, + ...codeOptions, strictSchema: false, validateSchema: false, allowUnionTypes: true, @@ -21,8 +27,9 @@ class Validator { keyword: 'fjs_type', type: 'object', errors: false, - validate: (_type, data) => { - return data && typeof data.toJSON === 'function' + code (context) { + const { data } = context + context.fail(_`!${data} || typeof ${data}.toJSON !== "function"`) } }) diff --git a/test/standalone-mode.test.js b/test/standalone-mode.test.js index 528d05fa..abb489b3 100644 --- a/test/standalone-mode.test.js +++ b/test/standalone-mode.test.js @@ -39,13 +39,13 @@ test('activate standalone mode', async (t) => { }) test('test ajv schema', async (t) => { - t.plan(3) + t.plan(4) after(async () => { await fs.promises.rm(destination, { force: true }) }) - const code = build({ mode: 'standalone' }, { + const code = build({ mode: 'standalone', inlineValidators: false }, { type: 'object', properties: { }, @@ -94,6 +94,7 @@ test('test ajv schema', async (t) => { }) t.assert.ok(typeof code === 'string') t.assert.equal(code.indexOf('ajv') > 0, true) + t.assert.match(code, /fast-json-stringify\/lib\/validator/) const destination = path.resolve(tmpDir, 'standalone2.js') @@ -217,3 +218,109 @@ test('no need to keep external schemas once compiled - with oneOf validator', as t.assert.equal(stringify({ oneOfSchema: { baz: 5 } }), '{"oneOfSchema":{"baz":5}}') t.assert.equal(stringify({ oneOfSchema: { bar: 'foo' } }), '{"oneOfSchema":{"bar":"foo"}}') }) + +test('inline validators with if/then/else', async (t) => { + t.plan(6) + + after(async () => { + await fs.promises.rm(destination, { force: true }) + }) + + const ajvOptions = { + code: { source: false, esm: true, lines: true } + } + const code = fjs({ + type: 'object', + properties: {}, + if: { + type: 'object', + properties: { + kind: { const: 'foo' } + }, + required: ['kind'] + }, + then: { + properties: { + kind: { type: 'string' }, + foo: { type: 'string' } + } + }, + else: { + properties: { + kind: { type: 'string' }, + bar: { type: 'integer' } + } + } + }, { + mode: 'standalone', + inlineValidators: true, + ajv: ajvOptions + }) + + t.assert.ok(typeof code === 'string') + t.assert.doesNotMatch(code, /fast-json-stringify\/lib\/validator/) + t.assert.match(code, /function validate\d/) + t.assert.deepEqual(ajvOptions, { + code: { source: false, esm: true, lines: true } + }) + + const destination = path.resolve(tmpDir, 'standalone-inline-if.js') + + await fs.promises.writeFile(destination, code) + const stringify = require(destination) + + t.assert.equal(stringify({ kind: 'foo', foo: 'FOO', bar: 42 }), '{"kind":"foo","foo":"FOO"}') + t.assert.equal(stringify({ kind: 'bar', foo: 'FOO', bar: 42 }), '{"kind":"bar","bar":42}') +}) + +test('inline validators with external oneOf refs and toJSON values', async (t) => { + t.plan(4) + + after(async () => { + await fs.promises.rm(destination, { force: true }) + }) + + const code = fjs({ + type: 'object', + properties: { + value: { + oneOf: [ + { $ref: 'values#/definitions/timestamp' }, + { $ref: 'values#/definitions/count' } + ] + } + } + }, { + mode: 'standalone', + inlineValidators: true, + schema: { + values: { + definitions: { + timestamp: { type: 'string', format: 'date-time' }, + count: { type: 'integer' } + } + } + } + }) + + t.assert.doesNotMatch(code, /fast-json-stringify\/lib\/validator/) + + const destination = path.resolve(tmpDir, 'standalone-inline-oneOf.js') + + await fs.promises.writeFile(destination, code) + const stringify = require(destination) + + t.assert.equal( + stringify({ value: new Date('2020-01-02T03:04:05.000Z') }), + '{"value":"2020-01-02T03:04:05.000Z"}' + ) + t.assert.equal(stringify({ value: 42 }), '{"value":42}') + t.assert.throws(() => stringify({ value: true }), /does not match schema definition/) +}) + +test('does not emit unused inline validators', (t) => { + const code = build({ mode: 'standalone', inlineValidators: true }) + + t.assert.doesNotMatch(code, /function validate\d/) + t.assert.match(code, /const validator = null/) +}) diff --git a/types/index.d.ts b/types/index.d.ts index 65134ef9..d40b382a 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -166,6 +166,12 @@ declare namespace build { * Running mode of fast-json-stringify */ mode?: 'debug' | 'standalone' + /** + * Embed Ajv-generated validators in standalone output instead of compiling schemas at runtime + * + * @default false + */ + inlineValidators?: boolean /** * Large arrays are defined as arrays containing, by default, `20000` * elements or more. That value can be adjusted via the option parameter diff --git a/types/index.tst.ts b/types/index.tst.ts index f2d7ff2c..fc73ce6b 100644 --- a/types/index.tst.ts +++ b/types/index.tst.ts @@ -31,6 +31,10 @@ expect(build).type.not.toBeCallableWith({ type: 'number' }, { rounding: 'invalid' }) +build({} as Schema, { inlineValidators: true }) +build({} as Schema, { inlineValidators: false }) +expect(build).type.not.toBeCallableWith({} as Schema, { inlineValidators: 'true' }) + // String schema build({ type: 'string'