diff --git a/ajv.js b/ajv.js deleted file mode 100644 index 61df4c7a..00000000 --- a/ajv.js +++ /dev/null @@ -1,30 +0,0 @@ -'use strict' - -const Ajv = require('ajv') -const fastUri = require('fast-uri') -const ajvFormats = require('ajv-formats') - -module.exports = buildAjv - -function buildAjv (options) { - const ajvInstance = new Ajv({ - ...options, - strictSchema: false, - validateSchema: false, - allowUnionTypes: true, - uriResolver: fastUri - }) - - ajvFormats(ajvInstance) - - ajvInstance.addKeyword({ - keyword: 'fjs_type', - type: 'object', - errors: false, - validate: (type, date) => { - return date instanceof Date - } - }) - - return ajvInstance -} diff --git a/index.js b/index.js index 90cb4544..0b578ae2 100644 --- a/index.js +++ b/index.js @@ -9,8 +9,8 @@ const { randomUUID } = require('crypto') const validate = require('./schema-validator') const Serializer = require('./serializer') +const Validator = require('./validator') const RefResolver = require('./ref-resolver') -const buildAjv = require('./ajv') let largeArraySize = 2e4 let largeArrayMechanism = 'default' @@ -75,51 +75,31 @@ const arrayItemsReferenceSerializersMap = new Map() const objectReferenceSerializersMap = new Map() let rootSchemaId = null -let ajvInstance = null let refResolver = null +let validator = null let contextFunctions = null function build (schema, options) { - schema = clone(schema) - arrayItemsReferenceSerializersMap.clear() objectReferenceSerializersMap.clear() contextFunctions = [] options = options || {} - ajvInstance = buildAjv(options.ajv) refResolver = new RefResolver() + validator = new Validator(options.ajv) + rootSchemaId = schema.$id || randomUUID() isValidSchema(schema) - extendDateTimeType(schema) - ajvInstance.addSchema(schema, rootSchemaId) + validator.addSchema(schema, rootSchemaId) refResolver.addSchema(schema, rootSchemaId) if (options.schema) { - const externalSchemas = clone(options.schema) - - for (const key of Object.keys(externalSchemas)) { - const externalSchema = externalSchemas[key] - isValidSchema(externalSchema, key) - extendDateTimeType(externalSchema) - - let schemaKey = externalSchema.$id || key - if (externalSchema.$id !== undefined && externalSchema.$id[0] === '#') { - schemaKey = key + externalSchema.$id // relative URI - } - - if (refResolver.getSchema(schemaKey) === undefined) { - refResolver.addSchema(externalSchema, key) - } - - if ( - ajvInstance.refs[schemaKey] === undefined && - ajvInstance.schemas[schemaKey] === undefined - ) { - ajvInstance.addSchema(externalSchema, schemaKey) - } + for (const key of Object.keys(options.schema)) { + isValidSchema(options.schema[key], key) + validator.addSchema(options.schema[key], key) + refResolver.addSchema(options.schema[key], key) } } @@ -160,28 +140,28 @@ function build (schema, options) { return main ` - const dependenciesName = ['ajv', 'serializer', contextFunctionCode] + const dependenciesName = ['validator', 'serializer', contextFunctionCode] if (options.debugMode) { options.mode = 'debug' } if (options.mode === 'debug') { - return { code: dependenciesName.join('\n'), ajv: ajvInstance } + return { code: dependenciesName.join('\n'), validator, ajv: validator.ajv } } if (options.mode === 'standalone') { // lazy load const buildStandaloneCode = require('./standalone') - return buildStandaloneCode(options, ajvInstance, contextFunctionCode) + return buildStandaloneCode(options, validator, contextFunctionCode) } /* eslint no-new-func: "off" */ - const contextFunc = new Function('ajv', 'serializer', contextFunctionCode) - const stringifyFunc = contextFunc(ajvInstance, serializer) + const contextFunc = new Function('validator', 'serializer', contextFunctionCode) + const stringifyFunc = contextFunc(validator, serializer) - ajvInstance = null refResolver = null + validator = null rootSchemaId = null contextFunctions = null arrayItemsReferenceSerializersMap.clear() @@ -345,9 +325,8 @@ function buildCode (location) { const propertiesLocation = mergeLocation(location, 'properties') Object.keys(schema.properties || {}).forEach((key) => { let propertyLocation = mergeLocation(propertiesLocation, key) - if (schema.properties[key].$ref) { - propertyLocation = resolveRef(location, schema.properties[key].$ref) - schema.properties[key] = propertyLocation.schema + if (propertyLocation.$ref) { + propertyLocation = resolveRef(location, propertyLocation.$ref) } const sanitized = JSON.stringify(key) @@ -364,8 +343,7 @@ function buildCode (location) { code += buildValue(propertyLocation, `obj[${JSON.stringify(key)}]`) - const defaultValue = schema.properties[key].default - + const defaultValue = propertyLocation.schema.default if (defaultValue !== undefined) { code += ` } else { @@ -480,16 +458,6 @@ function mergeAllOfSchema (location, schema, mergedSchema) { mergedSchema.anyOf.push(...allOfSchema.anyOf) } - if (allOfSchema.fjs_type !== undefined) { - if ( - mergedSchema.fjs_type !== undefined && - mergedSchema.fjs_type !== allOfSchema.fjs_type - ) { - throw new Error('allOf schemas have different fjs_type values') - } - mergedSchema.fjs_type = allOfSchema.fjs_type - } - if (allOfSchema.allOf !== undefined) { mergeAllOfSchema(location, allOfSchema, mergedSchema) } @@ -497,7 +465,7 @@ function mergeAllOfSchema (location, schema, mergedSchema) { delete mergedSchema.allOf mergedSchema.$id = `merged_${randomUUID()}` - ajvInstance.addSchema(mergedSchema) + validator.addSchema(mergedSchema) refResolver.addSchema(mergedSchema) location.schemaId = mergedSchema.$id location.jsonPointer = '#' @@ -527,7 +495,7 @@ function addIfThenElse (location) { const ifSchemaRef = ifLocation.schemaId + ifLocation.jsonPointer let code = ` - if (ajv.validate("${ifSchemaRef}", obj)) { + if (validator.validate("${ifSchemaRef}", obj)) { ` const thenLocation = mergeLocation(location, 'then') @@ -801,16 +769,12 @@ function buildValue (location, input) { location.schema = mergedSchema } - let type = schema.type + const type = schema.type const nullable = schema.nullable === true || (Array.isArray(type) && type.includes('null')) let code = '' let funcName - if (schema.fjs_type === 'string' && schema.format === undefined && Array.isArray(schema.type) && schema.type.length === 2) { - type = 'string' - } - if ('const' in schema) { if (nullable) { code += ` @@ -827,7 +791,15 @@ function buildValue (location, input) { code += 'json += serializer.asNull()' break case 'string': { - funcName = nullable ? 'serializer.asStringNullable.bind(serializer)' : 'serializer.asString.bind(serializer)' + if (schema.format === 'date-time') { + funcName = nullable ? 'serializer.asDateTimeNullable.bind(serializer)' : 'serializer.asDateTime.bind(serializer)' + } else if (schema.format === 'date') { + funcName = nullable ? 'serializer.asDateNullable.bind(serializer)' : 'serializer.asDate.bind(serializer)' + } else if (schema.format === 'time') { + funcName = nullable ? 'serializer.asTimeNullable.bind(serializer)' : 'serializer.asTime.bind(serializer)' + } else { + funcName = nullable ? 'serializer.asStringNullable.bind(serializer)' : 'serializer.asString.bind(serializer)' + } code += `json += ${funcName}(${input})` break } @@ -844,15 +816,7 @@ function buildValue (location, input) { code += `json += ${funcName}(${input})` break case 'object': - if (schema.format === 'date-time') { - funcName = nullable ? 'serializer.asDateTimeNullable.bind(serializer)' : 'serializer.asDateTime.bind(serializer)' - } else if (schema.format === 'date') { - funcName = nullable ? 'serializer.asDateNullable.bind(serializer)' : 'serializer.asDate.bind(serializer)' - } else if (schema.format === 'time') { - funcName = nullable ? 'serializer.asTimeNullable.bind(serializer)' : 'serializer.asTime.bind(serializer)' - } else { - funcName = buildObject(location) - } + funcName = buildObject(location) code += `json += ${funcName}(${input})` break case 'array': @@ -870,7 +834,7 @@ function buildValue (location, input) { const schemaRef = optionLocation.schemaId + optionLocation.jsonPointer const nestedResult = buildValue(optionLocation, input) code += ` - ${index === 0 ? 'if' : 'else if'}(ajv.validate("${schemaRef}", ${input})) + ${index === 0 ? 'if' : 'else if'}(validator.validate("${schemaRef}", ${input})) ${nestedResult} ` } @@ -882,6 +846,13 @@ function buildValue (location, input) { code += ` json += JSON.stringify(${input}) ` + } else if ('const' in schema) { + code += ` + if(validator.validate(${JSON.stringify(schema)}, ${input})) + json += '${JSON.stringify(schema.const)}' + else + throw new Error(\`Item $\{JSON.stringify(${input})} does not match schema definition.\`) + ` } else if (schema.type === undefined) { code += ` json += JSON.stringify(${input}) @@ -914,6 +885,7 @@ function buildValue (location, input) { ${statement}( typeof ${input} === "string" || ${input} === null || + ${input} instanceof Date || ${input} instanceof RegExp || ( typeof ${input} === "object" && @@ -941,17 +913,10 @@ function buildValue (location, input) { break } case 'object': { - if (schema.fjs_type) { - code += ` - ${statement}(${input} instanceof Date || ${input} === null) - ${nestedResult} - ` - } else { - code += ` - ${statement}(typeof ${input} === "object" || ${input} === null) - ${nestedResult} - ` - } + code += ` + ${statement}(typeof ${input} === "object" || ${input} === null) + ${nestedResult} + ` break } default: { @@ -980,30 +945,6 @@ function buildValue (location, input) { return code } -// Ajv does not support js date format. In order to properly validate objects containing a date, -// it needs to replace all occurrences of the string date format with a custom keyword fjs_type. -// (see https://github.com/fastify/fast-json-stringify/pull/441) -function extendDateTimeType (schema) { - if (schema === null) return - - if (schema.type === 'string') { - schema.fjs_type = 'string' - schema.type = ['string', 'object'] - } else if ( - Array.isArray(schema.type) && - schema.type.includes('string') && - !schema.type.includes('object') - ) { - schema.fjs_type = 'string' - schema.type.push('object') - } - for (const property in schema) { - if (typeof schema[property] === 'object') { - extendDateTimeType(schema[property]) - } - } -} - function isEmpty (schema) { // eslint-disable-next-line for (var key in schema) { @@ -1018,9 +959,9 @@ module.exports = build module.exports.validLargeArrayMechanisms = validLargeArrayMechanisms -module.exports.restore = function ({ code, ajv }) { +module.exports.restore = function ({ code, validator }) { const serializer = new Serializer() // eslint-disable-next-line - return (Function.apply(null, ['ajv', 'serializer', code]) - .apply(null, [ajv, serializer])) + return (Function.apply(null, ['validator', 'serializer', code]) + .apply(null, [validator, serializer])) } diff --git a/ref-resolver.js b/ref-resolver.js index 65f1eeb8..f9b22497 100644 --- a/ref-resolver.js +++ b/ref-resolver.js @@ -11,8 +11,10 @@ class RefResolver { if (schema.$id !== undefined && schema.$id.charAt(0) !== '#') { schemaId = schema.$id } - this.insertSchemaBySchemaId(schema, schemaId) - this.insertSchemaSubschemas(schema, schemaId) + if (this.getSchema(schemaId) === undefined) { + this.insertSchemaBySchemaId(schema, schemaId) + this.insertSchemaSubschemas(schema, schemaId) + } } getSchema (schemaId, jsonPointer = '#') { diff --git a/serializer.js b/serializer.js index e5a1bef5..12488731 100644 --- a/serializer.js +++ b/serializer.js @@ -74,6 +74,9 @@ module.exports = class Serializer { if (date instanceof Date) { return '"' + date.toISOString() + '"' } + if (typeof date === 'string') { + return '"' + date + '"' + } throw new Error(`The value "${date}" cannot be converted to a date-time.`) } @@ -86,6 +89,9 @@ module.exports = class Serializer { if (date instanceof Date) { return '"' + new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().slice(0, 10) + '"' } + if (typeof date === 'string') { + return '"' + date + '"' + } throw new Error(`The value "${date}" cannot be converted to a date.`) } @@ -98,6 +104,9 @@ module.exports = class Serializer { if (date instanceof Date) { return '"' + new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().slice(11, 19) + '"' } + if (typeof date === 'string') { + return '"' + date + '"' + } throw new Error(`The value "${date}" cannot be converted to a time.`) } diff --git a/standalone.js b/standalone.js index 3af879c1..f037866b 100644 --- a/standalone.js +++ b/standalone.js @@ -1,28 +1,28 @@ const fs = require('fs') const path = require('path') -function buildStandaloneCode (options, ajvInstance, contextFunctionCode) { +function buildStandaloneCode (options, validator, contextFunctionCode) { const serializerCode = fs.readFileSync(path.join(__dirname, 'serializer.js')).toString() let buildAjvCode = '' let defaultAjvSchema = '' - const defaultMeta = ajvInstance.defaultMeta() + const defaultMeta = validator.ajv.defaultMeta() if (typeof defaultMeta === 'string') { defaultAjvSchema = defaultMeta } else { defaultAjvSchema = defaultMeta.$id || defaultMeta.id } - const shouldUseAjv = contextFunctionCode.indexOf('ajv') !== -1 + const shouldUseAjv = contextFunctionCode.indexOf('validator') !== -1 // we need to export the custom json schema let ajvSchemasCode = '' if (shouldUseAjv) { - ajvSchemasCode += `const ajv = buildAjv(${JSON.stringify(options.ajv || {})})\n` - for (const [id, schema] of Object.entries(ajvInstance.schemas)) { + ajvSchemasCode += `const validator = new Validator(${JSON.stringify(options.ajv || {})})\n` + for (const [id, schema] of Object.entries(validator.ajv.schemas)) { // should skip ajv default schema if (id === defaultAjvSchema) continue - ajvSchemasCode += `ajv.addSchema(${JSON.stringify(schema.schema)}, "${id}")\n` + ajvSchemasCode += `validator.ajv.addSchema(${JSON.stringify(schema.schema)}, "${id}")\n` } - buildAjvCode = fs.readFileSync(path.join(__dirname, 'ajv.js')).toString() - buildAjvCode = buildAjvCode.replace("'use strict'", '').replace('module.exports = buildAjv', '') + buildAjvCode = fs.readFileSync(path.join(__dirname, 'validator.js')).toString() + buildAjvCode = buildAjvCode.replace("'use strict'", '').replace('module.exports = SchemaValidator', '') } return ` 'use strict' diff --git a/test/debug-mode.test.js b/test/debug-mode.test.js index 591f73d7..e358c957 100644 --- a/test/debug-mode.test.js +++ b/test/debug-mode.test.js @@ -2,7 +2,9 @@ const test = require('tap').test const fjs = require('..') + const Ajv = require('ajv').default +const Validator = require('../validator') function build (opts) { return fjs({ @@ -18,31 +20,34 @@ function build (opts) { } test('activate debug mode', t => { - t.plan(3) + t.plan(4) const debugMode = build({ debugMode: true }) t.type(debugMode, 'object') t.ok(debugMode.ajv instanceof Ajv) + t.ok(debugMode.validator instanceof Validator) t.type(debugMode.code, 'string') }) test('activate debug mode truthy', t => { - t.plan(3) + t.plan(4) const debugMode = build({ debugMode: 'yes' }) t.type(debugMode, 'object') t.type(debugMode.code, 'string') t.ok(debugMode.ajv instanceof Ajv) + t.ok(debugMode.validator instanceof Validator) }) test('to string auto-consistent', t => { - t.plan(4) + t.plan(5) const debugMode = build({ debugMode: 1 }) t.type(debugMode, 'object') t.type(debugMode.code, 'string') t.ok(debugMode.ajv instanceof Ajv) + t.ok(debugMode.validator instanceof Validator) const compiled = fjs.restore(debugMode) const tobe = JSON.stringify({ firstName: 'Foo' }) @@ -50,7 +55,7 @@ test('to string auto-consistent', t => { }) test('to string auto-consistent with ajv', t => { - t.plan(4) + t.plan(5) const debugMode = fjs({ title: 'object with multiple types field', @@ -69,6 +74,7 @@ test('to string auto-consistent with ajv', t => { t.type(debugMode, 'object') t.type(debugMode.code, 'string') t.ok(debugMode.ajv instanceof Ajv) + t.ok(debugMode.validator instanceof Validator) const compiled = fjs.restore(debugMode) const tobe = JSON.stringify({ str: 'Foo' }) diff --git a/validator.js b/validator.js new file mode 100644 index 00000000..53dfa0fe --- /dev/null +++ b/validator.js @@ -0,0 +1,75 @@ +'use strict' + +const Ajv = require('ajv') +const fastUri = require('fast-uri') +const ajvFormats = require('ajv-formats') +const clone = require('rfdc')({ proto: true }) + +class Validator { + constructor (ajvOptions) { + this.ajv = new Ajv({ + ...ajvOptions, + strictSchema: false, + validateSchema: false, + allowUnionTypes: true, + uriResolver: fastUri + }) + + ajvFormats(this.ajv) + + this.ajv.addKeyword({ + keyword: 'fjs_type', + type: 'object', + errors: false, + validate: (type, date) => { + return date instanceof Date + } + }) + } + + addSchema (schema, schemaName) { + let schemaKey = schema.$id || schemaName + if (schema.$id !== undefined && schema.$id[0] === '#') { + schemaKey = schemaName + schema.$id // relative URI + } + + if ( + this.ajv.refs[schemaKey] === undefined && + this.ajv.schemas[schemaKey] === undefined + ) { + const ajvSchema = clone(schema) + this.convertSchemaToAjvFormat(ajvSchema) + this.ajv.addSchema(ajvSchema, schemaKey) + } + } + + validate (schemaRef, data) { + return this.ajv.validate(schemaRef, data) + } + + // Ajv does not support js date format. In order to properly validate objects containing a date, + // it needs to replace all occurrences of the string date format with a custom keyword fjs_type. + // (see https://github.com/fastify/fast-json-stringify/pull/441) + convertSchemaToAjvFormat (schema) { + if (schema === null) return + + if (schema.type === 'string') { + schema.fjs_type = 'string' + schema.type = ['string', 'object'] + } else if ( + Array.isArray(schema.type) && + schema.type.includes('string') && + !schema.type.includes('object') + ) { + schema.fjs_type = 'string' + schema.type.push('object') + } + for (const property in schema) { + if (typeof schema[property] === 'object') { + this.convertSchemaToAjvFormat(schema[property]) + } + } + } +} + +module.exports = Validator