Skip to content

refactor: create separate schema for Ajv validation #504

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Aug 29, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 0 additions & 30 deletions ajv.js

This file was deleted.

153 changes: 47 additions & 106 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -480,24 +458,14 @@ 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)
}
}
delete mergedSchema.allOf

mergedSchema.$id = `merged_${randomUUID()}`
ajvInstance.addSchema(mergedSchema)
validator.addSchema(mergedSchema)
refResolver.addSchema(mergedSchema)
location.schemaId = mergedSchema.$id
location.jsonPointer = '#'
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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 += `
Expand All @@ -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
}
Expand All @@ -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':
Expand All @@ -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}
`
}
Expand All @@ -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})
Expand Down Expand Up @@ -914,6 +885,7 @@ function buildValue (location, input) {
${statement}(
typeof ${input} === "string" ||
${input} === null ||
${input} instanceof Date ||
${input} instanceof RegExp ||
(
typeof ${input} === "object" &&
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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) {
Expand All @@ -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]))
}
6 changes: 4 additions & 2 deletions ref-resolver.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Copy link
Member

Choose a reason for hiding this comment

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

Is this changes itself self contained?
Do we need a test case for it?

Copy link
Member Author

Choose a reason for hiding this comment

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

I didn't add this check. I just moved it inside the class.

if (refResolver.getSchema(schemaKey) === undefined) {

this.insertSchemaBySchemaId(schema, schemaId)
this.insertSchemaSubschemas(schema, schemaId)
}
}

getSchema (schemaId, jsonPointer = '#') {
Expand Down
9 changes: 9 additions & 0 deletions serializer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.`)
}

Expand All @@ -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.`)
}

Expand All @@ -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.`)
}

Expand Down
Loading