Skip to content
Open
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
116 changes: 100 additions & 16 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,26 @@ let largeArrayMechanism = 'default'

const NAMED_FRAGMENT_REF = /^#[a-z_][-\w._]*$/i

const schemaArrayKeywords = ['allOf', 'anyOf', 'oneOf']

const schemaMapKeywords = [
'$defs',
'definitions',
'patternProperties',
'properties'
]

const schemaValueKeywords = [
'additionalItems',
'additionalProperties',
'contains',
'else',
'if',
'not',
'propertyNames',
'then'
]

const serializerFns = `
const {
asString,
Expand Down Expand Up @@ -94,6 +114,54 @@ function getSchemaId (schema, rootSchemaId) {
return rootSchemaId
}

function validateSchemaIdsForAjvCodeGeneration (schema, schemaId, seen) {
if (typeof schema !== 'object' || schema === null || seen.has(schema)) return

seen.add(schema)
// Ajv emits the active schema ID inside a block-comment sourceURL.
const id = schema[schemaId]
if (typeof id === 'string' && id.includes('*/')) {
throw new Error(`Schema ${schemaId} must not contain "*/" when Ajv source code generation is enabled`)
}

for (const keyword of schemaMapKeywords) {
const schemas = schema[keyword]
if (typeof schemas === 'object' && schemas !== null && !Array.isArray(schemas)) {
for (const nestedSchema of Object.values(schemas)) {
validateSchemaIdsForAjvCodeGeneration(nestedSchema, schemaId, seen)
}
}
}

for (const keyword of schemaValueKeywords) {
validateSchemaIdsForAjvCodeGeneration(schema[keyword], schemaId, seen)
}

if (Array.isArray(schema.items)) {
for (const item of schema.items) {
validateSchemaIdsForAjvCodeGeneration(item, schemaId, seen)
}
} else {
validateSchemaIdsForAjvCodeGeneration(schema.items, schemaId, seen)
}

for (const keyword of schemaArrayKeywords) {
if (Array.isArray(schema[keyword])) {
for (const nestedSchema of schema[keyword]) {
validateSchemaIdsForAjvCodeGeneration(nestedSchema, schemaId, seen)
}
}
}

if (typeof schema.dependencies === 'object' && schema.dependencies !== null) {
for (const dependency of Object.values(schema.dependencies)) {
if (!Array.isArray(dependency)) {
validateSchemaIdsForAjvCodeGeneration(dependency, schemaId, seen)
}
}
}
}

function getSafeSchemaRef (context, location) {
let schemaRef = location.getSchemaRef() || ''
if (schemaRef.startsWith(context.rootSchemaId)) {
Expand Down Expand Up @@ -260,13 +328,26 @@ function build (schema, options) {
options.ajv,
options.mode === 'standalone' && options.inlineValidators
)
const ajvCodeOptions = options.ajv && options.ajv.code
const validateAjvSchemaIds = (
(options.mode === 'standalone' && options.inlineValidators) ||
(ajvCodeOptions && (ajvCodeOptions.source || ajvCodeOptions.process))
)
const ajvSchemaId = options.ajv?.schemaId ?? '$id'
const seenAjvSchemas = new WeakSet()

for (const schemaId of context.validatorSchemasIds) {
const schema = context.refResolver.getSchema(schemaId)
if (validateAjvSchemaIds) {
validateSchemaIdsForAjvCodeGeneration(schema, ajvSchemaId, seenAjvSchemas)
}
validator.addSchema(schema, schemaId)

const dependencies = context.refResolver.getSchemaDependencies(schemaId)
for (const [schemaId, schema] of Object.entries(dependencies)) {
if (validateAjvSchemaIds) {
validateSchemaIdsForAjvCodeGeneration(schema, ajvSchemaId, seenAjvSchemas)
}
validator.addSchema(schema, schemaId)
}
}
Expand Down Expand Up @@ -371,7 +452,7 @@ function buildExtraObjectPropertiesSerializer (context, location, addComma, objV
const propertyLocation = patternPropertiesLocation.getPropertyLocation(propertyKey)

code += `
if (/${propertyKey.replace(/\\*\//g, '\\/')}/.test(key)) {
if (new RegExp(${JSON.stringify(propertyKey)}).test(key)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is inside the for (const key of Object.keys(...)) loop, so the RegExp gets constructed once per key per pattern on every serialization. Was that intentional, or worth hoisting it to module scope next to the generated functions?

${addComma}
json += asString(key) + JSON_STR_COLONS
${buildValue(context, propertyLocation, 'value')}
Expand Down Expand Up @@ -426,7 +507,8 @@ function buildInnerObject (context, location, objVar) {
for (const key of requiredProperties) {
if (!propertiesKeys.includes(key)) {
const sanitizedKey = JSON.stringify(key)
code += `if (${objVar}[${sanitizedKey}] === undefined) throw new Error('${sanitizedKey.replace(/'/g, '\\\'')} is required!')\n`
const requiredError = JSON.stringify(`"${key}" is required!`)
code += `if (${objVar}[${sanitizedKey}] === undefined) throw new Error(${requiredError})\n`
}
}

Expand Down Expand Up @@ -474,8 +556,9 @@ function buildInnerObject (context, location, objVar) {
}
`
} else if (isRequired) {
const requiredError = JSON.stringify(`"${key}" is required!`)
code += ` else {
throw new Error('${sanitizedKey.replace(/'/g, '\\\'')} is required!')
throw new Error(${requiredError})
}
`
} else {
Expand Down Expand Up @@ -519,8 +602,9 @@ function buildInnerObject (context, location, objVar) {
`
} else if (isRequired) {
// Should not happen if requiredProperties.length === 0 but safety
const requiredError = JSON.stringify(`"${key}" is required!`)
code += ` else {
throw new Error('${sanitizedKey.replace(/'/g, '\\\'')} is required!')
throw new Error(${requiredError})
}
`
} else {
Expand Down Expand Up @@ -620,10 +704,7 @@ function buildObject (context, location, input) {
const functionName = generateFuncName(context)
context.functionsNamesBySchema.set(schema, functionName)

const schemaRef = getSafeSchemaRef(context, location)

const functionCode = `
// ${schemaRef}
function ${functionName} (input) {
const obj = ${toJSON('input')}
if (obj === null) return ${nullable ? 'JSON_STR_NULL' : 'JSON_STR_EMPTY_OBJECT'}
Expand Down Expand Up @@ -680,17 +761,17 @@ function buildArray (context, location, input) {
context.functionsNamesBySchema.set(schema, functionName)

const schemaRef = getSafeSchemaRef(context, location)
const schemaRefError = JSON.stringify(`The value of '${schemaRef}' does not match schema definition.`)

let functionCode = `
function ${functionName} (obj) {
// ${schemaRef}
let json = ''
`

functionCode += `
if (obj === null) return ${nullable ? 'JSON_STR_NULL' : 'JSON_STR_EMPTY_ARRAY'}
if (!Array.isArray(obj)) {
throw new TypeError(\`The value of '${schemaRef}' does not match schema definition.\`)
throw new TypeError(${schemaRefError})
}
const arrayLength = obj.length
`
Expand Down Expand Up @@ -768,14 +849,15 @@ function buildArray (context, location, input) {
}

context.buildingSet.add(schema)
const safeSchemaRef = getSafeSchemaRef(context, location)
const schemaRef = getSafeSchemaRef(context, location)
const schemaRefError = JSON.stringify(`The value of '${schemaRef}' does not match schema definition.`)
const objVar = `obj_${context.uid++}`
let inlinedCode = `
const ${objVar} = ${input}
if (${objVar} === null) {
json += ${nullable ? 'JSON_STR_NULL' : 'JSON_STR_EMPTY_ARRAY'}
} else if (!Array.isArray(${objVar})) {
throw new TypeError(\`The value of '${safeSchemaRef}' does not match schema definition.\`)
throw new TypeError(${schemaRefError})
} else {
const arrayLength_${objVar} = ${objVar}.length
`
Expand Down Expand Up @@ -976,8 +1058,9 @@ function buildMultiTypeSerializer (context, location, input) {
}
}
})
const schemaRef = getSafeSchemaRef(context, location)
code += `
else throw new TypeError(\`The value of '${getSafeSchemaRef(context, location)}' does not match schema definition.\`)
else throw new TypeError(${JSON.stringify(`The value of '${schemaRef}' does not match schema definition.`)})
`

return code
Expand Down Expand Up @@ -1218,14 +1301,15 @@ function buildOneOf (context, location, input) {
context.validatorSchemaRefs.add(schemaRef)

code += `
${index === 0 ? 'if' : 'else if'}(validator.validate("${schemaRef}", ${input})) {
${index === 0 ? 'if' : 'else if'}(validator.validate(${JSON.stringify(schemaRef)}, ${input})) {
${nestedResult}
}
`
}

const schemaRef = getSafeSchemaRef(context, location)
code += `
else throw new TypeError(\`The value of '${getSafeSchemaRef(context, location)}' does not match schema definition.\`)
else throw new TypeError(${JSON.stringify(`The value of '${schemaRef}' does not match schema definition.`)})
`

return code
Expand Down Expand Up @@ -1268,7 +1352,7 @@ function buildIfThenElse (context, location, input) {

if (!elseSchema) {
return `
if (validator.validate("${ifSchemaRef}", ${input})) {
if (validator.validate(${JSON.stringify(ifSchemaRef)}, ${input})) {
${buildValue(context, thenMergedLocation, input)}
} else {
${buildValue(context, rootLocation, input)}
Expand All @@ -1292,7 +1376,7 @@ function buildIfThenElse (context, location, input) {
}

return `
if (validator.validate("${ifSchemaRef}", ${input})) {
if (validator.validate(${JSON.stringify(ifSchemaRef)}, ${input})) {
${buildValue(context, thenMergedLocation, input)}
} else {
${buildValue(context, elseMergedLocation, input)}
Expand Down
18 changes: 17 additions & 1 deletion lib/location.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
'use strict'

function encodeFragmentToken (value) {
const escapedValue = String(value).replace(/~/g, '~0').replace(/\//g, '~1')
let encodedValue = ''

for (const character of escapedValue) {
const code = character.charCodeAt(0)
// encodeURIComponent throws for lone surrogates, which remain safe in generated string literals.
encodedValue += character.length === 1 && code >= 0xD800 && code <= 0xDFFF
? character
: encodeURIComponent(character)
}

return encodedValue
}

class Location {
constructor (schema, schemaId, jsonPointer = '#') {
this.schema = schema
Expand All @@ -8,10 +23,11 @@ class Location {
}

getPropertyLocation (propertyName) {
const escapedPropertyName = encodeFragmentToken(propertyName)
const propertyLocation = new Location(
this.schema[propertyName],
this.schemaId,
this.jsonPointer + '/' + propertyName
this.jsonPointer + '/' + escapedPropertyName
)
return propertyLocation
}
Expand Down
8 changes: 5 additions & 3 deletions test/code-generation-fallbacks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,18 +110,20 @@ test('inline array generation without schema IDs', t => {
})

test('code generation reference fallbacks', t => {
t.plan(3)
t.plan(4)

const buildWithoutPointer = loadBuildWithLocation(LocationWithoutJsonPointer)
const stringifyObject = buildWithoutPointer({ type: 'object' })
const stringifyArray = buildWithoutPointer({ type: 'array' })

const buildWithoutRef = loadBuildWithLocation(LocationWithoutSchemaRef)
const stringifyWithoutRef = buildWithoutRef({ type: 'object' })
const stringifyObjectWithoutRef = buildWithoutRef({ type: 'object' })
const stringifyArrayWithoutRef = buildWithoutRef({ type: 'array' })

t.assert.equal(stringifyObject({}), '{}')
t.assert.equal(stringifyArray([]), '[]')
t.assert.equal(stringifyWithoutRef({}), '{}')
t.assert.equal(stringifyObjectWithoutRef({}), '{}')
t.assert.equal(stringifyArrayWithoutRef([]), '[]')
})

test('required-property fallback tolerates unexpected property ordering', t => {
Expand Down
Loading