From c85cc87363ea2f4c717e3f15529eaac261a61979 Mon Sep 17 00:00:00 2001 From: ivan-tymoshenko Date: Fri, 10 Jun 2022 00:15:21 +0300 Subject: [PATCH 1/7] fix: use ajv for schema ref resolving --- index.js | 417 ++++++++++++++++----------------------------- package.json | 5 +- test/allof.test.js | 16 +- test/ref.test.js | 253 +++++++++++++++++++++++---- 4 files changed, 370 insertions(+), 321 deletions(-) diff --git a/index.js b/index.js index 19529050..44843890 100644 --- a/index.js +++ b/index.js @@ -12,7 +12,6 @@ const Serializer = require('./serializer') const buildAjv = require('./ajv') let largeArraySize = 2e4 -let stringSimilarity = null let largeArrayMechanism = 'default' const validLargeArrayMechanisms = [ 'default', @@ -41,17 +40,46 @@ function isValidSchema (schema, name) { } } -function mergeLocation (source, dest) { +function mergeLocation (location, key) { return { - schema: dest.schema || source.schema, - root: dest.root || source.root, - externalSchema: dest.externalSchema || source.externalSchema + schema: location.schema[key], + schemaId: location.schemaId, + jsonPointer: location.jsonPointer + '/' + key } } +function resolveRef (location, ref) { + let hashIndex = ref.indexOf('#') + if (hashIndex === -1) { + hashIndex = ref.length + } + + const schemaId = ref.slice(0, hashIndex) || location.schemaId + const jsonPointer = ref.slice(hashIndex) + + const schemaRef = schemaId + jsonPointer + + let ajvSchema + try { + ajvSchema = ajvInstance.getSchema(schemaRef) + } catch (error) { + throw new Error(`Cannot find reference "${ref}"`) + } + + if (ajvSchema === undefined) { + throw new Error(`Cannot find reference "${ref}"`) + } + + const schema = ajvSchema.schema + if (schema.$ref !== undefined) { + return resolveRef({ schema, schemaId, jsonPointer }, schema.$ref) + } + + return { schema, schemaId, jsonPointer } +} + const arrayItemsReferenceSerializersMap = new Map() const objectReferenceSerializersMap = new Map() -const schemaReferenceMap = new Map() let ajvInstance = null let contextFunctions = null @@ -59,18 +87,36 @@ let contextFunctions = null function build (schema, options) { arrayItemsReferenceSerializersMap.clear() objectReferenceSerializersMap.clear() - schemaReferenceMap.clear() contextFunctions = [] options = options || {} ajvInstance = buildAjv(options.ajv) + const schemaId = schema.$id || randomUUID() + isValidSchema(schema) + extendDateTimeType(schema) + ajvInstance.addSchema(schema, schemaId) + if (options.schema) { - // eslint-disable-next-line - for (var key of Object.keys(options.schema)) { - isValidSchema(options.schema[key], key) + for (const key of Object.keys(options.schema)) { + const externalSchema = options.schema[key] + isValidSchema(externalSchema, key) + extendDateTimeType(externalSchema) + + if (externalSchema.$id !== undefined) { + if (externalSchema.$id[0] === '#') { + ajvInstance.addSchema(externalSchema, key + externalSchema.$id) + } else { + ajvInstance.addSchema(externalSchema) + if (externalSchema.$id !== key) { + ajvInstance.addSchema({ $ref: externalSchema.$id }, key) + } + } + } else { + ajvInstance.addSchema(externalSchema, key) + } } } @@ -98,22 +144,8 @@ function build (schema, options) { const serializer = new Serializer(options) - let location = { - schema, - root: schema, - externalSchema: options.schema - } - - if (schema.$ref) { - location = refFinder(schema.$ref, location) - schema = location.schema - } - - if (schema.type === undefined) { - schema.type = inferTypeByKeyword(schema) - } - - const code = buildValue('main', 'input', location) + const location = { schema, schemaId, jsonPointer: '#' } + const code = buildValue(location, 'input') const contextFunctionCode = ` function main (input) { @@ -149,7 +181,6 @@ function build (schema, options) { contextFunctions = null arrayItemsReferenceSerializersMap.clear() objectReferenceSerializersMap.clear() - schemaReferenceMap.clear() return stringifyFunc } @@ -230,10 +261,11 @@ function addPatternProperties (location) { if (properties[keys[i]]) continue ` - Object.keys(pp).forEach((regex, index) => { - let ppLocation = mergeLocation(location, { schema: pp[regex] }) + const patternPropertiesLocation = mergeLocation(location, 'patternProperties') + Object.keys(pp).forEach((regex) => { + let ppLocation = mergeLocation(patternPropertiesLocation, regex) if (pp[regex].$ref) { - ppLocation = refFinder(pp[regex].$ref, location) + ppLocation = resolveRef(ppLocation, pp[regex].$ref) pp[regex] = ppLocation.schema } @@ -243,7 +275,7 @@ function addPatternProperties (location) { throw new Error(`${err.message}. Found at ${regex} matching ${JSON.stringify(pp[regex])}`) } - const valueCode = buildValue('', 'obj[keys[i]]', ppLocation) + const valueCode = buildValue(ppLocation, 'obj[keys[i]]') code += ` if (/${regex.replace(/\\*\//g, '\\/')}/.test(keys[i])) { ${addComma} @@ -276,13 +308,13 @@ function additionalProperty (location) { return code } - let apLocation = mergeLocation(location, { schema: ap }) + let apLocation = mergeLocation(location, 'additionalProperties') if (ap.$ref) { - apLocation = refFinder(ap.$ref, location) + apLocation = resolveRef(location, ap.$ref) ap = apLocation.schema } - const valueCode = buildValue('', 'obj[keys[i]]', apLocation) + const valueCode = buildValue(apLocation, 'obj[keys[i]]') code += ` ${addComma} @@ -304,140 +336,9 @@ function addAdditionalProperties (location) { ` } -function idFinder (schema, searchedId) { - let objSchema - const explore = (schema, searchedId) => { - Object.keys(schema || {}).forEach((key, i, a) => { - if (key === '$id' && schema[key] === searchedId) { - objSchema = schema - } else if (objSchema === undefined && typeof schema[key] === 'object') { - explore(schema[key], searchedId) - } - }) - } - explore(schema, searchedId) - return objSchema -} - -function refFinder (ref, location) { - const externalSchema = location.externalSchema - let root = location.root - let schema = location.schema - - if (externalSchema && externalSchema[ref]) { - return { - schema: externalSchema[ref], - root: externalSchema[ref], - externalSchema - } - } - - // Split file from walk - ref = ref.split('#') - - // Check schemaReferenceMap for $id entry - if (ref[0] && schemaReferenceMap.has(ref[0])) { - schema = schemaReferenceMap.get(ref[0]) - root = schemaReferenceMap.get(ref[0]) - if (schema.$ref) { - return refFinder(schema.$ref, { - schema, - root, - externalSchema - }) - } - } else if (ref[0]) { // If external file - schema = externalSchema[ref[0]] - root = externalSchema[ref[0]] - - if (schema === undefined) { - findBadKey(externalSchema, [ref[0]]) - } - - if (schema.$ref) { - return refFinder(schema.$ref, { - schema, - root, - externalSchema - }) - } - } - - let code = 'return schema' - // If it has a path - if (ref[1]) { - // ref[1] could contain a JSON pointer - ex: /definitions/num - // or plain name fragment id without suffix # - ex: customId - const walk = ref[1].split('/') - if (walk.length === 1) { - const targetId = `#${ref[1]}` - let dereferenced = idFinder(schema, targetId) - if (dereferenced === undefined && !ref[0]) { - // eslint-disable-next-line - for (var key of Object.keys(externalSchema)) { - dereferenced = idFinder(externalSchema[key], targetId) - if (dereferenced !== undefined) { - root = externalSchema[key] - break - } - } - } - - return { - schema: dereferenced, - root, - externalSchema - } - } else { - // eslint-disable-next-line - for (var i = 1; i < walk.length; i++) { - code += `[${JSON.stringify(walk[i])}]` - } - } - } - let result - try { - result = (new Function('schema', code))(root) - } catch (err) {} - - if (result === undefined && ref[1]) { - const walk = ref[1].split('/') - findBadKey(schema, walk.slice(1)) - } - - if (result.$ref) { - return refFinder(result.$ref, { - schema, - root, - externalSchema - }) - } - - return { - schema: result, - root, - externalSchema - } - - function findBadKey (obj, keys) { - if (keys.length === 0) return null - const key = keys.shift() - if (obj[key] === undefined) { - stringSimilarity = stringSimilarity || require('string-similarity') - const { bestMatch } = stringSimilarity.findBestMatch(key, Object.keys(obj)) - if (bestMatch.rating >= 0.5) { - throw new Error(`Cannot find reference ${JSON.stringify(key)}, did you mean ${JSON.stringify(bestMatch.target)}?`) - } else { - throw new Error(`Cannot find reference ${JSON.stringify(key)}`) - } - } - return findBadKey(obj[key], keys) - } -} - -function buildCode (location, locationPath) { +function buildCode (location) { if (location.schema.$ref) { - location = refFinder(location.schema.$ref, location) + location = resolveRef(location, location.schema.$ref) } const schema = location.schema @@ -445,10 +346,11 @@ function buildCode (location, locationPath) { let code = '' + const propertiesLocation = mergeLocation(location, 'properties') Object.keys(schema.properties || {}).forEach((key) => { - let propertyLocation = mergeLocation(location, { schema: schema.properties[key] }) + let propertyLocation = mergeLocation(propertiesLocation, key) if (schema.properties[key].$ref) { - propertyLocation = refFinder(schema.properties[key].$ref, location) + propertyLocation = resolveRef(location, schema.properties[key].$ref) schema.properties[key] = propertyLocation.schema } @@ -464,7 +366,7 @@ function buildCode (location, locationPath) { json += ${asString} + ':' ` - code += buildValue(locationPath + key, `obj[${JSON.stringify(key)}]`, mergeLocation(propertyLocation, { schema: schema.properties[key] })) + code += buildValue(propertyLocation, `obj[${JSON.stringify(key)}]`) const defaultValue = schema.properties[key].default if (defaultValue !== undefined) { @@ -494,9 +396,14 @@ function buildCode (location, locationPath) { } function mergeAllOfSchema (location, schema, mergedSchema) { - for (let allOfSchema of schema.allOf) { + const allOfLocation = mergeLocation(location, 'allOf') + + for (let i = 0; i < schema.allOf.length; i++) { + let allOfSchema = schema.allOf[i] + if (allOfSchema.$ref) { - allOfSchema = refFinder(allOfSchema.$ref, mergeLocation(location, { schema: allOfSchema })).schema + const allOfSchemaLocation = mergeLocation(allOfLocation, i) + allOfSchema = resolveRef(allOfSchemaLocation, allOfSchema.$ref).schema } let allOfSchemaType = allOfSchema.type @@ -583,9 +490,9 @@ function mergeAllOfSchema (location, schema, mergedSchema) { delete mergedSchema.allOf } -function buildInnerObject (location, locationPath) { +function buildInnerObject (location) { const schema = location.schema - let code = buildCode(location, locationPath) + let code = buildCode(location) if (schema.patternProperties) { code += addPatternProperties(location) } else if (schema.additionalProperties && !schema.patternProperties) { @@ -594,49 +501,44 @@ function buildInnerObject (location, locationPath) { return code } -function addIfThenElse (location, locationPath) { - let code = '' +function addIfThenElse (location) { + const schema = merge({}, location.schema) + const thenSchema = schema.then + const elseSchema = schema.else || { additionalProperties: true } - const schema = location.schema - const copy = merge({}, schema) - const i = copy.if - const then = copy.then - const e = copy.else ? copy.else : { additionalProperties: true } - delete copy.if - delete copy.then - delete copy.else - let merged = merge(copy, then) - let mergedLocation = mergeLocation(location, { schema: merged }) - - const schemaKey = i.$id || randomUUID() - ajvInstance.addSchema(i, schemaKey) + delete schema.if + delete schema.then + delete schema.else - code += ` - valid = ajv.validate("${schemaKey}", obj) - if (valid) { + const ifLocation = mergeLocation(location, 'if') + const ifSchemaRef = ifLocation.schemaId + ifLocation.jsonPointer + + let code = ` + if (ajv.validate("${ifSchemaRef}", obj)) { ` - if (merged.if && merged.then) { - code += addIfThenElse(mergedLocation, locationPath + 'Then') - } - code += buildInnerObject(mergedLocation, locationPath + 'Then') + const thenLocation = mergeLocation(location, 'then') + thenLocation.schema = merge(schema, thenSchema) + if (thenSchema.if && thenSchema.then) { + code += addIfThenElse(thenLocation) + } + code += buildInnerObject(thenLocation) code += ` } ` - merged = merge(copy, e) - mergedLocation = mergeLocation(mergedLocation, { schema: merged }) + + const elseLocation = mergeLocation(location, 'else') + elseLocation.schema = merge(schema, elseSchema) code += ` else { ` - if (merged.if && merged.then) { - code += addIfThenElse(mergedLocation, locationPath + 'Else') + if (elseSchema.if && elseSchema.then) { + code += addIfThenElse(elseLocation) } - - code += buildInnerObject(mergedLocation, locationPath + 'Else') - + code += buildInnerObject(elseLocation) code += ` } ` @@ -650,11 +552,8 @@ function toJSON (variableName) { ` } -function buildObject (location, locationPath) { +function buildObject (location) { const schema = location.schema - if (schema.$id !== undefined) { - schemaReferenceMap.set(schema.$id, schema) - } if (objectReferenceSerializersMap.has(schema)) { return objectReferenceSerializersMap.get(schema) @@ -665,7 +564,7 @@ function buildObject (location, locationPath) { let functionCode = ` function ${functionName} (input) { - // ${locationPath} + // ${location.schemaId + location.jsonPointer} ` if (schema.nullable) { functionCode += ` @@ -683,12 +582,9 @@ function buildObject (location, locationPath) { let rCode if (schema.if && schema.then) { - functionCode += ` - var valid - ` - rCode = addIfThenElse(location, locationPath) + rCode = addIfThenElse(location) } else { - rCode = buildInnerObject(location, locationPath) + rCode = buildInnerObject(location) } // Removes the comma if is the last element of the string (in case there are not properties) @@ -702,17 +598,16 @@ function buildObject (location, locationPath) { return functionName } -function buildArray (location, locationPath) { +function buildArray (location) { let schema = location.schema - if (schema.$id !== undefined) { - schemaReferenceMap.set(schema.$id, schema) - } // default to any items type if (!schema.items) { schema.items = {} } + let itemsLocation = mergeLocation(location, 'items') + if (schema.items.$ref) { if (!schema[fjsCloned]) { location.schema = clone(location.schema) @@ -720,7 +615,8 @@ function buildArray (location, locationPath) { schema[fjsCloned] = true } - location = refFinder(schema.items.$ref, location) + location = resolveRef(location, schema.items.$ref) + itemsLocation = location schema.items = location.schema } @@ -733,7 +629,7 @@ function buildArray (location, locationPath) { let functionCode = ` function ${functionName} (obj) { - // ${locationPath} + // ${location.schemaId + location.jsonPointer} ` if (schema.nullable) { @@ -771,11 +667,10 @@ function buildArray (location, locationPath) { let jsonOutput = '' ` - const accessor = '[i]' if (Array.isArray(schema.items)) { for (let i = 0; i < schema.items.length; i++) { const item = schema.items[i] - const tmpRes = buildValue(locationPath + accessor + i, `obj[${i}]`, mergeLocation(location, { schema: item })) + const tmpRes = buildValue(mergeLocation(itemsLocation, i), `obj[${i}]`) functionCode += ` if (${i} < arrayLength) { if (${buildArrayTypeCondition(item.type, `[${i}]`)}) { @@ -803,7 +698,7 @@ function buildArray (location, locationPath) { }` } } else { - const code = buildValue(locationPath + accessor, 'obj[i]', mergeLocation(location, { schema: schema.items })) + const code = buildValue(itemsLocation, 'obj[i]') functionCode += ` for (let i = 0; i < arrayLength; i++) { let json = '' @@ -860,40 +755,17 @@ function buildArrayTypeCondition (type, accessor) { return condition } -function dereferenceOfRefs (location, type) { - if (!location.schema[fjsCloned]) { - const schemaClone = clone(location.schema) - schemaClone[fjsCloned] = true - location.schema = schemaClone - } - - const schema = location.schema - const locations = [] - - schema[type].forEach((s, index) => { - // follow the refs - let sLocation = mergeLocation(location, { schema: s }) - while (s.$ref) { - sLocation = refFinder(s.$ref, sLocation) - schema[type][index] = sLocation.schema - s = schema[type][index] - } - locations[index] = sLocation - }) - - return locations -} - let genFuncNameCounter = 0 function generateFuncName () { return 'anonymous' + genFuncNameCounter++ } -function buildValue (locationPath, input, location) { +function buildValue (location, input) { let schema = location.schema if (schema.$ref) { - schema = refFinder(schema.$ref, location) + location = resolveRef(location, schema.$ref) + schema = location.schema } if (schema.type === undefined) { @@ -940,41 +812,32 @@ function buildValue (locationPath, input, location) { code += `json += ${funcName}(${input})` break case 'object': - funcName = buildObject(location, locationPath) + funcName = buildObject(location) code += `json += ${funcName}(${input})` break case 'array': - funcName = buildArray(location, locationPath) + funcName = buildArray(location) code += `json += ${funcName}(${input})` break case undefined: - if (schema.anyOf || schema.oneOf) { + if (schema.fjs_date_type) { + funcName = getStringSerializer(schema.fjs_date_type, nullable) + code += `json += ${funcName}(${input})` + break + } else if (schema.anyOf || schema.oneOf) { // beware: dereferenceOfRefs has side effects and changes schema.anyOf - const locations = dereferenceOfRefs(location, schema.anyOf ? 'anyOf' : 'oneOf') - locations.forEach((location, index) => { - const nestedResult = buildValue(locationPath + 'i' + index, input, location) - // Since we are only passing the relevant schema to ajv.validate, it needs to be full dereferenced - // otherwise any $ref pointing to an external schema would result in an error. - // Full dereference of the schema happens as side effect of two functions: - // 1. `dereferenceOfRefs` loops through the `schema.anyOf`` array and replaces any top level reference - // with the actual schema - // 2. `buildValue`, through `buildCode`, replaces any reference in object properties with the actual schema - // (see https://github.com/fastify/fast-json-stringify/blob/6da3b3e8ac24b1ca5578223adedb4083b7adf8db/index.js#L631) - - // 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_date_type. - // (see https://github.com/fastify/fast-json-stringify/pull/441) - const extendedSchema = clone(location.schema) - extendDateTimeType(extendedSchema) - - const schemaKey = location.schema.$id || randomUUID() - ajvInstance.addSchema(extendedSchema, schemaKey) + const type = schema.anyOf ? 'anyOf' : 'oneOf' + const anyOfLocation = mergeLocation(location, type) + for (let index = 0; index < location.schema[type].length; index++) { + const optionLocation = mergeLocation(anyOfLocation, index) + const schemaRef = optionLocation.schemaId + optionLocation.jsonPointer + const nestedResult = buildValue(optionLocation, input) code += ` - ${index === 0 ? 'if' : 'else if'}(ajv.validate("${schemaKey}", ${input})) + ${index === 0 ? 'if' : 'else if'}(ajv.validate("${schemaRef}", ${input})) ${nestedResult} ` - }) + } code += ` else throw new Error(\`The value $\{JSON.stringify(${input})} does not match schema definition.\`) @@ -1011,10 +874,11 @@ function buildValue (locationPath, input, location) { } else {` } + const locationClone = clone(location) sortedTypes.forEach((type, index) => { const statement = index === 0 ? 'if' : 'else if' - const tempSchema = Object.assign({}, schema, { type }) - const nestedResult = buildValue(locationPath, input, mergeLocation(location, { schema: tempSchema })) + locationClone.schema.type = type + const nestedResult = buildValue(locationClone, input) switch (type) { case 'string': { code += ` @@ -1063,7 +927,12 @@ function buildValue (locationPath, input, location) { 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_date_type. +// (see https://github.com/fastify/fast-json-stringify/pull/441) function extendDateTimeType (schema) { + if (schema === null) return + if (schema.type === 'string' && ['date-time', 'date', 'time'].includes(schema.format)) { schema.fjs_date_type = schema.format delete schema.type diff --git a/package.json b/package.json index fbb49fbb..4a76656a 100644 --- a/package.json +++ b/package.json @@ -53,9 +53,8 @@ "ajv": "^8.10.0", "ajv-formats": "^2.1.1", "deepmerge": "^4.2.2", - "fast-uri": "^2.0.0", - "rfdc": "^1.2.0", - "string-similarity": "^4.0.1" + "fast-uri": "^2.1.0", + "rfdc": "^1.2.0" }, "engines": { "node": ">= 10.0.0" diff --git a/test/allof.test.js b/test/allof.test.js index 0db1b4ea..019076fe 100644 --- a/test/allof.test.js +++ b/test/allof.test.js @@ -402,12 +402,14 @@ test('object with external $refs in allOf', (t) => { } }, second: { - id2: { - $id: '#id2', - type: 'object', - properties: { - id2: { - type: 'integer' + definitions: { + id2: { + $id: '#id2', + type: 'object', + properties: { + id2: { + type: 'integer' + } } } } @@ -422,7 +424,7 @@ test('object with external $refs in allOf', (t) => { $ref: 'first#/definitions/id1' }, { - $ref: 'second#id2' + $ref: 'second#/definitions/id2' } ] } diff --git a/test/ref.test.js b/test/ref.test.js index fbf9d117..f84b9190 100644 --- a/test/ref.test.js +++ b/test/ref.test.js @@ -431,10 +431,215 @@ test('ref external - plain name fragment', (t) => { type: 'object', properties: { first: { - $ref: '#first-schema' + $ref: 'first#first-schema' }, second: { - $ref: '#second-schema' + $ref: 'second#second-schema' + } + } + } + + const object = { + first: { + str: 'test' + }, + second: { + int: 42 + } + } + + const stringify = build(schema, { schema: externalSchema }) + const output = stringify(object) + + JSON.parse(output) + t.pass() + + t.equal(output, '{"first":{"str":"test"},"second":{"int":42}}') +}) + +test('external reference to $id', (t) => { + t.plan(2) + + const externalSchema = { + first: { + $id: 'external-reference', + type: 'object', + properties: { + str: { + type: 'string' + } + } + } + } + + const schema = { + type: 'object', + properties: { + first: { + $ref: 'external-reference' + } + } + } + + const object = { first: { str: 'test' } } + + const stringify = build(schema, { schema: externalSchema }) + const output = stringify(object) + + JSON.parse(output) + t.pass() + + t.equal(output, '{"first":{"str":"test"}}') +}) + +test('external reference to key#id', (t) => { + t.plan(2) + + const externalSchema = { + first: { + $id: '#external-reference', + type: 'object', + properties: { + str: { + type: 'string' + } + } + } + } + + const schema = { + type: 'object', + properties: { + first: { + $ref: 'first#external-reference' + } + } + } + + const object = { first: { str: 'test' } } + + const stringify = build(schema, { schema: externalSchema }) + const output = stringify(object) + + JSON.parse(output) + t.pass() + + t.equal(output, '{"first":{"str":"test"}}') +}) + +test('external and inner reference', (t) => { + t.plan(2) + + const externalSchema = { + first: { + $id: 'external-reference', + $ref: '#external-reference', + definitions: { + inner: { + $id: '#external-reference', + type: 'object', + properties: { + str: { + type: 'string' + } + } + } + } + } + } + + const schema = { + type: 'object', + properties: { + first: { + $ref: 'external-reference' + } + } + } + + const object = { first: { str: 'test' } } + + const stringify = build(schema, { schema: externalSchema }) + const output = stringify(object) + + JSON.parse(output) + t.pass() + + t.equal(output, '{"first":{"str":"test"}}') +}) + +test('external reference to key', (t) => { + t.plan(2) + + const externalSchema = { + first: { + $id: 'external-reference', + type: 'object', + properties: { + str: { + type: 'string' + } + } + } + } + + const schema = { + type: 'object', + properties: { + first: { + $ref: 'first' + } + } + } + + const object = { first: { str: 'test' } } + + const stringify = build(schema, { schema: externalSchema }) + const output = stringify(object) + + JSON.parse(output) + t.pass() + + t.equal(output, '{"first":{"str":"test"}}') +}) + +test('ref external - plain name fragment', (t) => { + t.plan(2) + + const externalSchema = { + first: { + $id: 'first-schema', + type: 'object', + properties: { + str: { + type: 'string' + } + } + }, + second: { + definitions: { + second: { + $id: 'second-schema', + type: 'object', + properties: { + int: { + type: 'integer' + } + } + } + } + } + } + + const schema = { + title: 'object with $ref to external plain name fragment', + type: 'object', + properties: { + first: { + $ref: 'first-schema' + }, + second: { + $ref: 'second-schema' } } } @@ -503,7 +708,7 @@ test('ref external - duplicate plain name fragment', (t) => { $ref: 'external#duplicateSchema' }, other: { - $ref: '#otherSchema' + $ref: 'other#otherSchema' } } } @@ -893,7 +1098,7 @@ test('ref in root external multiple times', (t) => { const schema = { title: 'object with $ref in root schema', type: 'object', - $ref: 'numbers#/definitions/num' + $ref: 'numbers' } const object = { int: 42 } @@ -978,32 +1183,6 @@ test('ref to nested ref definition', (t) => { t.equal(output, '{"foo":"foo"}') }) -test('ref in definition with exact match', (t) => { - t.plan(2) - - const externalSchema = { - '#/definitions/foo': { - type: 'string' - } - } - - const schema = { - type: 'object', - properties: { - foo: { $ref: '#/definitions/foo' } - } - } - - const object = { foo: 'foo' } - const stringify = build(schema, { schema: externalSchema }) - const output = stringify(object) - - JSON.parse(output) - t.pass() - - t.equal(output, '{"foo":"foo"}') -}) - test('Bad key', t => { t.test('Find match', t => { t.plan(1) @@ -1026,7 +1205,7 @@ test('Bad key', t => { }) t.fail('Should throw') } catch (err) { - t.equal(err.message, 'Cannot find reference "porjectId", did you mean "projectId"?') + t.equal(err.message, 'Cannot find reference "#/definitions/porjectId"') } }) @@ -1051,7 +1230,7 @@ test('Bad key', t => { }) t.fail('Should throw') } catch (err) { - t.equal(err.message, 'Cannot find reference "foobar"') + t.equal(err.message, 'Cannot find reference "#/definitions/foobar"') } }) @@ -1081,7 +1260,7 @@ test('Bad key', t => { }) t.fail('Should throw') } catch (err) { - t.equal(err.message, 'Cannot find reference "porjectId", did you mean "projectId"?') + t.equal(err.message, 'Cannot find reference "external#/definitions/porjectId"') } }) @@ -1111,7 +1290,7 @@ test('Bad key', t => { }) t.fail('Should throw') } catch (err) { - t.equal(err.message, 'Cannot find reference "foobar"') + t.equal(err.message, 'Cannot find reference "external#/definitions/foobar"') } }) @@ -1141,7 +1320,7 @@ test('Bad key', t => { }) t.fail('Should throw') } catch (err) { - t.equal(err.message, 'Cannot find reference "deifnitions", did you mean "definitions"?') + t.equal(err.message, 'Cannot find reference "external#/deifnitions/projectId"') } }) @@ -1166,7 +1345,7 @@ test('Bad key', t => { }) t.fail('Should throw') } catch (err) { - t.equal(err.message, 'Cannot find reference "deifnitions", did you mean "definitions"?') + t.equal(err.message, 'Cannot find reference "#/deifnitions/projectId"') } }) @@ -1196,7 +1375,7 @@ test('Bad key', t => { }) t.fail('Should throw') } catch (err) { - t.equal(err.message, 'Cannot find reference "extrenal", did you mean "external"?') + t.equal(err.message, 'Cannot find reference "extrenal#/definitions/projectId"') } }) From ac7fd4d3463bb85bce334de9fcdf6a4f3a748a3d Mon Sep 17 00:00:00 2001 From: ivan-tymoshenko Date: Mon, 13 Jun 2022 13:22:20 +0300 Subject: [PATCH 2/7] add tests for #350 and #417 --- test/ref.test.js | 327 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 327 insertions(+) diff --git a/test/ref.test.js b/test/ref.test.js index f84b9190..d38ba70f 100644 --- a/test/ref.test.js +++ b/test/ref.test.js @@ -1428,3 +1428,330 @@ test('Regression 2.5.2', t => { t.equal(output, '[{"field":"parent","sub":{"field":"joined"}}]') }) + +test('Reference through multiple definitions', (t) => { + t.plan(2) + + const schema = { + $ref: '#/definitions/A', + definitions: { + A: { + type: 'object', + additionalProperties: false, + properties: { a: { anyOf: [{ $ref: '#/definitions/B' }] } }, + required: ['a'] + }, + B: { + type: 'object', + properties: { b: { anyOf: [{ $ref: '#/definitions/C' }] } }, + required: ['b'], + additionalProperties: false + }, + C: { + type: 'object', + properties: { c: { type: 'string', const: 'd' } }, + required: ['c'], + additionalProperties: false + } + } + } + + const object = { a: { b: { c: 'd' } } } + + const stringify = build(schema) + const output = stringify(object) + + JSON.parse(output) + t.pass() + + t.equal(output, JSON.stringify(object)) +}) + +test('issue #350', (t) => { + t.plan(2) + + const schema = { + title: 'Example Schema', + type: 'object', + properties: { + firstName: { $ref: '#foo' }, + lastName: { $ref: '#foo' }, + nested: { + type: 'object', + properties: { + firstName: { $ref: '#foo' }, + lastName: { $ref: '#foo' } + } + } + }, + definitions: { + foo: { + $id: '#foo', + type: 'string' + } + } + } + + const object = { + firstName: 'Matteo', + lastName: 'Collina', + nested: { + firstName: 'Matteo', + lastName: 'Collina' + } + } + + const stringify = build(schema) + const output = stringify(object) + + JSON.parse(output) + t.pass() + + t.equal(output, JSON.stringify(object)) +}) + +test('deep union type', (t) => { + const stringify = build({ + schema: { + type: 'array', + items: { + oneOf: [ + { + $ref: 'components#/schemas/IDirectory' + }, + { + $ref: 'components#/schemas/IImageFile' + }, + { + $ref: 'components#/schemas/ITextFile' + }, + { + $ref: 'components#/schemas/IZipFile' + } + ] + }, + nullable: false + }, + components: { + schemas: { + IDirectory: { + $id: 'IDirectory', + $recursiveAnchor: true, + type: 'object', + properties: { + children: { + type: 'array', + items: { + oneOf: [ + { + $recursiveRef: '#' + }, + { + $ref: 'components#/schemas/IImageFile' + }, + { + $ref: 'components#/schemas/ITextFile' + }, + { + $ref: 'components#/schemas/IZipFile' + } + ] + }, + nullable: false + }, + type: { + type: 'string', + nullable: false + }, + id: { + type: 'string', + nullable: false + }, + name: { + type: 'string', + nullable: false + } + }, + nullable: false, + required: [ + 'children', + 'type', + 'id', + 'name' + ] + }, + IImageFile: { + $id: 'IImageFile', + type: 'object', + properties: { + width: { + type: 'number', + nullable: false + }, + height: { + type: 'number', + nullable: false + }, + url: { + type: 'string', + nullable: false + }, + extension: { + type: 'string', + nullable: false + }, + size: { + type: 'number', + nullable: false + }, + type: { + type: 'string', + nullable: false + }, + id: { + type: 'string', + nullable: false + }, + name: { + type: 'string', + nullable: false + } + }, + nullable: false, + required: [ + 'width', + 'height', + 'url', + 'extension', + 'size', + 'type', + 'id', + 'name' + ] + }, + ITextFile: { + $id: 'ITextFile', + type: 'object', + properties: { + content: { + type: 'string', + nullable: false + }, + extension: { + type: 'string', + nullable: false + }, + size: { + type: 'number', + nullable: false + }, + type: { + type: 'string', + nullable: false + }, + id: { + type: 'string', + nullable: false + }, + name: { + type: 'string', + nullable: false + } + }, + nullable: false, + required: [ + 'content', + 'extension', + 'size', + 'type', + 'id', + 'name' + ] + }, + IZipFile: { + $id: 'IZipFile', + type: 'object', + properties: { + files: { + type: 'number', + nullable: false + }, + extension: { + type: 'string', + nullable: false + }, + size: { + type: 'number', + nullable: false + }, + type: { + type: 'string', + nullable: false + }, + id: { + type: 'string', + nullable: false + }, + name: { + type: 'string', + nullable: false + } + }, + nullable: false, + required: [ + 'files', + 'extension', + 'size', + 'type', + 'id', + 'name' + ] + } + } + } + }) + + const obj = [ + { + type: 'directory', + id: '7b1068a4-dd6e-474a-8d85-09a2d77639cb', + name: 'ixcWGOKI', + children: [ + { + type: 'directory', + id: '5883e17c-b207-46d4-ad2d-be72249711ce', + name: 'vecQwFGS', + children: [] + }, + { + type: 'file', + id: '670b6556-a610-4a48-8a16-9c2da97a0d18', + name: 'eStFddzX', + extension: 'jpg', + size: 7, + width: 300, + height: 1200, + url: 'https://github.com/samchon/typescript-json' + }, + { + type: 'file', + id: '85dc796d-9593-4833-b1a1-addc8ebf74ea', + name: 'kTdUfwRJ', + extension: 'ts', + size: 86, + content: 'console.log("Hello world");' + }, + { + type: 'file', + id: '8933c86a-7a1e-4d4a-b0a6-17d6896fdf89', + name: 'NBPkefUG', + extension: 'zip', + size: 22, + files: 20 + } + ] + } + ] + t.equal(JSON.stringify(obj), stringify(obj)) + t.autoend() +}) From 04defe18857100869f40b6d1785417dd14376b12 Mon Sep 17 00:00:00 2001 From: ivan-tymoshenko Date: Mon, 13 Jun 2022 19:16:30 +0300 Subject: [PATCH 3/7] Don't print root schema id --- index.js | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/index.js b/index.js index 44843890..7d0ab477 100644 --- a/index.js +++ b/index.js @@ -81,10 +81,13 @@ function resolveRef (location, ref) { const arrayItemsReferenceSerializersMap = new Map() const objectReferenceSerializersMap = new Map() +let rootSchemaId = null let ajvInstance = null let contextFunctions = null function build (schema, options) { + schema = clone(schema) + arrayItemsReferenceSerializersMap.clear() objectReferenceSerializersMap.clear() @@ -92,12 +95,11 @@ function build (schema, options) { options = options || {} ajvInstance = buildAjv(options.ajv) - - const schemaId = schema.$id || randomUUID() + rootSchemaId = schema.$id || randomUUID() isValidSchema(schema) extendDateTimeType(schema) - ajvInstance.addSchema(schema, schemaId) + ajvInstance.addSchema(schema, rootSchemaId) if (options.schema) { for (const key of Object.keys(options.schema)) { @@ -107,15 +109,23 @@ function build (schema, options) { if (externalSchema.$id !== undefined) { if (externalSchema.$id[0] === '#') { - ajvInstance.addSchema(externalSchema, key + externalSchema.$id) + if (ajvInstance.getSchema(key + externalSchema.$id) === undefined) { + ajvInstance.addSchema(externalSchema, key + externalSchema.$id) + } } else { - ajvInstance.addSchema(externalSchema) + if (ajvInstance.getSchema(externalSchema.$id) === undefined) { + ajvInstance.addSchema(externalSchema) + } if (externalSchema.$id !== key) { - ajvInstance.addSchema({ $ref: externalSchema.$id }, key) + if (ajvInstance.getSchema(key) === undefined) { + ajvInstance.addSchema({ $ref: externalSchema.$id }, key) + } } } } else { - ajvInstance.addSchema(externalSchema, key) + if (ajvInstance.getSchema(key) === undefined) { + ajvInstance.addSchema(externalSchema, key) + } } } } @@ -144,7 +154,7 @@ function build (schema, options) { const serializer = new Serializer(options) - const location = { schema, schemaId, jsonPointer: '#' } + const location = { schema, schemaId: rootSchemaId, jsonPointer: '#' } const code = buildValue(location, 'input') const contextFunctionCode = ` @@ -178,6 +188,7 @@ function build (schema, options) { const stringifyFunc = contextFunc(ajvInstance, serializer) ajvInstance = null + rootSchemaId = null contextFunctions = null arrayItemsReferenceSerializersMap.clear() objectReferenceSerializersMap.clear() @@ -562,9 +573,10 @@ function buildObject (location) { const functionName = generateFuncName() objectReferenceSerializersMap.set(schema, functionName) + const schemaId = location.schemaId === rootSchemaId ? '' : location.schemaId let functionCode = ` function ${functionName} (input) { - // ${location.schemaId + location.jsonPointer} + // ${schemaId + location.jsonPointer} ` if (schema.nullable) { functionCode += ` @@ -627,9 +639,10 @@ function buildArray (location) { const functionName = generateFuncName() arrayItemsReferenceSerializersMap.set(schema.items, functionName) + const schemaId = location.schemaId === rootSchemaId ? '' : location.schemaId let functionCode = ` function ${functionName} (obj) { - // ${location.schemaId + location.jsonPointer} + // ${schemaId + location.jsonPointer} ` if (schema.nullable) { From f610099ed830e39f2a3f1c19b89d203114734061 Mon Sep 17 00:00:00 2001 From: ivan-tymoshenko Date: Thu, 16 Jun 2022 16:06:06 +0300 Subject: [PATCH 4/7] fix: remove schema setting by $id and key at the same time --- index.js | 19 ++++--------------- test/ref.test.js | 13 +++++++------ 2 files changed, 11 insertions(+), 21 deletions(-) diff --git a/index.js b/index.js index 7d0ab477..dc0700e0 100644 --- a/index.js +++ b/index.js @@ -108,24 +108,13 @@ function build (schema, options) { extendDateTimeType(externalSchema) if (externalSchema.$id !== undefined) { - if (externalSchema.$id[0] === '#') { - if (ajvInstance.getSchema(key + externalSchema.$id) === undefined) { - ajvInstance.addSchema(externalSchema, key + externalSchema.$id) - } + if (externalSchema.$id[0] === '#') { // relative URI + ajvInstance.addSchema(externalSchema, key + externalSchema.$id) } else { - if (ajvInstance.getSchema(externalSchema.$id) === undefined) { - ajvInstance.addSchema(externalSchema) - } - if (externalSchema.$id !== key) { - if (ajvInstance.getSchema(key) === undefined) { - ajvInstance.addSchema({ $ref: externalSchema.$id }, key) - } - } + ajvInstance.addSchema(externalSchema) } } else { - if (ajvInstance.getSchema(key) === undefined) { - ajvInstance.addSchema(externalSchema, key) - } + ajvInstance.addSchema(externalSchema, key) } } } diff --git a/test/ref.test.js b/test/ref.test.js index d38ba70f..071b4cad 100644 --- a/test/ref.test.js +++ b/test/ref.test.js @@ -532,11 +532,11 @@ test('external and inner reference', (t) => { const externalSchema = { first: { - $id: 'external-reference', - $ref: '#external-reference', + $id: 'reference', + $ref: '#reference', definitions: { inner: { - $id: '#external-reference', + $id: '#reference', type: 'object', properties: { str: { @@ -552,7 +552,7 @@ test('external and inner reference', (t) => { type: 'object', properties: { first: { - $ref: 'external-reference' + $ref: 'reference' } } } @@ -587,7 +587,7 @@ test('external reference to key', (t) => { type: 'object', properties: { first: { - $ref: 'first' + $ref: 'external-reference' } } } @@ -1511,6 +1511,8 @@ test('issue #350', (t) => { }) test('deep union type', (t) => { + t.plan(1) + const stringify = build({ schema: { type: 'array', @@ -1753,5 +1755,4 @@ test('deep union type', (t) => { } ] t.equal(JSON.stringify(obj), stringify(obj)) - t.autoend() }) From 3b7a45c664ef242326bbca040ae0b0a67fad031e Mon Sep 17 00:00:00 2001 From: ivan-tymoshenko Date: Thu, 16 Jun 2022 22:18:22 +0300 Subject: [PATCH 5/7] fix: clone external schemas --- index.js | 2 ++ test/ref.test.js | 66 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/index.js b/index.js index dc0700e0..c1ea5a19 100644 --- a/index.js +++ b/index.js @@ -102,6 +102,8 @@ function build (schema, options) { ajvInstance.addSchema(schema, rootSchemaId) if (options.schema) { + options.schema = clone(options.schema) + for (const key of Object.keys(options.schema)) { const externalSchema = options.schema[key] isValidSchema(externalSchema, key) diff --git a/test/ref.test.js b/test/ref.test.js index 071b4cad..ccec54c5 100644 --- a/test/ref.test.js +++ b/test/ref.test.js @@ -1756,3 +1756,69 @@ test('deep union type', (t) => { ] t.equal(JSON.stringify(obj), stringify(obj)) }) + +test('ref with same id in properties', (t) => { + t.plan(2) + + const externalSchema = { + ObjectId: { + $id: 'ObjectId', + type: 'string' + }, + File: { + $id: 'File', + type: 'object', + properties: { + _id: { $ref: 'ObjectId' }, + name: { type: 'string' }, + owner: { $ref: 'ObjectId' } + } + } + } + + t.test('anyOf', (t) => { + t.plan(1) + + const schema = { + $id: 'Article', + type: 'object', + properties: { + _id: { $ref: 'ObjectId' }, + image: { + anyOf: [ + { $ref: 'File' }, + { type: 'null' } + ] + } + } + } + + const stringify = build(schema, { schema: externalSchema }) + const output = stringify({ _id: 'foo', image: { _id: 'bar', name: 'hello', owner: 'baz' } }) + + t.equal(output, '{"_id":"foo","image":{"_id":"bar","name":"hello","owner":"baz"}}') + }) + + t.test('oneOf', (t) => { + t.plan(1) + + const schema = { + $id: 'Article', + type: 'object', + properties: { + _id: { $ref: 'ObjectId' }, + image: { + oneOf: [ + { $ref: 'File' }, + { type: 'null' } + ] + } + } + } + + const stringify = build(schema, { schema: externalSchema }) + const output = stringify({ _id: 'foo', image: { _id: 'bar', name: 'hello', owner: 'baz' } }) + + t.equal(output, '{"_id":"foo","image":{"_id":"bar","name":"hello","owner":"baz"}}') + }) +}) From 0fad6c7f2eb451a30c65ab156b56a92ecfa51b7e Mon Sep 17 00:00:00 2001 From: ivan-tymoshenko Date: Sun, 19 Jun 2022 18:32:20 +0300 Subject: [PATCH 6/7] Disable ajv schema validation --- ajv.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ajv.js b/ajv.js index 80194fea..b926c392 100644 --- a/ajv.js +++ b/ajv.js @@ -7,7 +7,7 @@ const ajvFormats = require('ajv-formats') module.exports = buildAjv function buildAjv (options) { - const ajvInstance = new Ajv({ ...options, strictSchema: false, uriResolver: fastUri }) + const ajvInstance = new Ajv({ ...options, strictSchema: false, validateSchema: false, uriResolver: fastUri }) ajvFormats(ajvInstance) const validateDateTimeFormat = ajvFormats.get('date-time').validate From 8a86160d2b3fd41a81efa636ea94e86520d443aa Mon Sep 17 00:00:00 2001 From: ivan-tymoshenko Date: Sun, 19 Jun 2022 19:44:02 +0300 Subject: [PATCH 7/7] Check is external schema exist --- index.js | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/index.js b/index.js index c1ea5a19..8f08d874 100644 --- a/index.js +++ b/index.js @@ -109,14 +109,13 @@ function build (schema, options) { isValidSchema(externalSchema, key) extendDateTimeType(externalSchema) - if (externalSchema.$id !== undefined) { - if (externalSchema.$id[0] === '#') { // relative URI - ajvInstance.addSchema(externalSchema, key + externalSchema.$id) - } else { - ajvInstance.addSchema(externalSchema) - } - } else { - ajvInstance.addSchema(externalSchema, key) + let schemaKey = externalSchema.$id || key + if (externalSchema.$id !== undefined && externalSchema.$id[0] === '#') { + schemaKey = key + externalSchema.$id // relative URI + } + + if (ajvInstance.getSchema(schemaKey) === undefined) { + ajvInstance.addSchema(externalSchema, schemaKey) } } }