Skip to content

feat: enable Ajv2019 and Ajv2020#152

Open
jmjf wants to merge 4 commits intofastify:mainfrom
jmjf:feat-enable-2019-2020
Open

feat: enable Ajv2019 and Ajv2020#152
jmjf wants to merge 4 commits intofastify:mainfrom
jmjf:feat-enable-2019-2020

Conversation

@jmjf
Copy link

@jmjf jmjf commented Jun 7, 2025

Closes #151 and probably makes #105 moot.

The for...of approach to tests seemed simple. If maintainers don't like it, I'm open to input on what you'd prefer.

Checklist

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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't look like same as before. Previously, accessing the .default.

Suggested change
const Ajv = require(ajvPath)
const Ajv = require(ajvPath)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Member

@climba03003 climba03003 Jun 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I means const Ajv = require('ajv').default, now becomes const Ajv = require('ajv')
Unsure why the .default exist.

Copy link
Author

@jmjf jmjf Jun 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.)

@jmjf
Copy link
Author

jmjf commented Jun 8, 2025

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.

Copy link
Member

@mcollina mcollina left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

Copy link
Member

@metcoder95 metcoder95 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lint seems failing

} else {
this.ajv = new Ajv(Object.assign({}, defaultAjvOptions, options.customOptions))
}
const ajvPath = ['JTD', '2019', '2020'].includes(options.mode) ? `ajv/dist/${options.mode.toLowerCase()}` : 'ajv'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@jmjf
Copy link
Author

jmjf commented Aug 16, 2025

lint seems failing

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.)

> @fastify/ajv-compiler@4.0.2 lint
> eslint


/home/jmjf/dev/ajv-compiler/types/index.d.ts
  12:18  error  'buildCompilerFromPool' is defined but only used as a type    @typescript-eslint/no-unused-vars
  15:18  error  'buildSerializerFromPool' is defined but only used as a type  @typescript-eslint/no-unused-vars

✖ 2 problems (2 errors, 0 warnings)

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 })).

Suggested change
const compiler = factory({}, fastifyAjvOptionsDefault)
const compiler = factory({}, { ...fastifyAjvOptionsDefault, mode })

Copilot uses AI. Check for mistakes.

const compiler3 = factory(externalSchemas2, fastifyAjvOptionsDefault)
t.assert.notEqual(compiler3, compiler1, 'new ajv instance when externa schema change')
const compiler3 = factory(externalSchemas2, fastifyAjvOptionsDefault)
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
const compiler3 = factory(externalSchemas2, fastifyAjvOptionsDefault)
const compiler3 = factory(externalSchemas2, { ...fastifyAjvOptionsDefault, mode })

Copilot uses AI. Check for mistakes.
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)
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
const compiler4 = factory(externalSchemas1, fastifyAjvOptionsCustom)
const compiler4 = factory(externalSchemas1, { ...fastifyAjvOptionsCustom, mode })

Copilot uses AI. Check for mistakes.
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)
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
const Ajv = require(ajvPath)
const Ajv = require(ajvPath).default || require(ajvPath)

Copilot uses AI. Check for mistakes.
Comment on lines +48 to +221
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')
})
})
}
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allow Ajv 2019 and 2020 as options

6 participants