diff --git a/index.js b/index.js index 60c9ff10..12768492 100644 --- a/index.js +++ b/index.js @@ -13,7 +13,6 @@ const { randomUUID } = require('crypto') const validate = require('./schema-validator') let largeArraySize = 2e4 -let stringSimilarity = null let largeArrayMechanism = 'default' const validLargeArrayMechanisms = [ 'default', @@ -45,6 +44,7 @@ function isValidSchema (schema, name) { function mergeLocation (source, dest) { return { schema: dest.schema || source.schema, + schemaRef: dest.schemaRef || source.schemaRef, root: dest.root || source.root, externalSchema: dest.externalSchema || source.externalSchema } @@ -55,6 +55,7 @@ const objectReferenceSerializersMap = new Map() const schemaReferenceMap = new Map() let ajvInstance = null +let schemaRefResolver = null class Serializer { constructor (options = {}) { @@ -223,6 +224,40 @@ class Serializer { } } +function getSchema (ref, location) { + let ajvSchema + let schemaRef + + if (ref[0] === '#') { + schemaRef = location.schemaRef + ref + } else { + schemaRef = ref + location.schemaRef = ref.split('#')[0] + } + + try { + ajvSchema = schemaRefResolver.getSchema(schemaRef) + } catch (error) { + throw new Error(`Cannot find reference "${ref}"`) + } + + if (ajvSchema === undefined) { + throw new Error(`Cannot find reference "${ref}"`) + } + + let schema = ajvSchema.schema + if (schema.$ref !== undefined) { + schema = getSchema(schema.$ref, location).schema + } + + return { + root: schema, + schema, + schemaRef: location.schemaRef, + externalSchema: location.externalSchema + } +} + function build (schema, options) { arrayItemsReferenceSerializersMap.clear() objectReferenceSerializersMap.clear() @@ -256,11 +291,28 @@ function build (schema, options) { } }) + schemaRefResolver = new Ajv() + const mainSchemaRef = schema.$id || randomUUID() + isValidSchema(schema) + schemaRefResolver.addSchema(schema, mainSchemaRef) 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) + + if (externalSchema.$id !== undefined) { + if (externalSchema.$id[0] === '#') { + schemaRefResolver.addSchema(externalSchema, key + externalSchema.$id) + } else { + schemaRefResolver.addSchema(externalSchema) + if (externalSchema.$id !== key) { + schemaRefResolver.addSchema({ $ref: externalSchema.$id }, key) + } + } + } else { + schemaRefResolver.addSchema(externalSchema, key) + } } } @@ -290,12 +342,13 @@ function build (schema, options) { let location = { schema, + schemaRef: mainSchemaRef, root: schema, externalSchema: options.schema } if (schema.$ref) { - location = refFinder(schema.$ref, location) + location = getSchema(schema.$ref, location) schema = location.schema } @@ -326,6 +379,7 @@ function build (schema, options) { const stringifyFunc = contextFunc(ajvInstance, serializer) ajvInstance = null + schemaRefResolver = null arrayItemsReferenceSerializersMap.clear() objectReferenceSerializersMap.clear() schemaReferenceMap.clear() @@ -413,7 +467,7 @@ function addPatternProperties (location) { Object.keys(pp).forEach((regex, index) => { let ppLocation = mergeLocation(location, { schema: pp[regex] }) if (pp[regex].$ref) { - ppLocation = refFinder(pp[regex].$ref, location) + ppLocation = getSchema(pp[regex].$ref, location) pp[regex] = ppLocation.schema } @@ -461,7 +515,7 @@ function additionalProperty (location) { } let apLocation = mergeLocation(location, { schema: ap }) if (ap.$ref) { - apLocation = refFinder(ap.$ref, location) + apLocation = getSchema(ap.$ref, location) ap = apLocation.schema } @@ -490,140 +544,9 @@ function addAdditionalProperties (location) { return { code, laterCode: additionalPropertyCode.laterCode } } -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, code, laterCode, locationPath) { if (location.schema.$ref) { - location = refFinder(location.schema.$ref, location) + location = getSchema(location.schema.$ref, location) } const schema = location.schema @@ -632,7 +555,7 @@ function buildCode (location, code, laterCode, locationPath) { Object.keys(schema.properties || {}).forEach((key) => { let propertyLocation = mergeLocation(location, { schema: schema.properties[key] }) if (schema.properties[key].$ref) { - propertyLocation = refFinder(schema.properties[key].$ref, location) + propertyLocation = getSchema(schema.properties[key].$ref, location) schema.properties[key] = propertyLocation.schema } @@ -682,7 +605,7 @@ function buildCode (location, code, laterCode, locationPath) { function mergeAllOfSchema (location, schema, mergedSchema) { for (let allOfSchema of schema.allOf) { if (allOfSchema.$ref) { - allOfSchema = refFinder(allOfSchema.$ref, mergeLocation(location, { schema: allOfSchema })).schema + allOfSchema = getSchema(allOfSchema.$ref, mergeLocation(location, { schema: allOfSchema })).schema } let allOfSchemaType = allOfSchema.type @@ -934,7 +857,7 @@ function buildArray (location, code, functionName, locationPath) { schema[fjsCloned] = true } - location = refFinder(schema.items.$ref, location) + location = getSchema(schema.items.$ref, location) schema.items = location.schema if (arrayItemsReferenceSerializersMap.has(schema.items)) { @@ -1068,7 +991,7 @@ function dereferenceOfRefs (location, type) { // follow the refs let sLocation = mergeLocation(location, { schema: s }) while (s.$ref) { - sLocation = refFinder(s.$ref, sLocation) + sLocation = getSchema(s.$ref, sLocation) schema[type][index] = sLocation.schema s = schema[type][index] } @@ -1087,7 +1010,7 @@ function buildValue (laterCode, locationPath, input, location) { let schema = location.schema if (schema.$ref) { - schema = refFinder(schema.$ref, location) + schema = getSchema(schema.$ref, location) } if (schema.type === undefined) { 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"') } })