Conversation
| this.ajv = new Ajv(Object.assign({}, defaultAjvOptions, options.customOptions)) | ||
| } | ||
| const ajvPath = ['JTD', '2019', '2020'].includes(options.mode) ? `ajv/dist/${options.mode.toLowerCase()}` : 'ajv' | ||
| const Ajv = require(ajvPath) |
There was a problem hiding this comment.
It doesn't look like same as before. Previously, accessing the .default.
| const Ajv = require(ajvPath) | |
| const Ajv = require(ajvPath) |
There was a problem hiding this comment.
This allows using Ajv2019 and Ajv2020 by passing 2019 or 2020 in options.mode.
If options.mode is not one of JTD, 2019, or 2020, ajvPath is "ajv" (the default).
If I'm misunderstanding the question, please help me understand.
There was a problem hiding this comment.
I means const Ajv = require('ajv').default, now becomes const Ajv = require('ajv')
Unsure why the .default exist.
There was a problem hiding this comment.
I see. Thanks for explaining. This became a learning opportunity for me.
What I learned is, Ajv is ESM. According to Node docs
When an ES Module contains both named exports and a default export, the result returned by require() is the module namespace object, which places the default export in the .default property, similar to the results returned by import(). To customize what should be returned by require(esm) directly, the ES Module can export the desired value using the string name "module.exports".
In Ajv code, Ajv uses module.exports, so require('ajv') should get the Ajv class, which is the default export. The JTD, 2019, and 2020 versions do the same.
My guess is that, in the past, require().default may have been required to get the default export out of the module namespace object. The JTD code wasn't using .default and worked and this code works without .default.
I don't mind adding a commit to add the .default if wanted.
This morning, I woke up thinking I should typeof options.mode === 'string' to the ternary condition to avoid trying to call toLowerCase on a non-string (would default to plain Ajv if options.mode is not a string or is not one of the three recognized options).
But Array.prototype.includes doesn't coerce types, so non-string won't match and won't attempt to call toLowerCase, so not needed. (Another thing I learned.)
|
Lint is failing in CI. When I ran lint locally on main, it failed with the same error. I'm not sure what the lint issue is. |
| } else { | ||
| this.ajv = new Ajv(Object.assign({}, defaultAjvOptions, options.customOptions)) | ||
| } | ||
| const ajvPath = ['JTD', '2019', '2020'].includes(options.mode) ? `ajv/dist/${options.mode.toLowerCase()}` : 'ajv' |
There was a problem hiding this comment.
I would refactor a bit here, because it is strange to me that we don't support the default version.
I would implement something like:
const supportedModes = new Map()
supportedModes.add('2020', () => require('ajv/dist/2020'))
supportedModes.add('2019', () => require('ajv/dist/2019'))
supportedModes.add('draft-04', () => require('ajv/dist/refs/json-schema-draft-06.json'))
Co-authored-by: Manuel Spigolon <behemoth89@gmail.com> Signed-off-by: jmjf <jamee.mikell@gmail.com>
Lint fails in main (see also this comment) with the same errors. At the time I created this PR, I saw several merged PRs that had the same lint failures. If the team wants to recommend lint config changes the team considers acceptable, I'm willing to make them. (I use biome, not eslint, so am not familiar with eslint config.) |
There was a problem hiding this comment.
Pull request overview
This PR adds support for Ajv2019 and Ajv2020 JSON Schema modes to enable newer schema features like unevaluatedProperties, which are needed for proper validation with combining keywords (allOf, anyOf, oneOf). The implementation follows the existing JTD mode pattern by using the mode option to select different Ajv variants.
Changes:
- Added TypeScript overload declarations for '2019' and '2020' modes in the buildCompilerFromPool function
- Refactored validator-compiler.js to dynamically require the appropriate Ajv version based on the mode option
- Updated existing tests to run against all three modes (default, '2019', '2020') using a for...of loop
- Added documentation explaining how to use the new modes
Reviewed changes
Copilot reviewed 4 out of 5 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| types/index.d.ts | Added TypeScript overload declarations for '2019' and '2020' modes |
| lib/validator-compiler.js | Refactored to use dynamic require based on mode, consolidating JTD, 2019, and 2020 handling |
| test/index.test.js | Wrapped existing tests in a loop to run against all three modes |
| test/duplicated-id-compile.test.js | Wrapped existing test in a loop to run against all three modes |
| README.md | Added documentation section explaining the new mode options |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| await t.test('must not store schema on compile', t => { | ||
| t.plan(5) | ||
| const factory = AjvCompiler() | ||
| const compiler = factory({}, fastifyAjvOptionsDefault) |
There was a problem hiding this comment.
The mode parameter is defined in the loop but is not being passed to the factory function. It should be included in the options object passed to factory, similar to how it's done in test/index.test.js (e.g., factory({}, { ...fastifyAjvOptionsDefault, mode })).
| const compiler = factory({}, fastifyAjvOptionsDefault) | |
| const compiler = factory({}, { ...fastifyAjvOptionsDefault, mode }) |
|
|
||
| const compiler3 = factory(externalSchemas2, fastifyAjvOptionsDefault) | ||
| t.assert.notEqual(compiler3, compiler1, 'new ajv instance when externa schema change') | ||
| const compiler3 = factory(externalSchemas2, fastifyAjvOptionsDefault) |
There was a problem hiding this comment.
In the cache test, compiler3 is created without the mode parameter (uses fastifyAjvOptionsDefault directly instead of spreading it with mode). This means it will use undefined/default mode regardless of which mode iteration the test is running in, potentially causing incorrect cache behavior comparisons. It should be { ...fastifyAjvOptionsDefault, mode } to properly test cache invalidation across different schemas while maintaining the same mode.
| const compiler3 = factory(externalSchemas2, fastifyAjvOptionsDefault) | |
| const compiler3 = factory(externalSchemas2, { ...fastifyAjvOptionsDefault, mode }) |
| t.assert.notEqual(compiler4, compiler1, 'new ajv instance when externa schema change') | ||
| t.assert.notEqual(compiler4, compiler3, 'new ajv instance when externa schema change') | ||
| }) | ||
| const compiler4 = factory(externalSchemas1, fastifyAjvOptionsCustom) |
There was a problem hiding this comment.
Similar to line 133, compiler4 is created without the mode parameter (uses fastifyAjvOptionsCustom directly). This should be { ...fastifyAjvOptionsCustom, mode } to ensure the test properly validates cache invalidation for different customOptions while maintaining the same mode.
| const compiler4 = factory(externalSchemas1, fastifyAjvOptionsCustom) | |
| const compiler4 = factory(externalSchemas1, { ...fastifyAjvOptionsCustom, mode }) |
| this.ajv = new Ajv(Object.assign({}, defaultAjvOptions, options.customOptions)) | ||
| } | ||
| const ajvPath = ['JTD', '2019', '2020'].includes(options.mode) ? `ajv/dist/${options.mode.toLowerCase()}` : 'ajv' | ||
| const Ajv = require(ajvPath) |
There was a problem hiding this comment.
The dynamic require needs to handle the .default property correctly. When requiring 'ajv', the export structure requires .default to get the Ajv constructor, but when requiring 'ajv/dist/jtd', 'ajv/dist/2019', or 'ajv/dist/2020', the module exports the constructor directly. The current implementation will fail for the default mode because it's missing .default when ajvPath is 'ajv'. This should be: const Ajv = require(ajvPath).default || require(ajvPath) or use a conditional check based on the mode.
| const Ajv = require(ajvPath) | |
| const Ajv = require(ajvPath).default || require(ajvPath) |
| for (const mode of [undefined, '2019', '2020']) { | ||
| test(`mode: ${mode ?? 'default'}`, async (t) => { | ||
| await t.test('basic usage', t => { | ||
| t.plan(1) | ||
| const factory = AjvCompiler() | ||
| const compiler = factory(externalSchemas1, { ...fastifyAjvOptionsDefault, mode }) | ||
| const validatorFunc = compiler({ schema: sampleSchema }) | ||
| const result = validatorFunc({ name: 'hello' }) | ||
| t.assert.deepStrictEqual(result, true) | ||
| }) | ||
|
|
||
| test('array coercion', t => { | ||
| t.plan(2) | ||
| const factory = AjvCompiler() | ||
| const compiler = factory(externalSchemas1, fastifyAjvOptionsDefault) | ||
| await t.test('array coercion', t => { | ||
| t.plan(2) | ||
| const factory = AjvCompiler() | ||
| const compiler = factory(externalSchemas1, { ...fastifyAjvOptionsDefault, mode }) | ||
|
|
||
| const arraySchema = { | ||
| $id: 'example1', | ||
| type: 'object', | ||
| properties: { | ||
| name: { type: 'array', items: { type: 'string' } } | ||
| } | ||
| } | ||
| const arraySchema = { | ||
| $id: 'example1', | ||
| type: 'object', | ||
| properties: { | ||
| name: { type: 'array', items: { type: 'string' } } | ||
| } | ||
| } | ||
|
|
||
| const validatorFunc = compiler({ schema: arraySchema }) | ||
| const validatorFunc = compiler({ schema: arraySchema }) | ||
|
|
||
| const inputObj = { name: 'hello' } | ||
| t.assert.deepStrictEqual(validatorFunc(inputObj), true) | ||
| t.assert.deepStrictEqual(inputObj, { name: ['hello'] }, 'the name property should be coerced to an array') | ||
| }) | ||
| const inputObj = { name: 'hello' } | ||
| t.assert.deepStrictEqual(validatorFunc(inputObj), true) | ||
| t.assert.deepStrictEqual(inputObj, { name: ['hello'] }, 'the name property should be coerced to an array') | ||
| }) | ||
|
|
||
| test('nullable default', t => { | ||
| t.plan(2) | ||
| const factory = AjvCompiler() | ||
| const compiler = factory({}, fastifyAjvOptionsDefault) | ||
| const validatorFunc = compiler({ | ||
| schema: { | ||
| type: 'object', | ||
| properties: { | ||
| nullable: { type: 'string', nullable: true }, | ||
| notNullable: { type: 'string' } | ||
| } | ||
| } | ||
| }) | ||
| const input = { nullable: null, notNullable: null } | ||
| const result = validatorFunc(input) | ||
| t.assert.deepStrictEqual(result, true) | ||
| t.assert.deepStrictEqual(input, { nullable: null, notNullable: '' }, 'the notNullable field has been coerced') | ||
| }) | ||
| await t.test('nullable default', t => { | ||
| t.plan(2) | ||
| const factory = AjvCompiler() | ||
| const compiler = factory({}, { ...fastifyAjvOptionsDefault, mode }) | ||
| const validatorFunc = compiler({ | ||
| schema: { | ||
| type: 'object', | ||
| properties: { | ||
| nullable: { type: 'string', nullable: true }, | ||
| notNullable: { type: 'string' } | ||
| } | ||
| } | ||
| }) | ||
| const input = { nullable: null, notNullable: null } | ||
| const result = validatorFunc(input) | ||
| t.assert.deepStrictEqual(result, true) | ||
| t.assert.deepStrictEqual(input, { nullable: null, notNullable: '' }, 'the notNullable field has been coerced') | ||
| }) | ||
|
|
||
| test('plugin loading', t => { | ||
| t.plan(3) | ||
| const factory = AjvCompiler() | ||
| const compiler = factory(externalSchemas1, fastifyAjvOptionsCustom) | ||
| const validatorFunc = compiler({ | ||
| schema: { | ||
| type: 'object', | ||
| properties: { | ||
| q: { | ||
| type: 'string', | ||
| format: 'date', | ||
| formatMinimum: '2016-02-06', | ||
| formatExclusiveMaximum: '2016-12-27' | ||
| await t.test('plugin loading', t => { | ||
| t.plan(3) | ||
| const factory = AjvCompiler() | ||
| const compiler = factory(externalSchemas1, { ...fastifyAjvOptionsCustom, mode }) | ||
| const validatorFunc = compiler({ | ||
| schema: { | ||
| type: 'object', | ||
| properties: { | ||
| q: { | ||
| type: 'string', | ||
| format: 'date', | ||
| formatMinimum: '2016-02-06', | ||
| formatExclusiveMaximum: '2016-12-27' | ||
| } | ||
| }, | ||
| required: ['q'], | ||
| errorMessage: 'hello world' | ||
| } | ||
| }, | ||
| required: ['q'], | ||
| errorMessage: 'hello world' | ||
| } | ||
| }) | ||
| const result = validatorFunc({ q: '2016-10-02' }) | ||
| t.assert.deepStrictEqual(result, true) | ||
| }) | ||
| const result = validatorFunc({ q: '2016-10-02' }) | ||
| t.assert.deepStrictEqual(result, true) | ||
|
|
||
| const resultFail = validatorFunc({}) | ||
| t.assert.deepStrictEqual(resultFail, false) | ||
| t.assert.deepStrictEqual(validatorFunc.errors[0].message, 'hello world') | ||
| }) | ||
| const resultFail = validatorFunc({}) | ||
| t.assert.deepStrictEqual(resultFail, false) | ||
| t.assert.deepStrictEqual(validatorFunc.errors[0].message, 'hello world') | ||
| }) | ||
|
|
||
| test('optimization - cache ajv instance', t => { | ||
| t.plan(5) | ||
| const factory = AjvCompiler() | ||
| const compiler1 = factory(externalSchemas1, fastifyAjvOptionsDefault) | ||
| const compiler2 = factory(externalSchemas1, fastifyAjvOptionsDefault) | ||
| t.assert.deepStrictEqual(compiler1, compiler2, 'same instance') | ||
| t.assert.deepStrictEqual(compiler1, compiler2, 'same instance') | ||
| await t.test('optimization - cache ajv instance', t => { | ||
| t.plan(5) | ||
| const factory = AjvCompiler() | ||
| const compiler1 = factory(externalSchemas1, { ...fastifyAjvOptionsDefault, mode }) | ||
| const compiler2 = factory(externalSchemas1, { ...fastifyAjvOptionsDefault, mode }) | ||
| t.assert.deepStrictEqual(compiler1, compiler2, 'same instance') | ||
| t.assert.deepStrictEqual(compiler1, compiler2, 'same instance') | ||
|
|
||
| const compiler3 = factory(externalSchemas2, fastifyAjvOptionsDefault) | ||
| t.assert.notEqual(compiler3, compiler1, 'new ajv instance when externa schema change') | ||
| const compiler3 = factory(externalSchemas2, fastifyAjvOptionsDefault) | ||
| t.assert.notEqual(compiler3, compiler1, 'new ajv instance when externa schema change') | ||
|
|
||
| const compiler4 = factory(externalSchemas1, fastifyAjvOptionsCustom) | ||
| t.assert.notEqual(compiler4, compiler1, 'new ajv instance when externa schema change') | ||
| t.assert.notEqual(compiler4, compiler3, 'new ajv instance when externa schema change') | ||
| }) | ||
| const compiler4 = factory(externalSchemas1, fastifyAjvOptionsCustom) | ||
| t.assert.notEqual(compiler4, compiler1, 'new ajv instance when externa schema change') | ||
| t.assert.notEqual(compiler4, compiler3, 'new ajv instance when externa schema change') | ||
| }) | ||
|
|
||
| test('the onCreate callback can enhance the ajv instance', t => { | ||
| t.plan(2) | ||
| const factory = AjvCompiler() | ||
| await t.test('the onCreate callback can enhance the ajv instance', t => { | ||
| t.plan(2) | ||
| const factory = AjvCompiler() | ||
|
|
||
| const fastifyAjvCustomOptionsFormats = Object.freeze({ | ||
| onCreate (ajv) { | ||
| for (const [formatName, format] of Object.entries(this.customOptions.formats)) { | ||
| ajv.addFormat(formatName, format) | ||
| } | ||
| }, | ||
| customOptions: { | ||
| formats: { | ||
| date: /foo/ | ||
| } | ||
| } | ||
| }) | ||
| const fastifyAjvCustomOptionsFormats = Object.freeze({ | ||
| onCreate (ajv) { | ||
| for (const [formatName, format] of Object.entries(this.customOptions.formats)) { | ||
| ajv.addFormat(formatName, format) | ||
| } | ||
| }, | ||
| customOptions: { | ||
| formats: { | ||
| date: /foo/ | ||
| } | ||
| }, | ||
| mode | ||
| }) | ||
|
|
||
| const compiler1 = factory(externalSchemas1, fastifyAjvCustomOptionsFormats) | ||
| const validatorFunc = compiler1({ | ||
| schema: { | ||
| type: 'string', | ||
| format: 'date' | ||
| } | ||
| }) | ||
| const result = validatorFunc('foo') | ||
| t.assert.deepStrictEqual(result, true) | ||
| const compiler1 = factory(externalSchemas1, fastifyAjvCustomOptionsFormats) | ||
| const validatorFunc = compiler1({ | ||
| schema: { | ||
| type: 'string', | ||
| format: 'date' | ||
| } | ||
| }) | ||
| const result = validatorFunc('foo') | ||
| t.assert.deepStrictEqual(result, true) | ||
|
|
||
| const resultFail = validatorFunc('2016-10-02') | ||
| t.assert.deepStrictEqual(resultFail, false) | ||
| }) | ||
| const resultFail = validatorFunc('2016-10-02') | ||
| t.assert.deepStrictEqual(resultFail, false) | ||
| }) | ||
|
|
||
| // https://github.com/fastify/fastify/pull/2969 | ||
| test('compile same $id when in external schema', t => { | ||
| t.plan(3) | ||
| const factory = AjvCompiler() | ||
| // https://github.com/fastify/fastify/pull/2969 | ||
| await t.test('compile same $id when in external schema', t => { | ||
| t.plan(3) | ||
| const factory = AjvCompiler() | ||
|
|
||
| const base = { | ||
| $id: 'urn:schema:base', | ||
| definitions: { | ||
| hello: { type: 'string' } | ||
| }, | ||
| type: 'object', | ||
| properties: { | ||
| hello: { $ref: '#/definitions/hello' } | ||
| } | ||
| } | ||
| const base = { | ||
| $id: 'urn:schema:base', | ||
| definitions: { | ||
| hello: { type: 'string' } | ||
| }, | ||
| type: 'object', | ||
| properties: { | ||
| hello: { $ref: '#/definitions/hello' } | ||
| } | ||
| } | ||
|
|
||
| const refSchema = { | ||
| $id: 'urn:schema:ref', | ||
| type: 'object', | ||
| properties: { | ||
| hello: { $ref: 'urn:schema:base#/definitions/hello' } | ||
| } | ||
| } | ||
| const refSchema = { | ||
| $id: 'urn:schema:ref', | ||
| type: 'object', | ||
| properties: { | ||
| hello: { $ref: 'urn:schema:base#/definitions/hello' } | ||
| } | ||
| } | ||
|
|
||
| const compiler = factory({ | ||
| [base.$id]: base, | ||
| [refSchema.$id]: refSchema | ||
| const compiler = factory({ | ||
| [base.$id]: base, | ||
| [refSchema.$id]: refSchema | ||
|
|
||
| }, fastifyAjvOptionsDefault) | ||
| }, { ...fastifyAjvOptionsDefault, mode }) | ||
|
|
||
| t.assert.ok(!compiler[sym], 'the ajv reference do not exists if code is not activated') | ||
| t.assert.ok(!compiler[sym], 'the ajv reference do not exists if code is not activated') | ||
|
|
||
| const validatorFunc1 = compiler({ | ||
| schema: { | ||
| $id: 'urn:schema:ref' | ||
| } | ||
| }) | ||
| const validatorFunc1 = compiler({ | ||
| schema: { | ||
| $id: 'urn:schema:ref' | ||
| } | ||
| }) | ||
|
|
||
| const validatorFunc2 = compiler({ | ||
| schema: { | ||
| $id: 'urn:schema:ref' | ||
| } | ||
| }) | ||
| const validatorFunc2 = compiler({ | ||
| schema: { | ||
| $id: 'urn:schema:ref' | ||
| } | ||
| }) | ||
|
|
||
| t.assert.ok('the compile does not fail if the schema compiled is already in the external schemas') | ||
| t.assert.deepStrictEqual(validatorFunc1, validatorFunc2, 'the returned function is the same') | ||
| }) | ||
| t.assert.ok('the compile does not fail if the schema compiled is already in the external schemas') | ||
| t.assert.deepStrictEqual(validatorFunc1, validatorFunc2, 'the returned function is the same') | ||
| }) | ||
| }) | ||
| } |
There was a problem hiding this comment.
The tests now iterate over three modes (undefined, '2019', '2020') but there are no tests that validate the specific features enabled by the 2019 and 2020 modes, such as unevaluatedProperties mentioned in issue #151. Consider adding at least one test case that uses a schema feature specific to 2019/2020 (like unevaluatedProperties) to ensure these modes are working correctly and providing the intended functionality.
Closes #151 and probably makes #105 moot.
The
for...ofapproach to tests seemed simple. If maintainers don't like it, I'm open to input on what you'd prefer.npm run testand(there is no benchmark script)npm run benchmarkand the Code of conduct
Checklist
npm run testandnpm run benchmarkand the Code of conduct