From c4cd00b0b0e4f37499f1c6b2a1a68954f10edf39 Mon Sep 17 00:00:00 2001 From: Shuyun Yang Date: Fri, 16 Oct 2026 07:22:59 -0700 Subject: [PATCH] fix: preserve literal data when converting schemas for Ajv Traverse schema-valued keywords and subschema containers explicitly so const, enum, annotations and property-dependency data remain unchanged. Add regression coverage for union selection and supported schema shapes. Prepared with AI assistance for human review. --- lib/validator.js | 24 ++++- test/validator-schema-traversal.test.js | 131 ++++++++++++++++++++++++ 2 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 test/validator-schema-traversal.test.js diff --git a/lib/validator.js b/lib/validator.js index 124b5d59..3409de52 100644 --- a/lib/validator.js +++ b/lib/validator.js @@ -6,6 +6,15 @@ const fastUri = require('fast-uri') const ajvFormats = require('ajv-formats') const clone = require('rfdc')({ proto: true }) +const schemaKeywords = new Set([ + 'additionalItems', 'items', 'contains', 'additionalProperties', + 'propertyNames', 'not', 'if', 'then', 'else' +]) +const schemaArrayKeywords = new Set(['items', 'allOf', 'anyOf', 'oneOf']) +const schemaMapKeywords = new Set([ + '$defs', 'definitions', 'properties', 'patternProperties', 'dependencies' +]) + class Validator { constructor (ajvOptions, inlineValidators) { const codeOptions = inlineValidators @@ -76,7 +85,7 @@ class Validator { // occurrences of the string type with a custom keyword fjs_type // (see https://github.com/fastify/fast-json-stringify/pull/441) convertSchemaToAjvFormat (schema) { - if (schema === null) return + if (schema === null || typeof schema !== 'object' || Array.isArray(schema)) return if (schema.type === 'string') { schema.fjs_type = 'string' @@ -90,8 +99,17 @@ class Validator { schema.type.push('object') } for (const property in schema) { - if (typeof schema[property] === 'object') { - this.convertSchemaToAjvFormat(schema[property]) + const value = schema[property] + // Only traverse subschemas. Objects in const, enum, default, etc. are + // data, even when they contain a property called "type". + if (schemaArrayKeywords.has(property) && Array.isArray(value)) { + for (const item of value) this.convertSchemaToAjvFormat(item) + } else if (schemaMapKeywords.has(property)) { + if (value !== null && typeof value === 'object') { + for (const key in value) this.convertSchemaToAjvFormat(value[key]) + } + } else if (schemaKeywords.has(property)) { + this.convertSchemaToAjvFormat(value) } } } diff --git a/test/validator-schema-traversal.test.js b/test/validator-schema-traversal.test.js new file mode 100644 index 00000000..55d97498 --- /dev/null +++ b/test/validator-schema-traversal.test.js @@ -0,0 +1,131 @@ +'use strict' + +const { test } = require('node:test') +const build = require('..') +const Validator = require('../lib/validator') + +for (const keyword of ['const', 'enum']) { + for (const union of ['anyOf', 'oneOf']) { + test(`${union} preserves schema-like objects in ${keyword}`, (t) => { + const values = [ + { type: 'string' }, + { type: ['string', 'null'] }, + { nested: [{ type: 'string' }] } + ] + t.plan(values.length * 4) + + for (const value of values) { + const schema = { + [union]: [ + { [keyword]: keyword === 'enum' ? [value] : value }, + { type: 'null' } + ] + } + const originalSchema = structuredClone(schema) + const stringify = build(schema) + + t.assert.equal(stringify(value), JSON.stringify(value)) + t.assert.equal(stringify(null), 'null') + t.assert.throws(() => stringify({ type: 'number' }), /does not match schema definition/) + t.assert.deepStrictEqual(schema, originalSchema) + } + }) + } +} + +test('validator preserves data in annotations and unknown keywords', (t) => { + t.plan(1) + const schema = { + $id: 'annotations', + default: { type: 'string' }, + examples: [{ type: ['string', 'null'] }], + 'x-example': { type: 'string' } + } + const validator = new Validator() + validator.addSchema(schema) + + t.assert.deepStrictEqual(validator.ajv.getSchema('annotations').schema, schema) +}) + +for (const property of ['const', 'enum', 'default', 'examples']) { + test(`validator still converts a property named ${property}`, (t) => { + t.plan(1) + const schema = { + anyOf: [{ + type: 'object', + properties: { [property]: { type: 'string' } }, + required: [property] + }] + } + const value = { [property]: new Date('2026-01-01T00:00:00.000Z') } + + t.assert.equal(build(schema)(value), JSON.stringify(value)) + }) +} + +const subschema = { type: 'string' } +const convertedSubschema = { type: ['string', 'object'], fjs_type: 'string' } + +for (const keyword of [ + 'additionalItems', 'items', 'contains', 'additionalProperties', + 'propertyNames', 'not', 'if', 'then', 'else' +]) { + test(`validator converts a subschema in ${keyword}`, (t) => { + t.plan(2) + const schema = { [keyword]: structuredClone(subschema) } + const validator = new Validator() + validator.convertSchemaToAjvFormat(schema) + t.assert.deepStrictEqual(schema, { [keyword]: convertedSubschema }) + + const booleanSchema = { [keyword]: true } + validator.convertSchemaToAjvFormat(booleanSchema) + t.assert.deepStrictEqual(booleanSchema, { [keyword]: true }) + }) +} + +for (const keyword of ['items', 'allOf', 'anyOf', 'oneOf']) { + test(`validator converts an array of subschemas in ${keyword}`, (t) => { + t.plan(1) + const schema = { [keyword]: [structuredClone(subschema), false] } + new Validator().convertSchemaToAjvFormat(schema) + t.assert.deepStrictEqual(schema, { [keyword]: [convertedSubschema, false] }) + }) +} + +for (const keyword of ['$defs', 'definitions', 'properties', 'patternProperties', 'dependencies']) { + test(`validator converts a map of subschemas in ${keyword}`, (t) => { + t.plan(1) + const schema = { [keyword]: { const: structuredClone(subschema), enum: false } } + new Validator().convertSchemaToAjvFormat(schema) + t.assert.deepStrictEqual(schema, { [keyword]: { const: convertedSubschema, enum: false } }) + }) +} + +test('validator preserves property dependencies', (t) => { + t.plan(1) + const schema = { dependencies: { type: ['string', 'other'] } } + new Validator().convertSchemaToAjvFormat(schema) + t.assert.deepStrictEqual(schema, { dependencies: { type: ['string', 'other'] } }) +}) + +for (const union of ['anyOf', 'oneOf']) { + test(`${union} preserves dependencies for a property named type`, (t) => { + t.plan(2) + const schema = { + [union]: [{ + type: 'object', + properties: { + type: { type: 'string' }, + string: { type: 'number' }, + other: { type: 'number' } + }, + dependencies: { type: ['string', 'other'] } + }] + } + const stringify = build(schema) + const value = { type: 'value', string: 1, other: 2 } + + t.assert.equal(stringify(value), JSON.stringify(value)) + t.assert.throws(() => stringify({ type: 'value', string: 1 }), /does not match schema definition/) + }) +}